🚧 Rate limiting: chặn tải xấu trước khi vào hệ thống

By Better Wise

Ở module trước, bạn đã biết load balancer chia tải và connection pool database cạn kiệt khi quá nhiều request cùng lúc. Nhưng load balancer chỉ chia đều cái tải đang có, nó không phân biệt người dùng thật với một con bot đang crawl 40.000 request mỗi giây. Rate limiting là lớp chặn trước cửa, để tải xấu không bao giờ chạm tới app server.

Sau bài này, bạn sẽ:

  • Giải thích được vì sao rate limit phải đặt trước app, không phải trong app
  • Chọn được ngưỡng rate limit dựa trên capacity thật của hệ thống
  • Trả về đúng mã lỗi cho từng nhóm người dùng
  • Nhận ra cái bẫy "mỗi instance đếm riêng" khi scale ngang

Chặn ở cửa, không chặn ở trong bếp

Sự cố tôi nhớ nhất: một bot search quét toàn bộ catalog qua endpoint /search. Nó đẩy 40.000 RPS vào 6 app server. Connection pool database cạn sau 90 giây, người dùng thật nhận 500 hàng loạt, dashboard đỏ rực. Chúng tôi không sập vì database yếu, mà vì đã để bot đi quá sâu vào hệ thống.

flowchart TD
    C["👤<br/>Client"] --> RL["🛡️<br/>Rate limiter"]
    RL -->|"trong hạn mức"| LB["⚖️<br/>Load balancer"]
    RL -->|"vượt ngưỡng"| R429["🚫<br/>429 + Retry-After"]
    LB --> App["🖥️<br/>App server"]
    App --> DB["🗄️<br/>Database"]

Điểm mấu chốt: request bị chặn ở rate limiter tốn vài micro giây. Request bị chặn ở database tốn một connection pool, một query, và có thể kéo cả hệ thống xuống. Rate limit rẻ hơn hàng trăm lần so với xử lý rồi mới từ chối.

Đếm bằng thuật toán nào

Ba lựa chọn đủ dùng cho người mới:

Thuật toán Cách hoạt động Điểm yếu
Fixed window Đếm số request trong mỗi phút, đầy thì chặn Burst gấp đôi ở ranh giới phút
Sliding window Đếm trên cửa sổ trượt liên tục Tốn bộ nhớ, phức tạp hơn
Token bucket Có xô token nạp đều, mỗi request tiêu 1 token Cần chọn dung lượng xô phù hợp

Với hầu hết API, token bucket là lựa chọn mặc định: nó cho phép burst ngắn (một trang web load 20 ảnh cùng lúc) nhưng vẫn giữ tốc độ trung bình trong tầm kiểm soát.

Chọn ngưỡng từ con số bạn có

Đừng chọn ngưỡng bằng cảm giác. Bắt đầu từ capacity đã đo:

  1. Đo RPS tối đa mà một instance chịu được khi p95 latency vẫn đạt mục tiêu. Ví dụ: 300 RPS.
  2. Nhân với số instance: 6 × 300 = 1.800 RPS.
  3. Lấy hệ số an toàn 0,6 đến 0,7 để còn chỗ cho traffic hợp lệ tăng đột biến: ngưỡng global khoảng 1.200 RPS.

Còn ngưỡng theo từng client thì lấy từ hành vi thật. Một người dùng bình thường gọi API bao nhiêu lần mỗi phút? Đo trong log một tuần, lấy percentile 99, rồi nhân 2. Nếu p99 là 25 request/phút, đặt 60 là hợp lý.

Note: Global limit và per-client limit là hai lớp khác nhau, cần cả hai. Global limit bảo vệ hệ thống khỏi tổng tải. Per-client limit bảo vệ hệ thống khỏi một nguồn duy nhất.

Ngưỡng và mã lỗi theo từng nhóm người dùng

Nhóm Cách đếm Ngưỡng ví dụ Trả về
Anonymous IP 60 request/phút 429 + Retry-After
Đã đăng nhập user_id 600 request/phút 429 + body JSON dễ đọc
Đối tác / API key API key 5.000 request/phút 429 + header X-RateLimit-Remaining
Nội bộ, health check Bỏ qua hoặc hạn mức riêng Không chặn chung Không bao giờ 429
Endpoint đắt (/login, /search, export) IP + user Thấp hơn 5 đến 10 lần 429 + gợi ý chờ

Vì sao phải là 429 chứ không phải 403 hay 503:

  • 403 Forbidden nghĩa là "bạn không có quyền", client sẽ không thử lại. Sai.
  • 503 Service Unavailable nghĩa là "server của tôi lỗi", client sẽ báo động và giám sát của bạn cũng báo động sai.
  • 429 Too Many Requests nghĩa đúng bản chất: "bạn gửi quá nhanh, chờ chút rồi thử lại". Kèm Retry-After: 30 để client biết chờ bao lâu.

Bốn ví dụ có số

Ví dụ 1, endpoint login. Đặt 5 lần/phút/IP. Lần thứ 6 trả 429 với Retry-After: 60. Việc này vừa giảm tải vừa là lớp chặn dò mật khẩu cơ bản (không thay thế các biện pháp xác thực an toàn khác).

Ví dụ 2, cái bẫy NAT. Văn phòng của một khách hàng có 800 nhân viên đi ra qua một IP công cộng. Nếu bạn đặt 60 request/phút/IP, cả văn phòng bị chặn oan. Cách xử lý: đếm theo user_id cho người đã đăng nhập, và nâng ngưỡng per-IP cho các endpoint rẻ. Đừng chỉ dựa vào IP.

Ví dụ 3, scale ngang làm ngưỡng nhân lên. Bạn đặt 100 request/phút trong code, chạy 8 instance. Ngưỡng thật mà client nhận được là 800 request/phút, vì mỗi instance đếm riêng và load balancer chia đều. Muốn một ngưỡng chung, phải có bộ đếm tập trung (thường là Redis) hoặc đẩy rate limit lên tầng gateway.

Ví dụ 4, rate limiter chết thì sao. Redis dùng để đếm bị restart. Chọn fail-open (cho qua, ghi cảnh báo) hay fail-closed (chặn hết)? Với API công khai, fail-open trong vài chục giây thường an toàn hơn là tự làm sập toàn bộ traffic. Hãy ghi rõ lựa chọn này vào runbook, đừng để on-call tự đoán lúc 2 giờ sáng.

Trong bài này chúng ta đã chặn được tải xấu. Nhưng có những request hoàn toàn hợp lệ mà vẫn chậm, ví dụ gửi email cho 10.000 người. Chặn chúng là sai. Đó là lý do bài tiếp theo nói về hàng đợi.

Vài mẹo từ những đợt traffic tăng vọt

  • Đặt rate limit trước khi tối ưu hiệu năng. Một ngưỡng rộng còn tốt hơn không có ngưỡng nào.
  • Luôn log số request bị chặn theo từng nhóm. Nếu con số này bằng 0 mãi, ngưỡng của bạn quá rộng. Nếu nó tăng vọt sau một lần deploy, bạn vừa chặn nhầm người dùng thật.
  • Trả 429 kèm Retry-After và một body dễ đọc. Client tốt sẽ tự backoff, client xấu sẽ tự bị loại.
  • Chừa đường cho health check và traffic nội bộ. Một lần tôi thấy load balancer đánh dấu toàn bộ instance là unhealthy vì chính health check bị rate limit.
  • Cập nhật ngưỡng mỗi khi capacity thay đổi (thêm instance, đổi instance type). Ngưỡng cũ sẽ lạc hậu sau vài tháng.
  • Đừng rate limit mọi thứ như nhau. Endpoint đắt cần ngưỡng chặt hơn endpoint đọc cache.

Tóm lại

Rate limit là lớp chặn rẻ nhất trong toàn bộ hệ thống. Hãy đặt nó ở cửa, chọn ngưỡng từ capacity đã đo, phân nhóm người dùng, và trả về _____ kèm header _____. Nhớ rằng khi scale ngang, mỗi instance đếm riêng thì ngưỡng thật sẽ nhân lên theo _____, nên cần bộ đếm tập trung.

Bài tiếp theo: Hàng đợi cho tác vụ nặng, cách đẩy những việc dài ra background để rate limiter không phải chặn nhầm công việc hợp lệ.

Thuật toán _____ cho phép burst ngắn nhưng vẫn giữ tốc độ trung bình trong tầm kiểm soát. Mã lỗi đúng khi client vượt ngưỡng là _____, kèm header _____ để client biết chờ bao lâu. Ngưỡng global nên lấy từ capacity đo được nhân với hệ số an toàn khoảng _____. Khi rate limiter dùng Redis bị sự cố, chọn _____ nghĩa là cho request đi qua và ghi cảnh báo.

Front: Rate limiting nên đặt ở đâu trong luồng request? Back: Trước app server, thường ở edge hoặc gateway, để tải xấu không chạm tới app và database.

Front: Vì sao chặn ở rate limiter rẻ hơn chặn ở database? Back: Rate limiter tốn vài micro giây, còn request đi tới database tốn connection pool và query.

Front: Mã lỗi nào dùng khi client vượt ngưỡng rate limit? Back: 429 Too Many Requests, kèm header Retry-After.

Front: Vì sao không dùng 403 khi vượt rate limit? Back: 403 nghĩa là không có quyền, client sẽ không thử lại, trong khi đây chỉ là chờ tạm thời.

Front: Vì sao không dùng 503 khi vượt rate limit? Back: 503 báo server gặp lỗi, làm giám sát và client hiểu sai bản chất sự việc.

Front: Token bucket khác fixed window ở điểm nào? Back: Token bucket cho phép burst ngắn có kiểm soát, fixed window có thể bị burst gấp đôi ở ranh giới cửa sổ.

Front: Cách tính ngưỡng global từ capacity? Back: RPS mỗi instance × số instance × hệ số an toàn khoảng 0,6 đến 0,7.

Front: Vì sao không nên rate limit chỉ theo IP? Back: Nhiều người dùng thật có thể đi chung một IP qua NAT, dẫn tới chặn nhầm cả tập thể.

Front: Đặt rate limit trong code ở 8 instance thì ngưỡng thật là bao nhiêu? Back: Gấp 8 lần ngưỡng trong code, vì mỗi instance đếm riêng.

Front: Muốn có một ngưỡng chung khi scale ngang thì cần gì? Back: Bộ đếm tập trung như Redis, hoặc đẩy rate limit lên tầng gateway.

Front: fail-open trong rate limiter nghĩa là gì? Back: Khi bộ đếm gặp sự cố, cho request đi qua và ghi cảnh báo thay vì chặn hết.

Front: Vì sao phải chừa health check khỏi rate limit? Back: Nếu health check bị chặn, load balancer có thể đánh dấu toàn bộ instance là unhealthy.

Front: Ngưỡng per-client nên lấy từ đâu? Back: Từ hành vi thật trong log: p99 request mỗi phút của người dùng bình thường, nhân khoảng 2.

Front: Endpoint đắt như login hay search nên đặt ngưỡng thế nào? Back: Chặt hơn endpoint rẻ từ 5 đến 10 lần, vì mỗi request tốn nhiều tài nguyên hơn.