Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 40 — Rate Limiting — Algorithms

Architecture Decision-Making Bài 40/60

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn là kiến trúc sư của một hệ thống thanh toán như VNPay hay MoMo. Vào lúc 0h ngày mùng 1 Tết, hàng triệu người cùng lúc bấm gửi lì xì. Hoặc bạn vận hành một cổng API cho ứng dụng giao hàng như GHTK, và một đối tác tích hợp sai, gọi API của bạn 50.000 lần mỗi giây thay vì 50 lần. Nếu không có cơ chế nào chặn lại, database của bạn sập, những khách hàng đang trả tiền thật cũng chịu chung số phận, và bạn mất tiền lẫn uy tín chỉ trong vài phút.

Rate limiting (giới hạn tốc độ) chính là lớp phòng thủ đó. Nó là quyết định kiến trúc để trả lời câu hỏi: "Một client được phép gọi bao nhiêu request trong một khoảng thời gian?" Nghe đơn giản, nhưng cách bạn đếmreset con số đó — tức là thuật toán bạn chọn — tạo ra khác biệt rất lớn về độ chính xác, chi phí bộ nhớ, và trải nghiệm người dùng.

Trong bài này chúng ta không bàn có nên rate limit hay không (câu trả lời gần như luôn là "có"), mà đi sâu vào các thuật toán rate limiting: Fixed Window, Sliding Window Log, Sliding Window Counter, Token Bucket và Leaky Bucket. Với mỗi thuật toán, bạn sẽ hiểu cơ chế hoạt động, đánh đổi (trade-off), và tình huống nào nên dùng. Đây là kiến thức mà một Senior/Principal Engineer cần nắm chắc, vì khi thiết kế API gateway hay bảo vệ service, chọn sai thuật toán sẽ khiến bạn hoặc chặn nhầm khách hàng tốt, hoặc để lọt các đợt burst gây quá tải.

Khái niệm cốt lõi

Trước khi vào từng thuật toán, cần thống nhất vài khái niệm nền. Rate là số request cho phép trên một đơn vị thời gian, ví dụ "100 request/phút". Burst là khả năng chấp nhận một cụm request đến dồn dập trong thời gian rất ngắn. Client identity là cách bạn nhận diện ai đang gọi — thường theo API key, user ID, hoặc địa chỉ IP. Mọi thuật toán dưới đây đều lưu một trạng thái đếm gắn với client identity đó, thường trong bộ nhớ nhanh như Redis để nhiều instance của service cùng chia sẻ.

Fixed Window Counter

Đây là thuật toán đơn giản nhất, đúng như ghi chú gốc của bài. Bạn chia thời gian thành các cửa sổ cố định, ví dụ mỗi phút một cửa sổ. Với mỗi client, bạn giữ một bộ đếm cho cửa sổ hiện tại. Mỗi request đến, bạn tăng counter lên 1. Nếu counter vượt ngưỡng (limit), bạn từ chối request đó. Khi sang cửa sổ mới, counter reset về 0.

Trong Redis, cách làm kinh điển là dùng key dạng ratelimit:{user_id}:{minute}, thực hiện INCR rồi đặt EXPIRE 60 giây. Cực kỳ đơn giản, tốn ít bộ nhớ (chỉ một số nguyên cho mỗi client mỗi cửa sổ), và dễ hiểu.

Nhưng Fixed Window có một điểm yếu chí mạng: vấn đề burst ở ranh giới cửa sổ (edge burst). Giả sử limit là 600 request/phút. Một client hoàn toàn có thể gửi 600 request vào giây thứ 59 của phút A, rồi thêm 600 request vào giây thứ 1 của phút B. Trong khoảng 2 giây đó, client đã đẩy 1.200 request — gấp đôi mức bạn định cho phép — mà không vi phạm luật của bất kỳ cửa sổ nào. Với hệ thống nhạy cảm về tải, đây là lỗ hổng nghiêm trọng.

Sliding Window Log

Để giải quyết edge burst, ý tưởng đầu tiên là lưu timestamp của từng request trong một cửa sổ trượt. Khi request mới đến, bạn xóa hết các timestamp cũ hơn (now - window), đếm số timestamp còn lại. Nếu số đó nhỏ hơn limit thì cho phép và thêm timestamp mới; ngược lại thì từ chối.

Thuật toán này chính xác tuyệt đối: cửa sổ luôn trượt theo thời gian thực, không có kẽ hở ranh giới. Trong Redis người ta thường dùng Sorted Set (ZADD để thêm, ZREMRANGEBYSCORE để dọn timestamp cũ, ZCARD để đếm).

Cái giá phải trả là bộ nhớ. Nếu limit là 10.000 request/phút cho mỗi client, bạn phải lưu tới 10.000 timestamp cho mỗi client. Với hàng triệu client, dung lượng bùng nổ. Vì vậy Sliding Window Log chỉ phù hợp khi limit thấp hoặc số client giới hạn — ví dụ các endpoint nhạy cảm như đăng nhập, đặt lại mật khẩu, gửi OTP.

Sliding Window Counter

Đây là sự dung hòa thông minh giữa Fixed Window (tiết kiệm) và Sliding Window Log (chính xác). Thay vì lưu từng timestamp, bạn giữ counter cho cửa sổ hiện tại và cửa sổ trước đó, rồi ước lượng có trọng số.

Công thức: count = counter_hiện_tại + counter_trước × (phần_trăm_còn_lại_của_cửa_sổ_trước). Ví dụ limit 600/phút. Cửa sổ trước có 500 request, cửa sổ hiện tại có 200 request, và ta đang ở giây thứ 15 của cửa sổ hiện tại (tức 25% trôi qua, 75% cửa sổ trước vẫn nằm trong khoảng trượt). Ước lượng: 200 + 500 × 0.75 = 575. Vẫn dưới 600 nên cho phép.

Cách này chỉ cần lưu 2 số nguyên cho mỗi client, gần như rẻ ngang Fixed Window, nhưng làm mượt được edge burst đáng kể. Nó không chính xác 100% như Log (vì giả định request phân bố đều trong cửa sổ trước), nhưng sai số thực tế rất nhỏ. Đây là thuật toán mà Cloudflare công bố họ dùng ở quy mô rất lớn, và là lựa chọn mặc định tốt cho phần lớn API công khai.

Token Bucket

Token Bucket đảo ngược góc nhìn: thay vì đếm request đã dùng, bạn phát token đều đặn. Hình dung một cái xô chứa tối đa N token. Mỗi khoảng thời gian, hệ thống nhỏ thêm token vào xô với tốc độ cố định (refill rate), nhưng không bao giờ vượt quá N. Mỗi request đến phải lấy ra một token; nếu xô hết token thì request bị từ chối (hoặc chờ).

Điểm mạnh của Token Bucket là cho phép burst có kiểm soát. Nếu client im lặng một lúc, xô đầy dần lên tới N token; khi cần, client có thể tiêu N request liền một lúc rồi sau đó bị bó về tốc độ refill. Điều này rất tự nhiên với hành vi thật của người dùng: họ hay hoạt động theo cụm. Đây là lý do Token Bucket cực kỳ phổ biến — AWS API Gateway, Stripe, và nhiều thư viện như golang.org/x/time/rate đều dựa trên nó. Bạn chỉ cần lưu 2 giá trị cho mỗi client: số token hiện có và thời điểm refill lần cuối.

Leaky Bucket

Leaky Bucket nhìn giống Token Bucket nhưng mục tiêu khác. Ở đây request đi vào một hàng đợi (cái xô), và được xử lý ra với tốc độ cố định — như nước rỉ ra khỏi xô thủng đáy. Nếu xô đầy (queue full), request mới bị bỏ.

Khác biệt cốt lõi: Leaky Bucket làm phẳng lưu lượng đầu ra thành một dòng đều tăm tắp, không cho phép burst đi qua. Token Bucket cho burst; Leaky Bucket chặn burst. Vì vậy Leaky Bucket hợp với những nơi hệ thống hạ nguồn cần nhịp ổn định — ví dụ gọi tới một payment gateway đối tác chỉ chịu được đúng 100 request/giây đều đặn, không được dồn cục.

Tình huống thực tế

Ví dụ 1 — Sàn thương mại điện tử Việt Nam và cú flash sale 12/12

Một sàn TMĐT (tạm gọi ShopVN) chạy chương trình flash sale lúc 0h ngày 12/12. Đội API ban đầu dùng Fixed Window: 100 request/phút cho mỗi user trên endpoint POST /checkout. Đúng khoảnh khắc 0h00, hàng chục nghìn khách bấm "Mua ngay" cùng lúc. Vì Fixed Window reset tại đầu phút, một loạt user đã bắn hết quota vào những giây cuối của 23h59 rồi lại full quota ngay 0h00 — tạo ra cú edge burst gấp đôi đổ thẳng vào tầng inventory. Database khóa row tồn kho quá lâu, timeout dây chuyền, nhiều khách trả tiền xong nhưng đơn không ghi nhận.

Sau sự cố, đội chuyển endpoint checkout sang Sliding Window Counter kết hợp một lớp Token Bucket ở API gateway cho phép burst nhỏ (10 token) hấp thụ các cú bấm dồn tự nhiên. Kết quả kỳ sale sau: đường tải phẳng hơn hẳn, không còn cú spike ranh giới phút, và tỉ lệ đơn lỗi giảm rõ rệt.

Bài học: Với sự kiện có mốc thời gian chẵn (0h, đầu giờ), Fixed Window là mồi lửa cho edge burst. Chọn Sliding Window hoặc Token Bucket để phân tán tải.

Ví dụ 2 — Cổng OTP của một ngân hàng số

Một ngân hàng số ở Đông Nam Á giới hạn gửi OTP: tối đa 5 tin/số điện thoại/giờ, để chống lạm dụng và chống kẻ xấu đốt tiền SMS. Ở đây limit rất thấp (5) nhưng yêu cầu chính xác tuyệt đối — không được để lọt tin thứ 6 chỉ vì kẽ hở ranh giới, vì mỗi SMS tốn tiền thật và là bề mặt tấn công.

Đội bảo mật chọn Sliding Window Log: lưu timestamp của tối đa 5 lần gửi trong Redis Sorted Set cho mỗi số điện thoại. Vì limit chỉ 5, chi phí bộ nhớ không đáng kể, và họ đổi lấy độ chính xác tuyệt đối của cửa sổ trượt thật. Khi kẻ tấn công thử spam OTP để làm ngân hàng tốn tiền SMS, hệ thống chặn chính xác ở đúng lần thứ 6, không sớm không muộn.

Bài học: Khi limit nhỏ và mỗi request có chi phí/rủi ro cao (OTP, đăng nhập, reset mật khẩu), Sliding Window Log là lựa chọn đáng đồng tiền dù nó "đắt" về lý thuyết.

Ví dụ 3 — API đối tác của một công ty logistics

Một công ty logistics (tạm gọi FastShip) mở API tra cứu vận đơn cho đối tác. Một đối tác tích hợp sai, chạy vòng lặp gọi API 50.000 lần/giây thay vì poll mỗi vài giây. Nếu để lọt, cluster tra cứu quá tải, ảnh hưởng mọi đối tác khác.

FastShip dùng Token Bucket tại gateway: mỗi API key được cấp xô 200 token, refill 100 token/giây. Đối tác gọi bình thường theo cụm nhỏ thì không bao giờ chạm giới hạn nhờ burst được cho phép. Nhưng đối tác lỗi kia lập tức cạn xô, nhận về 429 Too Many Requests kèm header Retry-After, mà không kéo sập service dùng chung. Đội vận hành cũng đặt limit khác nhau theo hạng đối tác (free/premium) chỉ bằng cách chỉnh kích thước xô và refill rate.

Bài học: Token Bucket vừa bảo vệ hệ thống trước client lỗi/độc hại, vừa linh hoạt cho hành vi burst hợp lệ, lại dễ phân tầng theo gói dịch vụ.

Hướng dẫn từng bước

Khi cần đưa rate limiting vào kiến trúc, hãy đi theo trình tự sau.

Bước 1 — Xác định client identity. Bạn giới hạn theo cái gì: API key, user ID, IP, hay tổ hợp (ví dụ IP + endpoint)? Với API có xác thực, ưu tiên API key/user ID vì IP dễ bị chia sẻ (NAT của một công ty, một nhà mạng).

Bước 2 — Chọn ngưỡng và đơn vị thời gian. Xuất phát từ khả năng chịu tải thật của service hạ nguồn, không phải con số cho đẹp. Ví dụ database chịu được 2.000 QPS an toàn thì tổng limit phải nằm dưới đó với biên dự phòng.

Bước 3 — Chọn thuật toán theo yêu cầu. Dùng bảng quyết định nhanh: cần đơn giản và chấp nhận sai lệch → Fixed Window; cần chính xác tuyệt đối với limit thấp → Sliding Window Log; cần cân bằng chính xác/tiết kiệm cho API công khai quy mô lớn → Sliding Window Counter; cần cho phép burst tự nhiên → Token Bucket; cần dòng ra đều tuyệt đối để bảo vệ hạ nguồn → Leaky Bucket.

Bước 4 — Chọn nơi lưu trạng thái. Nếu service chạy nhiều instance (gần như luôn luôn), trạng thái phải tập trung — thường là Redis, dùng Lua script để đảm bảo thao tác đọc-tính-ghi là nguyên tử (atomic), tránh race condition khi nhiều instance cùng cập nhật một counter.

Bước 5 — Thiết kế response khi vượt giới hạn. Trả HTTP 429 Too Many Requests. Kèm các header chuẩn: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, và đặc biệt Retry-After để client biết chờ bao lâu. Client tốt sẽ tôn trọng các header này thay vì retry điên cuồng.

Bước 6 — Đặt rate limit ở đúng tầng. Thường là ở API gateway hoặc reverse proxy (Nginx, Kong, Envoy) để chặn từ sớm, trước khi request tiêu tài nguyên của service. Có thể phối hợp nhiều tầng: một limit thô ở gateway, một limit tinh theo nghiệp vụ trong ứng dụng.

Bước 7 — Giám sát và tinh chỉnh. Theo dõi tỉ lệ 429, phân bố theo client. Nếu khách hàng tốt bị chặn nhiều, ngưỡng quá chặt. Nếu tải vẫn spike dù ít 429, có thể thuật toán để lọt burst.

Lỗi thường gặp & mẹo

Lỗi 1 — Dùng Fixed Window cho hệ thống nhạy cảm với burst. Đây là lỗi hay gặp nhất vì Fixed Window dễ code. Hãy nhớ vấn đề edge burst 2x và cân nhắc Sliding Window Counter — chi phí thêm gần như bằng không nhưng an toàn hơn nhiều.

Lỗi 2 — Không nguyên tử hóa thao tác trên Redis. Nếu bạn GET counter, kiểm tra ở tầng ứng dụng, rồi SET lại, hai instance có thể cùng đọc giá trị cũ và cùng cho phép — vượt limit. Luôn dùng INCR (đã nguyên tử) hoặc Lua script gói toàn bộ logic vào một lệnh chạy trên Redis.

Lỗi 3 — Quên xử lý khi Redis chết. Nếu tầng lưu trạng thái không truy cập được, bạn "fail open" (cho qua hết) hay "fail closed" (chặn hết)? Với hệ thống bảo vệ tài nguyên, fail open thường an toàn hơn về trải nghiệm nhưng nguy hiểm khi đang bị tấn công. Hãy quyết định có chủ đích và ghi rõ trong ADR.

Lỗi 4 — Chỉ giới hạn theo IP. Nhiều người dùng hợp lệ có thể chung một IP (văn phòng, wifi quán cà phê, CGNAT của nhà mạng). Chặn theo IP dễ bắt nhầm cả nhóm. Với API xác thực, giới hạn theo user/API key.

Lỗi 5 — Không trả Retry-After. Thiếu header này, client không biết chờ bao lâu và thường retry ngay lập tức, tạo cơn bão retry làm tình hình tệ hơn (retry storm).

Mẹo: Phân biệt rõ hai mục tiêu. Fairness (công bằng giữa các client) thường hợp với per-client Token Bucket. Bảo vệ hạ nguồn (giữ cho service không quá tải) hợp với Leaky Bucket hoặc một global limit. Nhiều hệ thống lớn chồng cả hai tầng.

Mẹo: Với hệ thống phân tán nhiều node, đừng chia đều limit cứng cho mỗi node (ví dụ 3 node thì mỗi node 33%) vì tải không phân bố đều. Hoặc dùng counter tập trung trên Redis, hoặc dùng thuật toán ước lượng chấp nhận sai số nhỏ.

Bài tập thực hành

  • Tính edge burst. Với Fixed Window limit 1.000 request/phút, hãy chỉ ra kịch bản cụ thể (theo giây) mà một client đẩy được gần 2.000 request trong khoảng ngắn nhất có thể mà không vi phạm luật cửa sổ nào. Ghi rõ mốc thời gian.
  • Áp dụng công thức Sliding Window Counter. Limit 300/phút. Cửa sổ trước có 280 request, cửa sổ hiện tại có 40 request, hiện đang ở giây thứ 30 của cửa sổ hiện tại. Tính giá trị ước lượng và cho biết request tiếp theo được chấp nhận hay từ chối.
  • Thiết kế Token Bucket. Bạn có một service hạ nguồn chịu được ổn định 500 QPS và chấp nhận burst tối đa 1 giây. Hãy chọn kích thước xô (N) và refill rate cho Token Bucket, giải thích lý do.
  • Ra quyết định thuật toán. Cho 4 tình huống, chọn thuật toán phù hợp và giải thích một câu: (a) endpoint reset mật khẩu 3 lần/giờ; (b) API public tra cứu thời tiết 10.000 client, mỗi client 1.000 req/phút; (c) gọi tới đối tác chỉ chịu đúng 50 req/giây đều; (d) internal tool cho 5 nhân viên, cần đơn giản nhất.
  • Viết ADR ngắn. Chọn một API bạn từng làm hoặc tưởng tượng, viết nửa trang quyết định: client identity, ngưỡng, thuật toán chọn, nơi lưu trạng thái, hành vi khi store lỗi (fail open/closed), và response 429 gồm những header nào.

Tóm tắt

Rate limiting là lớp phòng thủ nền tảng cho mọi hệ thống có API, và thuật toán bạn chọn quyết định độ chính xác, chi phí bộ nhớ lẫn trải nghiệm người dùng. Fixed Window đơn giản nhưng dính edge burst gấp đôi ở ranh giới cửa sổ. Sliding Window Log chính xác tuyệt đối nhưng tốn bộ nhớ, hợp với limit thấp và request đắt đỏ như OTP. Sliding Window Counter dung hòa tốt, là lựa chọn mặc định cho API công khai quy mô lớn. Token Bucket cho phép burst tự nhiên và dễ phân tầng theo gói dịch vụ. Leaky Bucket làm phẳng dòng ra tuyệt đối để bảo vệ hạ nguồn.

Không có thuật toán "tốt nhất" — chỉ có thuật toán phù hợp với ràng buộc của bạn: cần cho burst hay chặn burst, cần chính xác tuyệt đối hay tiết kiệm bộ nhớ, bảo vệ hạ nguồn hay công bằng giữa client. Khi thiết kế, hãy xuất phát từ khả năng chịu tải thật của hệ thống, đặt limit ở đúng tầng, nguyên tử hóa thao tác đếm, và luôn trả về 429 kèm Retry-After để client hành xử văn minh. Nắm chắc năm thuật toán này, bạn đã có công cụ để bảo vệ hệ thống của mình qua cả những đợt flash sale 0h căng thẳng nhất.