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.

Ảnh chụp đoạn mã Go nền tối minh hoạ rate limiting bằng token bucket giới hạn tốc độ không phải số đồng thời, golang.org x time rate semaphore giới hạn bao nhiêu cùng lúc rate limiter giới hạn bao nhiêu mỗi giây, một thuật toán token bucket một cái xô chứa tối đa BURST token tự đổ đầy RATE token giây mỗi sự kiện tiêu 1 token hết token chờ hoặc từ chối lim bằng rate.NewLimiter rate.Limit 5 3 5 token giây burst bằng 3 trung bình 5 giây nhưng cho phép dồn tối đa 3 lần liền burst cho phép các đợt ngắn vượt tốc độ trung bình miễn xô còn token rồi bị kéo về đúng tốc độ khi xô cạn, hai ba cách lấy token 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 bằng lim.Reserve đặt chỗ r.Delay cho biết phải chờ bao lâu r.Cancel trả token nếu không dùng Allow cho API quá tải thì trả 429 ngay Wait cho worker cứ chờ rồi làm Reserve cho logic tùy biến biết delay để quyết định, ba ví dụ burst rồi bị chặn lim bằng rate.NewLimiter rate.Limit 5 3 for i bằng 0 i nhỏ hơn 5 i print lim.Allow true true true false false 3 token đầu có sẵn hết false, bốn khác semaphore bài trước semaphore giới hạn số đồng thời tối đa N chạy cùng lúc rate limit giới hạn tốc độ tối đa N mỗi giây hai trục khác nhau 1 request chạy 10 phút vẫn 1 đồng thời nhưng 1000 request giây tức thời là vấn đề tốc độ bảo vệ backend thường cần cả hai rate limit chặn đợt dồn semaphore chặn quá tải đồng thời

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
  • Allow cho API "quá tải thì trả 429 ngay" — không muốn giữ request chờ.
  • Wait cho worker "cứ chờ rồi làm" — throttle công việc nền theo tốc độ.
  • Reserve cho 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:

Ảnh chụp bảng kết quả đo thật nền tối token bucket giữ đúng tốc độ và burst go run golang.org x time rate v0.6.0 Go 1.23 arm64 10 core, burst 3 token đầu có sẵn hết thì chặn NewLimiter 5 giây burst bằng 3 gọi Allow 5 lần true true true false false 3 lần đầu OK lần 4-5 bị bỏ xô bắt đầu đầy 3 token nên 3 sự kiện đầu qua ngay burst 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 độ giới hạn đúng như cấu hình cấu hình Wait 20 lần 5 giây burst 3 đo thật 3,40 s kỳ vọng 3,4 s 20 trừ 3 chia 5 Wait 200 lần 50 giây burst 1 đo thật 3,98 s tốc độ 50,2 giây kỳ vọng 4,0 s 50 giây Reserve khi hết token 2 giây đo thật chờ 0,50 s kỳ vọng 1 chia 2 bằng 0,5 s token bucket hội tụ chính xác về tốc độ cấu hình 50,2 giây so với mục tiêu 50 Reserve báo trước delay 0,50s thời gian nạp 1 token ở 2 giây mà không block, rate limiter vs semaphore rate limiter tối đa N sự kiện mỗi giây kiểm soát tốc độ cho burst semaphore tối đa N chạy cùng lúc kiểm soát đồng thời backend thường cần cả hai rate chặn đợt dồn sem chặn quá tải, cốt lõi token bucket xô BURST token nạp RATE token giây mỗi sự kiện tiêu 1 ba cách Allow bỏ Wait chờ Reserve biết delay đúng đắn tốc độ hội tụ về cấu hình đo 50,2 vs 50 giây burst cho phép đợt ngắn vượt trung bình đo 3 token đầu khác sem tốc độ mỗi giây vs đồng thời cùng lúc

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ề

  1. Token bucket giới hạn tốc độ với burst: một xô chứa tối đa burst token, nạp rate token/giây, mỗi sự kiện tiêu 1 — đo thật burst 3 cho 3 lần Allow() đầ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).
  2. 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).
  3. 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/rate là 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ả.