Mở đầu loạt bài về hệ thống chịu tải, ta bắt đầu từ tuyến phòng thủ đầu tiên: rate limiting (giới hạn tốc độ). Khi traffic dồn dập — một client lỗi gọi vòng lặp, một đợt tấn công, hay đơn giản là quá đông người dùng — hệ của bạn cần một cách từ chối bớt để không sập. Rate limiting quyết định: request nào được qua, request nào bị chặn (thường trả 429 Too Many Requests). Bài này (phần 1 loạt Hệ thống chịu tải) chạy thật để thấy hai thuật toán phổ biến hoạt động và đo chính xác chúng chặn bao nhiêu.

Cơ chế: token bucket và leaky bucket

  • Token bucket: một cái thùng chứa token, nạp đều R token/giây, tối đa bằng sức chứa (burst). Mỗi request lấy một token; hết token thì bị chặn. Điểm hay: nếu thùng đang đầy (lâu không có request), nó cho phép một cụm (burst) vượt tốc độ tức thời — hợp với traffic thực có lúc dồn lúc thưa.
  • Leaky bucket: request vào một hàng đợi, "rò ra" đều với tốc độ cố định. Nó làm mượt traffic — đầu ra luôn đều, không cho burst.

Token bucket tự cài rất gọn — mấu chốt là nạp token theo thời gian trôi thay vì dùng timer:

func (t *TokenBucket) Allow() bool {
    now := time.Now()
    t.tokens += now.Sub(t.last).Seconds() * t.refill  // nạp theo thời gian
    if t.tokens > t.max { t.tokens = t.max }           // không quá sức chứa
    t.last = now
    if t.tokens >= 1 { t.tokens--; return true }       // còn token -> cho qua
    return false                                       // hết -> chặn
}

Thư viện chuẩn Go có sẵn golang.org/x/time/rate:

lim := rate.NewLimiter(100, 100)  // 100 token/s, burst 100
if lim.Allow() { /* xử lý */ } else { /* trả 429 */ }

Ảnh chụp đoạn mã Go nền tối minh hoạ rate limiting, hai thuật toán token bucket thùng nạp đều R token mỗi giây tối đa bằng sức chứa burst mỗi request lấy 1 token hết token chặn cho phép cụm burst ngắn vượt tức thời tới sức chứa thùng leaky bucket request vào hàng đợi rò ra đều tốc độ cố định làm mượt traffic không cho burst đầu ra luôn đều, token bucket tự cài nạp token theo thời gian trôi func Allow now time Now t.tokens cộng now Sub t.last Seconds nhân t.refill nạp theo thời gian if t.tokens lớn hơn t.max t.tokens bằng t.max không quá sức chứa t.last bằng now if t.tokens lớn hơn hoặc bằng 1 t.tokens trừ return true còn token cho qua return false hết chặn, dùng thư viện chuẩn x time rate lim rate NewLimiter 100 100 100 token mỗi giây burst 100 if lim Allow xử lý else trả 429 Too Many Requests đo bắn nhiều request đếm allowed vs denied so với giới hạn

Hình 1: Token bucket (nạp token đều, cho phép burst tới sức chứa) vs leaky bucket (rò đều, làm mượt); token bucket tự cài nạp token theo thời gian trôi; hoặc dùng x/time/rate.

Đo thật: burst, bền vững, và rate ổn định

Mình đặt giới hạn 100/s và đo ba kịch bản:

Ảnh chụp bảng kết quả chạy thật rate limiting output thật, một BURST 1000 request tức thì rps 100 burst 100 cho qua 100 bằng sức chứa thùng chặn 900 thùng đầy 100 token cụm 100 request đầu qua ngay burst phần còn lại bị chặn vì hết token, hai SUSTAINED bắn liên tục trong 1 giây 100 mỗi giây burst 100 tổng thử 9699853 cho qua 199 chặn 9699654 cho qua khoảng 199 bằng burst 100 cộng khoảng 100 nạp trong 1s khớp giới hạn 100 mỗi giây, ba rate ổn định 3 giây burst 1 bỏ hiệu ứng burst đầu cho qua 300 request trong 3s bằng 100.0 req mỗi giây đạt đúng giới hạn 100 mỗi giây, kết luận token bucket cho phép burst ngắn 100 rồi giữ tốc độ đều 100 mỗi giây bền vững đo được đúng 100 req mỗi giây như cấu hình token bucket cho burst hợp API leaky bucket mượt đầu ra đều đặt ngưỡng theo capacity thật trả 429 khi chặn nhiều server cần rate limit phân tán Redis vì mỗi server đếm riêng

Hình 2: Chạy thật (giới hạn 100/s) — burst: 1000 request tức thì chỉ 100 qua (= sức chứa), 900 chặn; sustained 1s: 9,7 triệu lần thử, 199 qua (burst 100 + ~100 nạp); ổn định 3s: 300 qua = đúng 100 req/s.

Đọc kết quả đo được:

  • Burst cho cụm đầu qua ngay: bắn 1000 request tức thì, đúng 100 qua (bằng sức chứa thùng đang đầy), 900 bị chặn. Đây là đặc tính quan trọng của token bucket: nó không trải đều một cách cứng nhắc mà cho phép một cụm ngắn vượt tốc độ — phù hợp traffic thực (người dùng bấm nhiều nút liền một lúc rồi nghỉ).
  • Bền vững khớp giới hạn: bắn liên tục suốt 1 giây (9,7 triệu lần thử!), chỉ 199 qua — đúng bằng burst 100 cộng ~100 token nạp trong 1 giây. Dù áp lực khổng lồ, số cho qua vẫn bám sát cấu hình 100/s.
  • Rate ổn định chính xác: bỏ hiệu ứng burst đầu (đặt burst=1), chạy 3 giây cho 300 request qua = 100,0 req/s — đúng y giới hạn. Token bucket giữ tốc độ trung bình chính xác về lâu dài.

Đánh đổi cần cân nhắc

Token bucket cho burst, leaky bucket cho mượt — chọn theo nhu cầu. Nếu bạn muốn cho phép các cụm ngắn (API công khai, người dùng thao tác theo đợt), token bucket đúng: burst giúp trải nghiệm mượt mà mà vẫn giới hạn trung bình. Nếu bạn cần bảo vệ một tài nguyên có tốc độ xử lý cố định (ghi đĩa, gọi API bên thứ ba có quota cứng), leaky bucket tốt hơn vì đầu ra luôn đều, không dồn cục. Nhiều hệ dùng token bucket vì nó dung hòa tốt.

Đặt ngưỡng theo capacity thật, không theo cảm tính. Rate limit chỉ có ý nghĩa nếu ngưỡng phản ánh khả năng thật của hệ. Đặt quá cao thì không bảo vệ được (hệ vẫn sập trước khi chạm ngưỡng); quá thấp thì chặn nhầm người dùng hợp lệ. Cách đúng: đo throughput bền vững mà hệ chịu được (như các bài đo trong loạt Mạng), đặt ngưỡng dưới mức đó một chút, và trả 429 rõ ràng (kèm header Retry-After) để client biết đường lùi.

Rate limit trong một tiến trình khác với phân tán. Ví dụ ở đây giới hạn trong một tiến trình. Khi bạn có nhiều instance sau load balancer, mỗi instance đếm token riêng — tổng giới hạn thực tế thành N × ngưỡng, không phải ngưỡng. Rate limit phân tán cần một store chung (thường là Redis với thuật toán token bucket/sliding window) để mọi instance chia sẻ cùng bộ đếm. Đây là bước phức tạp hơn nhiều và có chi phí độ trễ riêng — chỉ làm khi thật cần giới hạn toàn cục.

Ba ý mang về

  1. Token bucket cho phép burst rồi giữ tốc độ trung bình đúng ngưỡng: đo thật 1000 request tức thì chỉ 100 qua (burst = sức chứa), nhưng 3 giây liên tục cho qua chính xác 100 req/s — dung hòa giữa mượt trải nghiệm và bảo vệ hệ.
  2. Token bucket vs leaky bucket là lựa chọn theo nhu cầu: token bucket cho burst (hợp API, thao tác theo đợt), leaky bucket làm mượt đầu ra đều (bảo vệ tài nguyên tốc độ cố định); dùng x/time/rate cho token bucket sẵn.
  3. Đặt ngưỡng theo capacity thật, và phân tán cần store chung: đo throughput hệ chịu được rồi đặt dưới mức đó, trả 429 + Retry-After; nhiều instance thì mỗi cái đếm riêng nên cần Redis để giới hạn toàn cục.

Nguồn

Phần sau ta gặp circuit breaker — khi một service phụ thuộc bắt đầu lỗi liên tục, cầu dao "ngắt mạch" để ngừng gọi nó, trả lỗi nhanh thay vì chờ timeout mãi; đo thật trạng thái đóng/mở/nửa-mở và cách nó bảo vệ hệ khỏi sụp dây chuyền.