Lập trình · 19/09/2026

API Rate Limiting thực chiến với Redis: Thuật toán, thiết kế và vận hành

Rate limiting không chỉ để chặn bot. Nó bảo vệ database và dịch vụ phụ thuộc khỏi traffic burst, phân chia tài nguyên công bằng giữa tenant, kiểm soát chi phí API và giúp hệ thống suy giảm có kiểm soát khi quá tải. Một limiter thiết kế sai lại có thể khóa nhầm hàng nghìn người dùng sau NAT hoặc trở thành điểm lỗi mới.

API Rate Limiting thực chiến với Redis: Thuật toán, thiết kế và vận hành

Rate limiting không chỉ để chặn bot. Nó bảo vệ database và dịch vụ phụ thuộc khỏi traffic burst, phân chia tài nguyên công bằng giữa tenant, kiểm soát chi phí API và giúp hệ thống suy giảm có kiểm soát khi quá tải. Một limiter thiết kế sai lại có thể khóa nhầm hàng nghìn người dùng sau NAT hoặc trở thành điểm lỗi mới.

Bài viết này đi từ lựa chọn thuật toán đến triển khai Redis phân tán, với mục tiêu tạo một chính sách dễ giải thích, có khả năng quan sát và hoạt động nhất quán trên nhiều application instance.

Rate limit khác quota và concurrency limit

  • Rate limit: số request trong một khoảng thời gian, ví dụ 100 request/phút.
  • Quota: tổng mức sử dụng trong chu kỳ dài, ví dụ 1 triệu request/tháng.
  • Concurrency limit: số tác vụ đang chạy đồng thời.

Một API upload file có thể cần cả ba: rate limit chống spam, quota kiểm soát chi phí và concurrency limit bảo vệ CPU/I/O. Đừng dùng một counter phút để thay thế mọi cơ chế.

1. Xác định mục tiêu trước khi chọn thuật toán

Trả lời các câu hỏi:

  • Tài nguyên nào cần bảo vệ: login, search, checkout hay API toàn cục?
  • Ai là chủ thể: user, API key, tenant, IP hay device?
  • Có chấp nhận burst ngắn không?
  • Limiter cần chính xác toàn cluster hay mỗi region có ngân sách riêng?
  • Khi Redis lỗi, request nên được phép hay bị từ chối?

Chính sách nên gắn với chi phí. Một request đọc cache không nên tiêu cùng số token với export báo cáo hoặc chạy AI inference.

2. Chọn identity và key an toàn

Ưu tiên identity đã xác thực: tenant ID, user ID hoặc API key ID. IP chỉ là tín hiệu phụ vì NAT có thể gom cả văn phòng vào một địa chỉ, trong khi attacker có thể xoay proxy.

rl:v1:{tenant_id}:{route_group}:{window}

Không đưa raw API key, email hoặc dữ liệu cá nhân vào Redis key/log. Hash hoặc dùng internal ID. Chỉ tin X-Forwarded-For từ reverse proxy do bạn kiểm soát; client có thể giả header này nếu ứng dụng tiếp xúc trực tiếp Internet.

Thường cần nhiều lớp:

  • Giới hạn IP thô ở CDN/WAF để hấp thụ abuse.
  • Giới hạn user/API key tại gateway hoặc application.
  • Giới hạn tenant để một khách hàng không chiếm toàn bộ capacity.
  • Giới hạn global để bảo vệ dependency khi gần quá tải.

3. Fixed window: đơn giản nhưng có burst ở biên

Fixed window đếm request trong từng phút/giây:

bucket = floor(current_time / 60)
key = "rl:user:42:" + bucket
count = INCR(key)
EXPIRE(key, 120)
allow = count <= 100

Ưu điểm là O(1), ít bộ nhớ và dễ vận hành. Nhược điểm: client có thể gửi 100 request cuối phút rồi 100 request đầu phút kế tiếp, tạo 200 request trong vài giây.

INCREXPIRE phải được thực hiện nguyên tử bằng transaction/script để tránh key không hết hạn khi process chết giữa hai lệnh.

4. Sliding window log: chính xác nhưng tốn bộ nhớ

Mỗi request được lưu vào sorted set với timestamp, sau đó xóa phần tử ngoài cửa sổ và đếm phần còn lại:

ZREMRANGEBYSCORE key 0 (now - window)
ZADD key now unique_request_id
ZCARD key
EXPIRE key window

Thuật toán cho giới hạn trượt chính xác nhưng chi phí tăng theo số request và cần ID duy nhất khi nhiều request cùng millisecond. Phù hợp endpoint nhạy cảm với lưu lượng vừa phải, không lý tưởng cho hàng triệu key hoạt động liên tục.

5. Sliding window counter: cân bằng chi phí và độ mượt

Giữ counter của cửa sổ hiện tại và trước đó, rồi nội suy phần cửa sổ trước còn ảnh hưởng:

estimated = current_count
          + previous_count * remaining_fraction

Nó dùng ít bộ nhớ hơn log, giảm burst ở biên tốt hơn fixed window nhưng chỉ là xấp xỉ. Đây là lựa chọn tốt cho quota request phổ thông khi sai số nhỏ được chấp nhận.

6. Token bucket: phù hợp API có burst hợp lệ

Bucket có sức chứa capacity, token được nạp theo tốc độ refill_rate. Mỗi request lấy một hoặc nhiều token. Khi hết token, request bị từ chối; sau thời gian rảnh, bucket tích lũy đủ token để cho phép burst có giới hạn.

Ví dụ capacity 60, refill 1 token/giây: client có thể burst tối đa 60 request sau thời gian rảnh, nhưng trung bình dài hạn là 1 request/giây. Request đắt có thể tốn 5–20 token.

7. Token bucket atomic bằng Redis Lua

Read–calculate–write phải nguyên tử để hai request đồng thời không cùng tiêu một token. Lua script chạy atomic trong Redis:

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now_ms = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])

local values = redis.call('HMGET', key, 'tokens', 'updated_at')
local tokens = tonumber(values[1]) or capacity
local updated_at = tonumber(values[2]) or now_ms

local elapsed = math.max(0, now_ms - updated_at) / 1000
tokens = math.min(capacity, tokens + elapsed * refill_rate)

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

redis.call('HSET', key, 'tokens', tokens, 'updated_at', now_ms)
redis.call('PEXPIRE', key, math.ceil((capacity / refill_rate) * 2000))

return { allowed, math.floor(tokens) }

Ứng dụng phải truyền thời gian nhất quán hoặc dùng thời gian từ Redis để giảm clock skew. Script phải rất ngắn vì Redis chặn hoạt động khác trong lúc thực thi. Với Redis Cluster, mọi key của một lần gọi phải cùng hash slot; thiết kế limiter một key cho mỗi subject giúp đơn giản hóa.

8. Đặt limiter ở đâu?

  • CDN/WAF: chặn bot và volumetric abuse sớm, nhưng ít hiểu identity nghiệp vụ.
  • API gateway: chính sách tập trung, phù hợp API key/tenant và giảm traffic vào app.
  • Application: hiểu user, route và chi phí nghiệp vụ chính xác nhất.
  • Downstream service: tự bảo vệ capacity riêng, ngay cả khi caller nội bộ lỗi.

Production thường dùng nhiều lớp. Không dựa vào limiter trong application để chống DDoS vì request đã đi qua network và web server. Ngược lại, limiter ở CDN không thay được quota tenant trong nghiệp vụ.

9. Phản hồi HTTP 429 hữu ích

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 12
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 12

{
  "type": "https://api.example.com/problems/rate-limit",
  "title": "Rate limit exceeded",
  "status": 429,
  "retry_after": 12
}

Retry-After cho client biết khi nào thử lại. RFC 9333 định nghĩa các field RateLimit để mô tả limit, remaining và reset; khi hỗ trợ, hãy giữ semantics nhất quán và không coi header là cơ chế bảo mật. Không tiết lộ chi tiết có thể giúp bypass policy.

Client nên dùng exponential backoff với jitter, tôn trọng Retry-After, giới hạn số lần retry và không retry đồng loạt đúng một thời điểm.

10. Đừng tính request thất bại giống nhau một cách máy móc

Thường limiter quyết định trước khi handler chạy, nên request được phép sẽ tiêu token dù trả 4xx/5xx. Hoàn token sau lỗi tạo thêm race condition và có thể bị abuse. Thay vào đó, thiết kế budget riêng:

  • Login thất bại giới hạn chặt theo account + IP.
  • Request validate sai vẫn tiêu ngân sách để chống spam.
  • Health check nội bộ có policy riêng.
  • Job đã nhận vào queue dùng concurrency/backpressure thay vì chỉ request rate.

11. Redis lỗi: fail-open hay fail-closed?

Fail-open cho request đi qua khi limiter unavailable, phù hợp endpoint đọc ít rủi ro nhưng có thể làm dependency quá tải. Fail-closed từ chối, phù hợp login, OTP, thanh toán hoặc giới hạn chi phí nghiêm ngặt nhưng biến Redis thành dependency availability.

Có thể dùng chiến lược lai: timeout Redis rất ngắn, local emergency limiter cho từng instance, circuit breaker và policy theo route. Không silently bypass; phải có metric/cảnh báo khi fallback được kích hoạt.

12. Hot key, memory và multi-region

Global key cho toàn hệ thống dễ trở thành hot key. Phân bổ limit theo tenant/route hoặc chia ngân sách theo region khi tính nhất quán tuyệt đối không cần thiết. Multi-region limiter đồng bộ mạnh làm tăng latency; quota region cộng lại có thể vượt global limit trong thời gian ngắn, nên để safety margin.

Đặt TTL cho mọi key tạm. Theo dõi memory, eviction, command latency và hit rate. Không dùng Redis cache với eviction tùy ý mà không hiểu hậu quả: key limiter bị evict có thể reset ngân sách sớm.

13. Observability

  • Allowed và rejected theo route, tenant, plan và region.
  • Tỷ lệ 429 và số subject chạm ngưỡng.
  • Redis latency, timeout, error, memory và eviction.
  • Thời gian chạy Lua/function và hot key.
  • Số lần fail-open/fail-closed/fallback local.
  • Tương quan giữa rejection, downstream latency và saturation.

Không gắn raw user ID vào metric cardinality cao. Log có sampling và hash identity; dashboard dùng dimension giới hạn như route/plan/region.

14. Kiểm thử trước production

  1. Unit test thời điểm biên cửa sổ, refill và request cost.
  2. Concurrency test nhiều request cùng một key.
  3. Load test với nhiều key và một hot key.
  4. Chaos test Redis timeout, failover và connection pool cạn.
  5. Xác minh TTL, memory growth và cleanup.
  6. Test client xử lý 429, Retry-After và jitter.
  7. Canary policy với ngưỡng cao, chỉ quan sát trước khi enforcement.

Checklist thiết kế

  • Policy gắn với tài nguyên và chi phí thực tế.
  • Identity ưu tiên user/API key/tenant, không chỉ IP.
  • Thuật toán phù hợp burst và độ chính xác cần thiết.
  • Quyết định atomic trong Redis và key có TTL.
  • 429 có hướng dẫn retry nhất quán.
  • Fail-open/closed được chọn theo route và có cảnh báo.
  • Giới hạn có version, feature flag và khả năng rollback.
  • Dashboard đo rejection cùng sức khỏe dependency.

Kết luận

Rate limiting tốt là cơ chế điều phối capacity, không phải một con số tùy ý đặt trước API. Hãy bắt đầu từ identity và chi phí, chọn fixed/sliding/token bucket theo hành vi burst, thực thi quyết định atomic trên Redis, trả 429 hữu ích và chuẩn bị cho chính lúc Redis gặp sự cố. Policy rõ ràng, quan sát được và rollout dần sẽ bảo vệ hệ thống mà không gây khó chịu không cần thiết cho người dùng hợp lệ.

Tài liệu tham khảo

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.