Trong bài này
Vì sao phải giới hạn
Rate limiter là lời hứa với mọi client rằng không một client nào khác được phép dùng cạn hệ thống. Thiếu nó, một bản build mobile lỗi tự gửi lại liên tục, một con scraper, hay cron job cấu hình sai của đối tác có thể đánh sập một API vốn được tính cho traffic tử tế. Việc của limiter là nói không sớm và rẻ, để những phần đắt đỏ của hệ thống không phải nói.
Phần thú vị không nằm ở thuật toán, thứ viết vừa một tấm phiếu. Nó nằm ở chỗ giữ một quota chung trên nhiều server stateless, giữ phép kiểm tra dưới một mili giây, và hỏng một cách an toàn.
Yêu cầu
- Giới hạn theo API key và theo nhóm route, ví dụ 100 request mỗi phút, burst tối đa 20.
- Giới hạn mang tính global: một client rải request qua 10 pod vẫn chỉ được 100 mỗi phút, không phải 1.000.
- Latency cộng thêm dưới 1 ms ở p99. Limiter nằm trên mọi request.
- Client được báo rõ ràng:
429 Too Many RequestskèmRetry-Aftervà header cho biết quota còn lại. - Nếu store của limiter không dùng được, API vẫn tiếp tục phục vụ.
- request / giây lúc cao điểm
- 50k
- key đang hoạt động
- 200k
- bộ nhớ Redis cho các bucket
- ~20 MB
- budget p99 mỗi lần check
- < 1 ms
Mỗi bucket chỉ là hai con số (số token và thời điểm refill gần nhất), khoảng 100 byte kể cả phần đầu key trong Redis. Hai trăm nghìn key đang hoạt động là 20 MB. Năm mươi nghìn lần check mỗi giây nằm gọn trong sức một Redis primary, vốn phục vụ vài trăm nghìn lệnh đơn giản mỗi giây. Memory và throughput không phải chỗ khó.
Chọn thuật toán
| Thuật toán | Trạng thái mỗi key | Điểm yếu |
|---|---|---|
| Fixed window counter | 1 counter | Burst gấp đôi ở mép window |
| Sliding log | 1 timestamp mỗi request | Memory tăng theo limit |
| Sliding window counter | 2 counter | Xấp xỉ (giả định phân bố đều) |
| Token bucket | số token + timestamp | Hai tham số phải giải thích |
| GCRA | 1 timestamp | Khó đọc và khó debug |
Mình dùng token bucket. Nó khớp thẳng với cách mọi người mô tả giới hạn (“100 lần mỗi phút, burst tối đa 20”), dùng memory cố định, và chính xác chứ không xấp xỉ. GCRA là cùng ý tưởng nén vào một timestamp, chọn nó cũng tốt; phần còn lại của bài áp dụng y nguyên.

Làm nó atomic trên Redis
Cách làm ngây thơ đọc bucket, tính trong ứng dụng, rồi ghi lại. Hai pod làm vậy với cùng một key cùng lúc sẽ cùng đọc thấy 1 token, cùng cho qua, cùng ghi 0. Đó chính là race condition “check-then-act” giống mọi vụ đặt trùng, và cách sửa cũng cùng một ý: kiểm tra và ghi trong một bước không thể chia cắt. Trên Redis, bước đó là một script Lua, chạy atomic ngay trên server.
-- KEYS[1] key của bucket ARGV: rate (token/giây), burst, cost
local rate, burst, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local t = redis.call('TIME') -- đồng hồ của Redis, không phải của pod
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or burst
local ts = tonumber(b[2]) or now
tokens = math.min(burst, tokens + (now - ts) * rate) -- refill
local allowed = tokens >= cost
if allowed then tokens = tokens - cost end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(burst / rate) + 1) -- key nhàn rỗi tự biến mất
local retry = allowed and 0 or (cost - tokens) / rate
return { allowed and 1 or 0, math.floor(tokens), tostring(retry) }- Một round trip. Nạp script một lần bằng
SCRIPT LOADrồi gọi theo SHA bằngEVALSHA. Cả lần check chỉ tốn một network hop, thường 200–400 µs trong cùng một availability zone. - Một clock.
redis.call('TIME')giúp các pod có đồng hồ lệch nhau không thể cãi nhau về số token đã được refill. - Tự dọn. Một bucket đầy không mang thông tin gì, nên key expire ngay khi lẽ ra nó đã được refill đầy. Bộ nhớ chỉ tỉ lệ với client đang hoạt động, không phải mọi client từng gặp.
Đặt limiter ở đâu
Hiếm khi chỉ có một limiter. Ở edge (CloudFront với rule rate-based của AWS WAF, trong các hệ thống mình dựng) một giới hạn thô theo IP hấp thụ lũ request miễn phí, ở xa máy chủ. Ở gateway hoặc middleware, token bucket phía trên thực thi hợp đồng theo API key mà client thực sự nhìn thấy. Bên trong một service, một local limiter nhỏ hoặc concurrency limit bảo vệ một dependency đắt đỏ, như API SMS bên thứ ba tính tiền theo lượt hay một truy vấn báo cáo ngốn trọn một CPU.
Khi Redis biến mất
Rate limiter không được phép trở thành sự cố. Nếu lời gọi Redis lỗi hoặc vượt timeout 5 ms, middleware fail open: cho request đi qua, tăng một metric, và fallback về local token bucket trên từng pod với giới hạn chia cho số pod. Quota cục bộ đó chỉ xấp xỉ (traffic không bao giờ cân bằng tuyệt đối) nhưng vẫn chặn được một client mất kiểm soát, và không tốn gì khi Redis khỏe. Một circuit breaker ngừng dồn request vào Redis đang ốm và chỉ thăm dò lại vài giây một lần.
Fail closed chỉ đúng ở một chỗ: những giới hạn bảo vệ tiền hoặc an toàn, như số lần đăng nhập hay số lần gửi OTP. Ở đó, vài phút “vui lòng thử lại” rẻ hơn một cánh cửa mở cho dò mật khẩu hàng loạt hay một hóa đơn SMS.
Nói cho client biết
HTTP/1.1 429 Too Many Requests
Retry-After: 3
RateLimit-Policy: 100;w=60;burst=20
RateLimit: limit=100, remaining=0, reset=3
Content-Type: application/problem+json
{"type":"https://api.example.com/errors/rate-limited","title":"Too many requests","retry_after":3}Client tử tế sẽ lùi lại khi được biết phải chờ bao lâu. Trả Retry-After và quota còn lại trên mọi response, không chỉ response 429, giúp các SDK tử tế tự giãn nhịp trước khi đâm vào tường. Thêm jitter phía client để một nghìn client cùng nhận “thử lại sau 3 giây” không quay lại đúng cùng một mili giây.
Scaling và trade-off
- Hot key. Một client khổng lồ dồn hết vào một shard Redis. Với tenant rất lớn, chia quota thành N sub-bucket (
rl:{tenant}:0..N) và chọn ngẫu nhiên một bucket mỗi request; giới hạn hơi xấp xỉ, đổi lại hot spot biến mất. - Multi-region. Một limit chính xác toàn cầu cần cross-region coordination ở mọi request, phá hỏng budget 1 ms. Chia cho mỗi region một phần quota và rebalance vài giây một lần theo traffic thực tế.
- Local pre-check. Ở rate rất cao, giữ một in-process cache nhỏ cho các key đã cạn trong khoảng
Retry-Aftercủa chúng; request bị từ chối khi đó không cần chạm tới Redis. - Exactness vs availability. Thiết kế này chính xác khi Redis còn sống và xấp xỉ khi Redis chết. Mình nghĩ đó là chiều đúng: độ chính xác của một rate limit đáng giá kém hơn availability của chính API mà nó bảo vệ.
Một rate limiter kéo cả API sập theo thì chẳng bảo vệ được gì.
