Mục lục
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.
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 được998. - 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ẽ đó.
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 GET và INCR 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).
Top 10 Anti-patterns Rate Limiting
-
GET-check-INCR (race condition): như incident trên. Fix: Lua script hoặc INCR-first.
-
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ùngSET key 0 EX ttl NXtrước INCR. -
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.
-
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.
-
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.
-
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 quaARGVtừ application (đọc ở client, truyền vào Lua). -
Global counter cho mọi user trên một key: ví dụ
rl:global:apithay 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). -
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).
-
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.
-
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_totalper user/endpoint, alert khi spike.
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).
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_totalvàrate_limit_denied_totalper 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".
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.
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_totaltă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_totaltăng: Redis xuống, rate limiter đang chạy ở degraded mode.
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 đó.
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).
Bài Tập
- 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?
- 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ùngINCRBYthay vìINCR. - 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?
- 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?
- Tại sao không nên gọi
TIMEtrong 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 ý
- 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. - Thay
redis.call('INCR', KEYS[1])bằngredis.call('INCRBY', KEYS[1], tonumber(ARGV[3])). Phần còn lại giữ nguyên. Khicurrent == tonumber(ARGV[3])(lần đầu từ 0 lên đúng cost) thì set EXPIRE — nhưng nếu cost > 1, điều kiệncurrent == 1không bao giờ đúng. Sửa: kiểm tracurrent <= tonumber(ARGV[3])và dùngredis.call('TTL', KEYS[1]) == -1để set EXPIRE. - 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.
- 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. - 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àoARGV; Lua chỉ dùngtonumber(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ộ.
