Rate limiting bảo vệ hệ thống khỏi lạm dụng (abuse, scraping), client lỗi (retry loop không backoff) và đồng nghiệp vô tình gây spike. Nhưng không phải thuật toán nào cũng phù hợp: có loại ưu tiên mượt burst, có loại ưu tiên sát ngưỡng theo thời gian, có loại cho “ra đều”. Dùng sai → hoặc 429 quá gắt khiến user hợp pháp bực, hoặc quá lỏng không bảo vệ được gì.

Bài này đi qua: mô hình của từng thuật toán, triển khai gợi ý với Redis (có script Lua), vị trí đặt rate limiter trong kiến trúc, các header HTTP nên trả về, và cách chọn ngưỡng dựa trên số liệu thật.


1. Mục tiêu rate limit thật sự là gì

Trước khi chọn thuật toán, cần rõ mục tiêu:

  • Bảo vệ tài nguyên khan hiếm: database connection, CPU cho route đắt, quota tới bên thứ 3 (SMS, email).
  • Đảm bảo công bằng giữa user/tenant: không để một client chiếm băng thông của cả hệ.
  • Chống abuse: brute force login, scraping, bot.
  • Shape traffic: làm luồng vào đều hơn để downstream dễ chịu.

Thuật toán phục vụ mục tiêu — không có “thuật toán tốt nhất”.


2. Token bucket

2.1. Ý tưởng

  • Một “xô” có dung tích tối đa B token.
  • Token được thêm vào với tốc độ cố định r token/giây (hoặc batch mỗi Δt).
  • Mỗi request tiêu c token (thường c = 1, có thể lớn hơn với endpoint đắt).
  • Nếu có đủ token: cho qua, trừ đi. Không đủ: reject (HTTP 429) hoặc chờ.

2.2. Tính chất

  • Cho phép burst ngắn tới B request nếu trước đó client im lặng (xô đầy).
  • Trung bình theo thời gian về tốc độ r.
  • Phù hợp khi bạn muốn nói: “trung bình 10 req/s, cho phép burst 30”.

2.3. Pseudo-code (in-process)

import time

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate = rate         # tokens per second
        self.capacity = capacity
        self.tokens = capacity
        self.last = time.monotonic()

    def allow(self, cost=1):
        now = time.monotonic()
        elapsed = now - self.last
        self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
        self.last = now
        if self.tokens >= cost:
            self.tokens -= cost
            return True
        return False

2.4. Token bucket trên Redis (atomic với Lua)

Khi có nhiều instance app, trạng thái phải ở store chung. Dùng Redis + script Lua để atomic:

-- KEYS[1]: bucket key, e.g. "rl:user:42"
-- ARGV[1]: now (seconds, float), ARGV[2]: rate, ARGV[3]: capacity, ARGV[4]: cost
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cap  = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])

local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or cap
local ts = tonumber(state[2]) or now

local elapsed = math.max(0, now - ts)
tokens = math.min(cap, tokens + elapsed * rate)

local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(cap / rate) + 10)
return { allowed, tokens }

Gọi từ app; với hầu hết ngôn ngữ có sẵn thư viện hoặc pattern load-script-once, dùng EVALSHA.

2.5. Leaky bucket (họ hàng)

Leaky bucket tương tự nhưng “đầu ra đều”: token được tiêu ở tốc độ cố định — nếu đầu vào nhanh hơn, xô đầy thì drop. Dùng khi muốn shape traffic ra downstream theo nhịp cố định (không chỉ giới hạn trung bình).


3. Fixed window counter

3.1. Ý tưởng

  • Chia thời gian thành ô cố định (ví dụ mỗi phút).
  • Với mỗi key (user, IP, endpoint), đếm số request trong ô hiện tại.
  • Vượt ngưỡng trong ô → reject.

3.2. Ưu/nhược

  • Ưu: dễ triển khai, ít bộ nhớ (một counter mỗi ô).
  • Nhược: burst tại biên. Nếu giới hạn 100/phút, client có thể gửi 100 ở giây 59 và 100 ở giây 60 → 200 request trong 2 giây dù vẫn hợp lệ theo từng ô.

3.3. Triển khai Redis

key = "rl:user:42:2026-04-09T12:03"   # bucket theo phút
INCR key
EXPIRE key 120                         # 2 phút cho an toàn
nếu counter > 100 thì reject

Để atomic và lặp EXPIRE, có thể gộp trong script Lua hoặc dùng SET ... NX EX + INCR.


4. Sliding window

4.1. Sliding window log

  • Lưu timestamp từng request trong ZSET Redis (score = timestamp).
  • Khi request tới, ZREMRANGEBYSCORE key -inf now-window để xoá request cũ, rồi ZCARD đếm còn lại.
  • Nếu < limit: ZADD timestamp mới, cho qua. Ngược lại reject.
now = ...ms
ZREMRANGEBYSCORE key -inf (now-60000)
count = ZCARD key
if count < 100:
   ZADD key now now
   EXPIRE key 120
   allow
else:
   reject

Ưu: chính xác cao — “không quá N trong đúng T giây”. Nhược: bộ nhớ tỷ lệ với số request gần đây — với QPS cao sẽ tốn.

4.2. Sliding window counter (approximation)

  • Giữ hai counter: ô hiện tại và ô trước.
  • Ước lượng số request trong cửa sổ trượt bằng cách pha trộn theo vị trí trong ô hiện tại.
count_current + count_previous * (1 - (elapsed_in_current_window / window_size))

Ưu: ít bộ nhớ như fixed window, chính xác hơn fixed (không có bug biên lớn). Nhược: vẫn là xấp xỉ.

4.3. Khi nào chọn sliding

  • Chính sách nghiêm theo thời gian (“không vượt 100/phút trong bất kỳ cửa sổ 60s nào”).
  • Abuse detection (phát hiện burst bất thường).
  • Endpoint quan trọng (login, OTP) — thà tốn bộ nhớ hơn là cho bypass bằng canh biên.

5. So sánh nhanh

Mục tiêuGợi ý thuật toán
Cho burst hợp lý, trung bình về rateToken bucket
Shape traffic ra đều cho downstreamLeaky bucket
Không quá N trong T (nghiêm)Sliding window (log hoặc counter tuỳ ngân sách RAM)
Quota daily/monthly đơn giảnFixed window theo ngày/tháng (chấp nhận bug biên nhỏ ở quy mô lớn)

6. Đặt rate limiter ở đâu

Không chỉ một tầng:

  • Edge / CDN / WAF: chặn IP abuse nặng, bảo vệ origin khỏi DDoS cơ bản. Ít context user.
  • API gateway / reverse proxy (Nginx, Envoy, Kong, Traefik): giới hạn theo IP, API key, route. Phù hợp chính sách chung.
  • Application layer: middleware trong service. Biết user, tenant, role — chính sách tinh vi (premium/free, per-endpoint).
  • Database / downstream: circuit breaker, connection pool size. Không phải rate limit cổ điển nhưng cùng mục tiêu bảo vệ.

Pattern tốt: nhiều lớp phối hợp. Edge chặn abuse thô; gateway áp policy; app xử lý quy tắc nghiệp vụ.


7. Chọn key để limit theo ai

  • IP: dễ, nhưng NAT → nhiều user chung IP; mobile → IP đổi liên tục. Chỉ dùng cho lớp ngoài cùng.
  • User ID / API key: sau khi auth, chính xác nhất.
  • Session / device: khi chưa login.
  • Tenant ID (cho SaaS): tách quota giữa các tenant để công bằng.
  • Tổ hợp (tuple): ví dụ (user, endpoint) để giới hạn từng endpoint riêng.

Nhớ: hash key nhạy cảm (ví dụ API key) trước khi làm key Redis — và đặt prefix rõ ràng (rl:v1:user:…) để version hoá.


8. Header HTTP nên trả về

Để client biết điều chỉnh, trả header chuẩn:

RateLimit-Limit: 100
RateLimit-Remaining: 42
RateLimit-Reset: 37

(IETF đang chuẩn hoá; nhiều dịch vụ dùng tương tự với prefix X-.)

Khi reject, trả 429 Too Many Requests + Retry-After (giây) để client biết đợi bao lâu:

HTTP/1.1 429 Too Many Requests
Retry-After: 37

Client tốt sẽ đọc Retry-Afterexponential backoff; bạn nên viết client của chính mình theo quy ước đó.


9. Các cạm bẫy hay gặp

9.1. Limit chung cho đọc và ghi

Ghi thường đắt hơn đọc nhiều lần. Gộp chung một limit → hoặc đọc bị kìm, hoặc ghi được thả lỏng. Tách policy theo method/endpoint.

9.2. Bỏ qua retry của client

Sau 429, client backoff đồng loạt có thể tạo thundering herd. Gợi ý: trả Retry-After ngẫu nhiên hoá nhẹ (jitter) hoặc nhắc client dùng jitter.

9.3. Không phân biệt 429 vs 503

  • 429: client gửi quá nhiều — lỗi của client.
  • 503: server quá tải (có hoặc không có Retry-After) — lỗi phía server.

Dùng đúng status để observability và client hành xử đúng.

9.4. Redis down → mở toang

Nếu rate limiter phụ thuộc Redis và Redis chết, app có fail-open (cho qua hết) hay fail-closed (chặn hết)? Cả hai đều có trade-off. Thường fail-open nhưng có alert + fallback local (token bucket in-memory lỏng hơn).

9.5. Kiểm thử

Viết test integration: spam N request, xác nhận 429 sau ngưỡng; đợi T → lại vượt qua. Dễ miss nếu chỉ test unit.


10. Đo lường sau khi bật

Bật rate limit mà không đo → không biết có đang “ngáng chân” user hợp lệ:

  • % request bị 429 theo endpoint, user segment.
  • Latency P95/P99 so với baseline (overhead của limiter).
  • Ratio 429 từ client bot vs client thật — nếu chặn nhầm nhiều bot hợp lệ (crawler SEO, monitor), nới rule hoặc whitelist.

Nếu thấy 429 tăng đột biến ở một nhóm user cụ thể, có thể policy chưa phù hợp (ví dụ giới hạn chung nhưng một tenant doanh nghiệp có nhu cầu cao hơn — tách ngưỡng).


11. Tóm tắt

  • Mục tiêu định hình thuật toán: bảo vệ tài nguyên, công bằng, chống abuse, shape traffic — chọn họ cho phù hợp.
  • Token bucket: đơn giản, cho burst, dễ Redis hoá bằng Lua.
  • Sliding window (log/counter): nghiêm theo thời gian, tốn hơn một chút bộ nhớ/CPU.
  • Fixed window: rẻ nhất, chấp nhận bug biên.
  • Nhiều lớp: edge + gateway + app; mỗi lớp chịu trách nhiệm khác nhau.
  • Header + observability: trả RateLimit-*, Retry-Afterđo tỷ lệ 429 để điều chỉnh.

Rate limit làm tốt là một công việc sản phẩm chứ không chỉ kỹ thuật: bạn đang nói với client có thể làm gì, với tốc độ nào. Chọn thuật toán, ngưỡng và cách phản hồi (error message, header) đều ảnh hưởng trải nghiệm.