Danh sách bài viết

Bài 41: Checklist & Anti-patterns Rate Limiting — Incident Race Condition

Bài tổng kết Module 3 phân tích một incident race condition thực tế trong rate limiter LLM API (GET-check-INCR), chỉ ra root cause và cách fix atomic bằng Lua. Tiếp theo là top 10 anti-patterns phổ biến, decision matrix giúp chọn thuật toán phù hợp use case, checklist production-ready đầy đủ (correctness, distributed, HTTP headers, operational, security), và cách viết concurrent test để phát hiện race condition trước khi lên production.

28/05/2026
0 lượt xem
1

Mục Tiêu Bài Học

  • Hiểu cơ chế race condition xảy ra trong rate limiter GET-check-INCR qua case study cụ thể.
  • Nhận diện 10 anti-pattern phổ biến và cách fix từng cái.
  • Chọn thuật toán phù hợp dựa trên yêu cầu cụ thể.
  • Áp dụng checklist trước khi đưa rate limiter lên production.
  • Viết concurrent test để phát hiện race condition.
2

Incident — Rate Limiter Race Condition

Bối cảnh

Một API LLM wrapper có rate limit 1.000 request/giờ per API key. Mục đích: kiểm soát cost LLM và chống abuse. Rate limiter được viết như sau:

def check_rate_limit(api_key: str) -> bool:
    key = f"rl:{api_key}:hour"
    count = int(redis.get(key) or 0)   # bước 1: đọc
    if count >= 1000:
        return False  # DENY
    redis.incr(key)                    # bước 2: tăng
    return True  # ALLOW

Khi test sequential — gửi từng request một — rate limiter hoạt động đúng, chặn đúng request thứ 1.001.

Sự cố

Một client mở 50 connection song song và gửi burst 50 request đồng thời khi counter đang ở 998. Timeline xảy ra như sau:

  • 50 request đồng loạt đọc count — tất cả đều nhận được 998.
  • Tất cả 50 thấy 998 < 1000 → tất cả pass kiểm tra.
  • Tất cả 50 gọi INCR → counter nhảy từ 998 lên 1048.
  • 48 request vượt limit không bị chặn. Vượt 4,8% trong trường hợp này.

Với burst lớn hơn (vd 200 connection song song), mức vượt tỷ lệ theo. Client có thể exploit pattern này để spam LLM API, đẩy chi phí vượt dự kiến.

Tại sao không phát hiện khi test?

Kiểm thử sequential không tái hiện được vấn đề. Race condition chỉ xuất hiện khi nhiều goroutine/thread/process đọc key trước khi bất kỳ cái nào kịp INCR. Sequential test không tạo ra khoảng thời gian xen kẽ đó.

3

Root Cause & Fix Atomic

Root cause

GET → check → INCR là 3 lệnh Redis riêng biệt. Mỗi lệnh riêng lẻ là atomic, nhưng toàn bộ chuỗi thì không. Các request concurrent có thể xen kẽ vào giữa GETINCR của nhau — đây là vấn đề đã được phân tích ở bài 32 (atomicity và Lua).

Fix 1 — Lua script atomic

Đóng gói toàn bộ logic vào Lua script. Redis thực thi Lua script là atomic: không có request nào khác chen vào giữa.

-- KEYS[1]: rate limit key
-- ARGV[1]: limit (số nguyên)
-- ARGV[2]: window TTL (giây)
local current = redis.call('INCR', KEYS[1])
if current == 1 then
  redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
end
if current > tonumber(ARGV[1]) then
  return 0  -- DENY
end
return 1    -- ALLOW

Lưu ý: INCR trước, set EXPIRE khi current == 1 (lần đầu tạo key), rồi mới so sánh với limit. Không có GET riêng.

Fix 2 — INCR-first trong Python thuần

Nếu không muốn Lua, dùng INCR trước rồi check giá trị trả về:

def check_rate_limit(api_key: str) -> bool:
    key = f"rl:{api_key}:hour"
    pipe = redis.pipeline()
    pipe.incr(key)
    pipe.expire(key, 3600)
    count, _ = pipe.execute()
    return count <= 1000  # True = ALLOW

Pipeline không atomic theo nghĩa Lua (EXPIRE vẫn là lệnh riêng), nhưng cũng không còn GET-check-INCR nữa. Nếu crash sau INCR mà trước EXPIRE: key tồn tại mãi — đây là anti-pattern #2 bên dưới. Lua script giải quyết cả hai vấn đề cùng lúc.

Bài học từ incident

  • Rate limiter phải atomic. GET-check-INCR là anti-pattern kinh điển.
  • Test sequential không đủ — phải test với concurrent load.
  • Atomic per-command ≠ atomic per-operation (bài 32).
4

Top 10 Anti-patterns Rate Limiting

  1. GET-check-INCR (race condition): như incident trên. Fix: Lua script hoặc INCR-first.

  2. INCR + EXPIRE là hai lệnh riêng, không trong Lua: nếu process crash hoặc Redis connection mất sau INCR nhưng trước EXPIRE, key tồn tại vĩnh viễn. Mọi request sau đó đều bị chặn mãi mãi (hoặc bị đếm sai). Fix: Lua script đặt EXPIRE ngay khi current == 1, hoặc dùng SET key 0 EX ttl NX trước INCR.

  3. Rate limit in-memory trên multi-instance: mỗi instance giữ counter riêng. 10 instance → tổng throughput = 10 × limit. Fix: central Redis, mọi instance cùng đọc/ghi một counter.

  4. Fail closed mặc định khi Redis down: Redis timeout → exception chưa xử lý → request bị block hết. Fix: fail open với fallback (vd in-memory token bucket local, tự giới hạn ở mức rộng hơn) và alert ngay khi Redis không khả dụng.

  5. Fixed window cho use case yêu cầu strict limit: burst 2× limit tại ranh giới window. Request cuối window và đầu window kế tiếp đều được phép → gấp đôi limit trong khoảng thời gian ngắn. Fix: sliding counter hoặc token bucket nếu burst quan trọng.

  6. Gọi TIME trong Lua script: redis.call('TIME') trả về server time. Trên replica hoặc khi Lua script được replicated, timestamp không nhất quán. Fix: truyền timestamp qua ARGV từ application (đọc ở client, truyền vào Lua).

  7. Global counter cho mọi user trên một key: ví dụ rl:global:api thay vì rl:{user_id}:api. Key này nhận mọi write → hot key, bottleneck. Fix: per-user key. Nếu cần global throttle thực sự, dùng shard (nhiều key, tổng hợp).

  8. Lock account vĩnh viễn khi login thất bại: attacker gửi sai password nhiều lần → tài khoản bị lock vô thời hạn → DoS chính chủ tài khoản. Fix: TTL cho mọi lockout, kết hợp IP dimension (bài 39).

  9. Lua script dài hoặc có loop lớn: Redis thực thi Lua là single-threaded, block toàn bộ server trong khi script chạy. Script 10ms delay → tất cả lệnh khác chờ. Fix: giữ Lua script ngắn, không loop trên dữ liệu lớn, không gọi KEYS/SCAN trong Lua.

  10. Không monitor tỷ lệ 429: không biết limit đang quá chặt (user hợp lệ bị block) hay quá lỏng (attacker không bị ảnh hưởng). Fix: expose metric rate_limit_denied_total per user/endpoint, alert khi spike.

5

Decision Matrix — Chọn Thuật Toán

Không có thuật toán nào tốt nhất mọi trường hợp. Bảng dưới tóm tắt use case phù hợp cho từng thuật toán đã học trong Module 3:

Yêu cầu Thuật toán phù hợp Bài
Đơn giản, internal API, chấp nhận burst tại ranh giới window Fixed Window 33
Chính xác tuyệt đối, limit nhỏ (vd 10 req/giây) Sliding Log 34
Production API, balance giữa chính xác và memory Sliding Counter 35
API cần burst-friendly, tích lũy token khi idle Token Bucket 36
Output rate đều, bảo vệ downstream service Leaky Bucket / GCRA 37
Giới hạn tổng tài nguyên theo billing period Quota (INCR + date-key) 40
Login / auth, brute force protection Multi-dimension + progressive lockout 39
Nhiều region, no single point of failure Distributed (replica + local fallback) 38

Trong thực tế, một hệ thống thường kết hợp nhiều tầng: fixed window tại edge (tốc độ cao), sliding counter tại application layer (chính xác hơn), quota tại billing layer (tổng tài nguyên).

6

Checklist Production-Ready

Correctness

  • Atomic: dùng Lua script hoặc INCR-first. Không GET-check-INCR.
  • Timestamp truyền qua ARGV vào Lua, không gọi TIME trong Lua.
  • EXPIRE được set atomic cùng với lần INCR đầu tiên (khi current == 1).
  • Đã test với concurrent load (không chỉ sequential).

Algorithm

  • Thuật toán phù hợp với use case (xem decision matrix).
  • Burst handling đúng yêu cầu nghiệp vụ.

Distributed

  • Central Redis — không in-memory per instance.
  • Fail open + local fallback khi Redis down. Có alert.
  • Per-user key, tránh hot key toàn cục.
  • NTP đồng bộ nếu rate limiter dùng timestamp (sliding window).

HTTP Response

  • Status code 429 Too Many Requests.
  • Header X-RateLimit-Limit: giới hạn tối đa.
  • Header X-RateLimit-Remaining: số request còn lại trong window.
  • Header X-RateLimit-Reset: Unix timestamp khi window reset.
  • Header Retry-After (giây) khi trả về 429.

Operational

  • Metric rate_limit_allowed_totalrate_limit_denied_total per user/endpoint.
  • Alert khi 429 rate spike: có thể limit quá chặt hoặc đang bị attack.
  • Monitor Redis latency P99 cho rate check operation.
  • Metric rate_limit_fallback_total: đếm số lần Redis down dùng fallback.
  • Tier config và limit lấy từ Redis/DB, không hardcode trong code.

Security (login / auth)

  • Multi-dimension: account + IP riêng biệt.
  • Progressive: CAPTCHA → delay → lockout (không nhảy thẳng vào block).
  • TTL cho mọi lockout key, không lock vĩnh viễn.
  • Response đồng nhất: không tiết lộ "username đúng, password sai" vs "username không tồn tại".
7

Testing Rate Limiter — Sequential vs Concurrent

Tại sao sequential test không đủ

Sequential test kiểm tra logic đúng/sai của từng request, nhưng không phát hiện race condition vì không tạo ra khoảng xen kẽ giữa các lệnh Redis. Concurrent test là bắt buộc để xác nhận atomicity.

Sequential test

def test_sequential():
    reset_key("rl:test_user:hour")
    for i in range(1000):
        assert check_rate_limit("test_user") is True, f"Blocked sớm ở request {i+1}"
    # Request thứ 1001 phải bị chặn
    assert check_rate_limit("test_user") is False, "Request 1001 phải bị DENY"

Concurrent test — quan trọng hơn

import asyncio
import aioredis

LIMIT = 1000

async def make_request(redis_client, key: str) -> bool:
    # Gọi rate check thực tế (Lua script hoặc INCR-first)
    result = await redis_client.eval(LUA_SCRIPT, 1, key, LIMIT, 3600)
    return bool(result)

async def test_concurrent_rate_limit():
    redis_client = await aioredis.from_url("redis://localhost")
    key = "rl:concurrent_test:hour"
    await redis_client.delete(key)

    # Gửi 200 request đồng thời khi counter đang ở 990
    # Chỉ 10 request đầu tiên được phép
    await redis_client.set(key, 990)
    await redis_client.expire(key, 3600)

    tasks = [make_request(redis_client, key) for _ in range(200)]
    results = await asyncio.gather(*tasks)

    allowed = sum(results)
    # Chỉ đúng 10 request được phép (990 + 10 = 1000)
    assert allowed == 10, f"Race condition: {allowed} request được phép (expected 10)"
    print(f"Concurrent test passed: {allowed}/200 allowed")

asyncio.run(test_concurrent_rate_limit())

Nếu dùng GET-check-INCR, test trên sẽ fail vì nhiều hơn 10 request vượt qua. Lua script hoặc INCR-first sẽ pass.

Load test với wrk / k6

Ngoài unit test, nên chạy load test với công cụ thực để đo latency P99 và behavior dưới traffic cao:

# k6: 50 concurrent user, burst 200 req/s trong 30 giây
k6 run --vus 50 --duration 30s --rps 200 rate_limit_test.js

Kiểm tra: tỷ lệ 429 có khớp với giới hạn đặt ra không, latency P99 của rate check dưới bao nhiêu ms.

8

Monitoring Metrics

Metrics tối thiểu để vận hành rate limiter trong production:

Metric Label Mục đích
rate_limit_allowed_total user, endpoint Tổng request được phép — baseline traffic
rate_limit_denied_total user, endpoint Tổng 429 — phát hiện abuse hoặc limit quá chặt
rate_limit_redis_latency_seconds quantile (p50, p99) Latency của rate check — phát hiện Redis chậm
rate_limit_fallback_total reason Số lần Redis down, dùng fallback

Alert gợi ý:

  • rate_limit_denied_total tăng đột biến trong 5 phút: có thể đang bị burst attack hoặc limit quá thấp.
  • rate_limit_redis_latency_seconds{quantile="0.99"} > 0.05: Redis latency cao, ảnh hưởng mọi request.
  • rate_limit_fallback_total tăng: Redis xuống, rate limiter đang chạy ở degraded mode.
9

Tổng Kết Module 3

Module 3 gồm 11 bài, đi từ khái niệm nền tảng đến các pattern production:

Bài Nội dung
31 Tại sao cần rate limiting — các loại abuse và cost
32 Atomicity & Lua script — nền tảng cho mọi thuật toán
33 Fixed Window Counter — đơn giản nhất
34 Sliding Window Log — chính xác nhất, tốn memory
35 Sliding Window Counter — balance giữa 33 và 34
36 Token Bucket — burst-friendly
37 Leaky Bucket & GCRA — smooth output
38 Distributed rate limiting — multi-region, fallback
39 Login protection — multi-dimension, progressive lockout
40 Quota per tier — billing period, token quota, cost quota
41 Checklist & anti-patterns — incident, testing, monitoring

Chủ đề xuyên suốt Module 3: atomicity. Mọi rate limiter đều phụ thuộc vào việc đọc-kiểm tra-ghi phải là một thao tác không bị xen kẽ. Lua script (bài 32) là cơ chế nền tảng cho điều đó.

10

Self-Assessment Trước Module 4

Trước khi sang Module 4, xác nhận bạn có thể trả lời các câu hỏi sau:

  • Giải thích tại sao GET-check-INCR tạo ra race condition và tại sao INCR-first không có vấn đề đó.
  • Cho một use case (vd "API public cần burst-friendly"), chọn được thuật toán phù hợp và giải thích lý do.
  • Mô tả challenge của distributed rate limiting (nhiều instance, multi-region) và cách Redis giải quyết.
  • Viết concurrent test cơ bản để kiểm tra rate limiter không bị race condition.
  • Giải thích tại sao login protection cần multi-dimension (account + IP), không chỉ một dimension.

Module 4 — Distributed Coordination

Module 4 mở rộng từ nền atomicity của Module 3 sang các vấn đề phức tạp hơn trong hệ thống phân tán: distributed lock, fencing token, Redlock algorithm, và leader election. Khái niệm atomic Lua (bài 32) trực tiếp là nền cho distributed lock (bài 42 trở đi).

11

Bài Tập

  1. Trong incident trên, nếu burst là 100 connection song song và counter đang ở 980, có bao nhiêu request vượt limit? Tại sao INCR-first giải quyết được nhưng pipeline (INCR + EXPIRE) vẫn có một vấn đề tiềm ẩn khác?
  2. Viết Lua script rate limiter hỗ trợ "cost-based": mỗi request có weight khác nhau (vd request loại A cost 1, loại B cost 5). Script nhận thêm ARGV[3] là cost, dùng INCRBY thay vì INCR.
  3. Bạn có API với: edge CDN (fixed window, 10.000 req/phút toàn server), application layer (sliding counter, 100 req/phút per user), billing quota (10.000 req/tháng per user). Request nào sẽ bị 429 đầu tiên nếu một user gửi 150 request trong 1 phút?
  4. Anti-pattern #8 (lock account vĩnh viễn): thiết kế schema Redis cho login protection tránh DoS. Gồm các key nào? TTL của từng key là bao nhiêu?
  5. Tại sao không nên gọi TIME trong Lua script? Nếu cần dùng timestamp hiện tại trong Lua, bạn làm thế nào? Điều gì xảy ra với replica nếu Lua gọi TIME?

Đáp án gợi ý

  1. 100 connection đọc 980 cùng lúc, tất cả thấy 980 < 1000 → pass → INCR 100 lần → counter = 1080. Vượt 80 request. INCR-first sửa race condition, nhưng nếu crash sau INCR mà trước EXPIRE: key tồn tại vĩnh viễn (anti-pattern #2). Lua script xử lý cả hai bằng cách set EXPIRE ngay trong script khi current == 1.
  2. Thay redis.call('INCR', KEYS[1]) bằng redis.call('INCRBY', KEYS[1], tonumber(ARGV[3])). Phần còn lại giữ nguyên. Khi current == tonumber(ARGV[3]) (lần đầu từ 0 lên đúng cost) thì set EXPIRE — nhưng nếu cost > 1, điều kiện current == 1 không bao giờ đúng. Sửa: kiểm tra current <= tonumber(ARGV[3]) và dùng redis.call('TTL', KEYS[1]) == -1 để set EXPIRE.
  3. Application layer (sliding counter per user, 100 req/phút) bị hit đầu tiên ở request thứ 101 trong phút đó. Edge CDN cho phép 10.000 req/phút toàn server nên không bị. Billing quota tính theo tháng, 150 request trong 1 phút không đủ chạm giới hạn tháng ngay.
  4. Keys cần thiết: login:fail:account:{username} (TTL 15 phút, reset sau mỗi login thành công), login:fail:ip:{ip} (TTL 1 giờ), login:lockout:{username} (TTL 30 phút khi vượt ngưỡng). Không nên dùng key vĩnh viễn cho bất kỳ state nào liên quan login failure.
  5. Lua script trên replica được replicated từ master — nếu Lua gọi TIME, timestamp tại thời điểm replay trên replica khác với lúc chạy trên master. Nếu logic phụ thuộc timestamp (vd set EXPIRE = "now + 3600"), kết quả sẽ lệch. Fix: đọc time ở client Python (int(time.time())) và truyền vào ARGV; Lua chỉ dùng tonumber(ARGV[...]).

Bài tiếp theo

Bài 42 bắt đầu Module 4 với câu hỏi: tại sao cần distributed lock, và những vấn đề gì xảy ra khi nhiều process cùng truy cập tài nguyên dùng chung mà không có cơ chế đồng bộ.

Tham khảo