Rate limiting không chỉ là “chặn spam”. Trong backend production, nó là cơ chế bảo vệ tài nguyên, giữ SLO, cô lập tenant ồn ào và tạo hợp đồng rõ ràng giữa client với server. Nếu thiết kế sai, rate limiter có thể trở thành single point of failure, gây false positive cho khách hàng tốt, hoặc không chặn được abuse khi hệ thống scale nhiều instance.
Bài này đi sâu vào cách thiết kế rate limiting trong distributed system: chọn thuật toán, dùng Redis/Lua để đảm bảo atomicity, định nghĩa quota theo tenant/API key/route, trả HTTP headers đúng, quan sát metric và chuẩn bị runbook vận hành.

Rate limiting giải quyết vấn đề gì trong production?
Ở hệ thống nhỏ, một middleware theo IP có thể đủ. Nhưng khi có nhiều tenant, mobile app, public API, background sync và partner integration, rate limiting cần trả lời nhiều câu hỏi khó hơn:
- Ai đang tiêu thụ quota: user, API key, tenant, IP, device hay route?
- Quota nào quan trọng hơn: request/second, request/minute, cost theo endpoint, concurrency hay bytes?
- Khi Redis lỗi thì fail-open hay fail-closed?
- Client biết khi nào retry bằng cách nào?
- Làm sao một tenant không làm nghẽn tài nguyên chung?
Rate limiting nên đi cùng các pattern bảo vệ hệ thống khác như backpressure và load shedding, bulkhead pattern, idempotency và timeout. Nó không thay thế circuit breaker hay queue; nó là lớp kiểm soát lưu lượng đầu vào.
Chọn thuật toán: fixed window, sliding window hay token bucket?
Fixed window
Fixed window đếm số request trong một khoảng thời gian cố định, ví dụ 1000 request/phút. Dễ cài nhưng có burst ở biên cửa sổ: client có thể gửi 1000 request cuối phút trước và 1000 request đầu phút sau.
Sliding window
Sliding window chính xác hơn vì nhìn trên cửa sổ thời gian trượt. Có thể dùng log từng request hoặc counter xấp xỉ theo bucket nhỏ. Đổi lại, chi phí lưu trữ/tính toán cao hơn nếu traffic lớn.
Token bucket
Token bucket thường phù hợp cho API production vì cho phép burst có kiểm soát. Bucket có capacity tối đa; token được refill theo rate. Mỗi request tiêu tốn một số token. Nếu còn đủ token thì cho qua, nếu không thì trả 429.

new_tokens = min(capacity, previous_tokens + elapsed_seconds * refill_rate)
if new_tokens >= request_cost:
allow request
tokens = new_tokens - request_cost
else:
deny with 429 and Retry-After
Điểm quan trọng là request_cost không nhất thiết luôn bằng 1. Endpoint tạo báo cáo, export dữ liệu hoặc gọi downstream đắt có thể tốn 5-20 token; endpoint read nhẹ có thể tốn 1 token.
Thiết kế key: IP-only thường không đủ
Một lỗi phổ biến là dùng IP làm key duy nhất. Điều này dễ sai với NAT, mobile network, corporate proxy và attacker xoay IP. Key nên phản ánh đơn vị tài nguyên hoặc hợp đồng thương mại:
-
tenant:{tenant_id}:route:{route_group}cho SaaS B2B. -
api_key:{key_id}cho public API. -
user:{user_id}:action:{action}cho hành động nhạy cảm. -
ip:{ip}:unauthenticatedcho request chưa đăng nhập. -
tenant:{tenant_id}:concurrencynếu cần giới hạn request đang chạy cùng lúc.
Với API có retry, hãy kết hợp rate limiting với idempotency key để retry không tạo side effect trùng lặp.
Redis + Lua: tránh race condition giữa nhiều instance
Trong distributed system, nhiều app instance có thể cùng xử lý request cho một tenant. Nếu bạn làm GET bucket rồi SET bucket bằng nhiều command riêng, hai request đồng thời có thể cùng thấy còn token và cùng được allow sai. Vì vậy, cập nhật bucket cần atomic.

-- KEYS[1] = bucket key
-- ARGV: now_ms, refill_per_ms, capacity, cost, ttl_ms
local raw = redis.call('HMGET', KEYS[1], 'tokens', 'updated_at')
local tokens = tonumber(raw[1]) or tonumber(ARGV[3])
local updated_at = tonumber(raw[2]) or tonumber(ARGV[1])
local now = tonumber(ARGV[1])
local refill = math.max(0, now - updated_at) * tonumber(ARGV[2])
tokens = math.min(tonumber(ARGV[3]), tokens + refill)
local allowed = 0
local retry_after_ms = 0
if tokens >= tonumber(ARGV[4]) then
allowed = 1
tokens = tokens - tonumber(ARGV[4])
else
retry_after_ms = math.ceil((tonumber(ARGV[4]) - tokens) / tonumber(ARGV[2]))
end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'updated_at', now)
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[5]))
return {allowed, tokens, retry_after_ms}
Script thật cần thêm versioning, input validation và cách xử lý clock. Nên ưu tiên Redis server time hoặc clock source thống nhất thay vì tin hoàn toàn vào từng app node.
HTTP contract: trả 429 thế nào để client retry đúng?
Rate limit không chỉ là deny. Nó là một contract. Response nên có:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 1716998400
Content-Type: application/json
{
"error": "rate_limit_exceeded",
"message": "Quota exceeded for this API key and route group.",
"retry_after_seconds": 12,
"limit_scope": "tenant:acme:route:search"
}
Không nên trả 500 khi quota hết. Không nên im lặng drop request. Với client nội bộ, contract này giúp SDK tự backoff, tránh retry storm. Với public API, nó giúp khách hàng debug và điều chỉnh lịch gọi.
Multi-layer limit: đừng chỉ có một quota toàn cục
Production thường cần nhiều lớp limit cùng lúc:
- Edge/IP limit: chống abuse unauthenticated cơ bản.
- User/API key limit: hợp đồng tiêu thụ cá nhân.
- Tenant limit: bảo vệ công bằng giữa khách hàng.
- Route group limit: endpoint đắt có quota riêng.
- Concurrency limit: giới hạn request đang chạy, đặc biệt với export/report.
- Downstream-aware limit: giảm quota tạm thời khi dependency chậm.
Đây là lý do rate limiting liên quan trực tiếp tới system design cho backend developer: bạn đang thiết kế quyền truy cập vào tài nguyên hữu hạn, không chỉ viết middleware.
Khi Redis lỗi: fail-open hay fail-closed?
Không có đáp án chung. Với login brute-force, payment hoặc endpoint tốn tiền, fail-closed có thể hợp lý hơn. Với API core phục vụ khách hàng trả tiền, fail-open có giới hạn và alert lớn có thể tốt hơn để tránh outage do rate limiter.
Một policy thực dụng:
- Endpoint bảo mật: fail-closed ngắn hạn, degrade message rõ.
- Endpoint read core: fail-open trong vài phút, bật local emergency limiter.
- Endpoint expensive/report/export: fail-closed hoặc queue.
- Luôn alert khi rate limiter store lỗi; đừng coi fail-open là bình thường.
Observability và runbook

Dashboard tối thiểu nên có:
-
rate_limit_allowed_total,rate_limit_denied_totaltheo tenant, route, policy. - Tỷ lệ 429 theo endpoint và client version.
- Top tenants/API keys bị throttle.
- Redis latency, error rate, command timeout.
- Remaining quota percentile để thấy tenant sắp chạm trần.
- Correlation giữa 429, backend latency và downstream error.
Nếu có distributed tracing, gắn span attribute như rate_limit.key, rate_limit.policy, rate_limit.allowed, rate_limit.remaining. Bài distributed tracing cho microservices là nền tốt để đưa quyết định của rate limiter vào request path.
Checklist trước khi ship rate limiter
- Đã xác định limit scope: IP, user, API key, tenant, route group.
- Đã chọn thuật toán phù hợp: token bucket/sliding window/concurrency limit.
- Update quota là atomic; không dùng GET/SET rời rạc dễ race.
- Có TTL để key inactive tự biến mất.
- HTTP 429 có
Retry-Aftervà RateLimit headers. - Có policy rõ khi Redis lỗi: fail-open/fail-closed theo endpoint.
- Quota của endpoint đắt dùng request cost cao hơn.
- Có dashboard, alert, top offender và override process.
- Đã load test với nhiều app instances, không chỉ single process.
- Đã document cách tăng quota tạm thời cho tenant quan trọng.
Kết luận
Rate limiting tốt không nằm ở thuật toán đẹp nhất, mà ở việc biến giới hạn tài nguyên thành hợp đồng production rõ ràng: ai được dùng bao nhiêu, khi nào bị từ chối, client retry ra sao, hệ thống quan sát thế nào và đội vận hành can thiệp bằng cách nào. Với distributed system, hãy coi rate limiter là một thành phần reliability nghiêm túc, ngang hàng với timeout, retry, idempotency, bulkhead và observability.