Bài trước phân biệt semaphore (giới hạn số đồng thời) với rate limiter (giới hạn tốc độ). Bài này đi sâu vào rate limiter chuẩn của Go: golang.org/x/time/rate, dựa trên thuật toán token bucket. Nó trả lời câu hỏi khác semaphore: không phải "bao nhiêu chạy cùng lúc" mà "bao nhiêu sự kiện mỗi giây" — và cho phép burst (đợt dồn ngắn). Bài này đo thật hành vi của nó: burst, tốc độ hội tụ, và ba cách lấy token.
Thuật toán token bucket
Hình dung một cái xô: chứa tối đa burst token, tự đổ đầy với tốc độ rate token mỗi giây. Mỗi sự kiện tiêu một token. Hết token thì chờ (hoặc từ chối):
lim := rate.NewLimiter(rate.Limit(5), 3)
// 5 token/giây burst=3
// → trung bình 5/giây, nhưng cho phép DỒN tối đa 3 lần liền
burst là chìa khóa: nó cho phép các đợt ngắn vượt tốc độ trung bình (miễn xô còn token), rồi kéo về đúng tốc độ khi xô cạn. Đây là cân bằng giữa "mượt hoàn toàn" (burst=1) và "cho phép bùng ngắn" (burst lớn) — quan trọng cho traffic thực tế vốn không đều.

Hình 1: Token bucket. Xô chứa burst token, nạp rate token/giây. Ba cách lấy (Allow/Wait/Reserve), ví dụ burst, và khác biệt với semaphore.
Ba cách lấy token
x/time/rate cho ba cách, mỗi cách hợp một tình huống:
lim.Allow() // KHÔNG chờ: true nếu có token, false thì bỏ (drop)
lim.Wait(ctx) // CHỜ tới khi có token (tôn trọng ctx hủy/timeout)
r := lim.Reserve() // đặt chỗ: r.Delay() cho biết PHẢI CHỜ bao lâu; r.Cancel() trả token
Allowcho API "quá tải thì trả 429 ngay" — không muốn giữ request chờ.Waitcho worker "cứ chờ rồi làm" — throttle công việc nền theo tốc độ.Reservecho logic tùy biến — biết trước delay để quyết định (chờ, bỏ, hay báo client).
Đo thật: burst và tốc độ
Burst: NewLimiter(5/giây, burst=3), gọi Allow() 5 lần liền:
→ true true true false false // 3 token đầu có sẵn, hết → bị bỏ
Xô bắt đầu đầy 3 token nên 3 sự kiện đầu qua ngay; token cạn nên các sự kiện tiếp bị từ chối cho tới khi xô nạp lại.
Tốc độ: đo bằng cách gọi Wait() nhiều lần và tính thời gian:

Hình 2: Burst (true×3 rồi false). Tốc độ: Wait 20 lần ở 5/giây burst 3 mất 3,40s (kỳ vọng (20-3)/5); Wait 200 lần ở 50/giây → 50,2/giây (hội tụ); Reserve báo chờ 0,50s. So rate limiter vs semaphore.
- Wait 20 lần ở 5/giây, burst 3: mất 3,40s ≈ (20-3)/5 (3 token burst dùng ngay, 17 còn lại ở 5/giây).
- Wait 200 lần ở 50/giây, burst 1: 3,98s → 50,2/giây — hội tụ chính xác về tốc độ cấu hình.
- Reserve khi hết token ở 2/giây: báo chờ 0,50s (= thời gian nạp 1 token) mà không block.
Token bucket giữ tốc độ chính xác như cấu hình (50,2 so với mục tiêu 50), và Reserve cho biết trước delay — công cụ chính xác để kiểm soát tốc độ.
Ứng dụng thực tế
Bảo vệ backend/API khỏi quá tải. Đặt rate limiter trước một endpoint hoặc một dependency (DB, API bên ngoài) để không bao giờ vượt tốc độ an toàn. Dùng Allow + trả 429 cho traffic vào, hoặc Wait để throttle worker gọi dependency.
Tôn trọng rate limit của API bên thứ ba. Nhiều API giới hạn "X request/giây". Đặt một rate.Limiter khớp giới hạn đó và Wait() trước mỗi lời gọi — bạn không bao giờ bị 429 từ họ, và burst cho phép tận dụng hạn ngạch khi cần.
Rate limit theo client (per-key). Giữ một map[clientID]*rate.Limiter để giới hạn từng client riêng — chống một client lạm dụng làm ảnh hưởng client khác. Nhớ dọn limiter cũ (hoặc dùng thư viện có TTL) để không rò bộ nhớ.
Đánh đổi cần cân nhắc
Rate limiter và semaphore giải hai bài toán khác nhau — thường cần cả hai. Rate limiter kiểm soát tốc độ (sự kiện/giây), semaphore kiểm soát đồng thời (số chạy cùng lúc). Một request chạy 10 phút vẫn là "1 đồng thời" nhưng 1000 request/giây tức thời là vấn đề tốc độ. Backend thực thường đặt cả hai: rate limit chặn đợt dồn, semaphore chặn quá tải đồng thời.
Chọn burst cẩn thận. Burst quá nhỏ (=1) làm traffic bị "nghẹt" cứng nhắc, không chịu được đợt tự nhiên. Burst quá lớn cho phép đợt dồn lớn có thể vẫn làm ngợp backend trong khoảnh khắc. Cân theo khả năng chịu đợt của hệ thống phía sau — thường burst = vài giây tốc độ trung bình là hợp lý.
Rate limiter này là in-process, không phân tán. x/time/rate giới hạn trong một tiến trình. Nếu bạn chạy nhiều instance sau load balancer, mỗi instance có limiter riêng — tổng tốc độ = N × giới hạn. Rate limit phân tán (chia sẻ qua Redis) là bài toán khác, phức tạp hơn nhiều; đừng nhầm limiter in-process với giới hạn toàn cục.
Ba ý mang về
- Token bucket giới hạn tốc độ với burst: một xô chứa tối đa
bursttoken, nạpratetoken/giây, mỗi sự kiện tiêu 1 — đo thật burst 3 cho 3 lầnAllow()đầu qua ngay rồi bị bỏ, và tốc độ hội tụ chính xác về cấu hình (200 sự kiện ở 50/giây → 50,2/giây). - Ba cách lấy token cho ba tình huống:
Allow(không chờ, bỏ — cho API trả 429 ngay),Wait(ctx)(chờ tới khi có token — cho worker),Reserve(báo trước delay — đo thật 0,50s khi hết token ở 2/giây, cho logic tùy biến). - Rate limiter khác semaphore — thường cần cả hai: rate limiter giới hạn tốc độ (mỗi giây, cho burst), semaphore giới hạn đồng thời (cùng lúc) — backend thực đặt cả hai; và nhớ
x/time/ratelà in-process, nhiều instance thì tổng tốc độ nhân lên.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho cấu hình sau, mô tả chính xác hành vi khi gọi lim.Allow() liên tục 5 lần ngay từ đầu (không có độ trễ giữa các lần gọi).
lim := rate.NewLimiter(rate.Limit(5), 3)
Đáp án
Kết quả: true, true, true, false, false. Xô (bucket) bắt đầu đầy với burst = 3 token, nên 3 lần gọi Allow() đầu tiên đều có token sẵn để tiêu, trả về true ngay lập tức. Sau khi hết 3 token, xô cần thời gian để nạp lại (với tốc độ 5 token/giây), nên hai lần gọi tiếp theo (gần như tức thì, không đủ thời gian nạp thêm token) bị từ chối, trả về false — vì Allow() không chờ, chỉ kiểm tra ngay có token hay không.
Bài 2 (đọc hiểu số liệu). Bài đo: gọi Wait() 20 lần với cấu hình 5 token/giây, burst 3, tổng thời gian đo được ~3,40s, khớp công thức kỳ vọng (20-3)/5. Giải thích ý nghĩa của công thức này — vì sao là (20-3) chứ không phải 20, và vì sao chia cho 5.
Đáp án
(20-3) vì 3 lần gọi Wait() đầu tiên được phục vụ ngay lập tức bằng 3 token có sẵn trong burst ban đầu (không phải chờ), chỉ 17 lần gọi còn lại (20-3) mới thực sự phải chờ token được nạp mới. Chia cho 5 vì tốc độ nạp token là 5 token/giây — mỗi token mất trung bình 1/5 giây để xuất hiện, nên 17 lần chờ tuần tự sẽ tốn khoảng 17/5 = 3,4 giây. Công thức tổng quát: thời gian ≈ (số lần gọi - burst) / rate khi số lần gọi vượt quá burst.
Bài 3 (phân biệt khái niệm — trọng tâm). Một service nhận trung bình 2 request/giây, nhưng mỗi request có thể mất tới 30 giây để xử lý (gọi một API chậm bên ngoài). Đội kỹ sư muốn dùng một công cụ để đảm bảo "không quá 10 request đang được xử lý cùng lúc" (tránh service quá tải). Dựa trên phân biệt của bài, rate.Limiter (token bucket) hay semaphore (từ bài trước) là công cụ đúng ở đây? Giải thích.
Đáp án
Semaphore là công cụ đúng, không phải rate.Limiter. Vấn đề ở đây là giới hạn số lượng đang chạy đồng thời (10 request cùng lúc), không phải giới hạn tốc độ nhận request mỗi giây. Vì mỗi request mất tới 30 giây, ngay cả khi tốc độ đến chỉ 2 request/giây (khá thấp), số lượng request đang "sống" cùng lúc có thể tích lũy lên rất cao (2/giây × 30 giây = 60 request đang xử lý) nếu không giới hạn đồng thời — một rate.Limiter giới hạn tốc độ vào không kiểm soát được điều này vì nó không biết một request tốn bao lâu để xử lý xong. Semaphore với sức chứa 10 mới đúng ngữ nghĩa "tối đa 10 đang chạy cùng lúc, bất kể chúng nhanh hay chậm".
Bài 4 (vận dụng thực tế). Bạn cần gọi một API bên thứ ba có giới hạn "tối đa 20 request/giây", và muốn đợi thay vì bị từ chối khi vượt giới hạn (không muốn drop bất kỳ request nào). Viết đoạn code khởi tạo rate.Limiter phù hợp và gọi đúng phương thức để đạt yêu cầu này.
Đáp án
lim := rate.NewLimiter(rate.Limit(20), 1) // burst nhỏ để không vượt giới hạn API ngay từ đầu
func goiAPI(ctx context.Context, req Request) (Response, error) {
if err := lim.Wait(ctx); err != nil {
return Response{}, err // context bị hủy trong lúc chờ
}
return callThirdPartyAPI(req)
}
Dùng lim.Wait(ctx) thay vì Allow() vì Wait sẽ chờ tới khi có token thay vì từ chối ngay — đúng yêu cầu "đợi thay vì bị từ chối". Đặt burst nhỏ (ví dụ 1) để tránh việc dồn nhiều request tức thì có thể vượt giới hạn tức thời của API bên thứ ba; nếu API cho phép dồn một chút, có thể tăng burst tương ứng.
Bài 5 (bẫy/đánh đổi). Một hệ thống chạy 5 instance sau load balancer, mỗi instance dùng một rate.Limiter cấu hình rate.NewLimiter(rate.Limit(100), 10) với ý định "toàn hệ thống không vượt 100 request/giây tới database". Điều này có đạt được mục tiêu không? Giải thích và nêu bản chất của rate limiter trong x/time/rate.
Đáp án
Không đạt được mục tiêu. golang.org/x/time/rate là rate limiter in-process — mỗi instance có bộ đếm token riêng, hoàn toàn độc lập với các instance khác. Với 5 instance mỗi cái giới hạn 100/giây, tổng tốc độ thực tế tới database có thể lên tới 5 × 100 = 500 request/giây — gấp 5 lần mục tiêu mong muốn. Muốn giới hạn tốc độ toàn cục trên nhiều instance, cần một giải pháp rate limiting phân tán (ví dụ chia sẻ trạng thái qua Redis), là bài toán phức tạp hơn nhiều so với dùng x/time/rate cục bộ trong một tiến trình — không nên nhầm lẫn hai loại giới hạn này.
Phần sau ta xem một kỹ thuật đồng thời giải quyết vấn đề "nhiều request cùng hỏi một thứ": Phần sau mổ xẻ singleflight — cách gộp nhiều lời gọi trùng thành một để chống thundering herd (đàn sấm) khi cache miss, và đo thật hiệu quả.