Khi API của Bạn Trở Thành Chiếc Bao Cát
Tôi đã chứng kiến điều này xảy ra không ít lần: một service vừa lên production, mọi thứ trông ổn thỏa, rồi traffic đột biến — hoặc ai đó bắt đầu liên tục gọi endpoints — và đột nhiên thời gian phản hồi tăng vọt, database gồng mình, người dùng bắt đầu thấy lỗi. Điều tệ nhất? Hoàn toàn có thể ngăn chặn được.
Rate limiting là một trong những thứ đầu tiên tôi tích hợp vào bất kỳ API production nào bây giờ. Không phải bổ sung sau, không phải thư viện tôi gắn vào mà không hiểu — mà là thứ tôi hiểu đủ để tự triển khai từ đầu. Thuật toán Token Bucket là lựa chọn hàng đầu của tôi: đơn giản, linh hoạt, và xử lý các pattern traffic thực tế tốt hơn nhiều so với cách tiếp cận fixed window.
Nếu bạn đang xây dựng API bằng Go và chưa thêm rate limiting, bài viết này sẽ hướng dẫn bạn xây dựng nó từ đầu — khoảng 100 dòng code mà bạn thực sự sẽ hiểu được.
Vấn Đề với API Không Được Bảo Vệ
Không có rate limiting, một client cấu hình sai có thể kéo sập toàn bộ service của bạn. Tôi đã tận mắt thấy điều đó xảy ra với một script bị thiếu câu lệnh sleep — không phải tấn công thật sự, chỉ là automation của ai đó gọi webhook liên tục trong một vòng lặp chặt. Server bắt đầu xếp hàng requests, bộ nhớ tăng dần, và chỉ trong vòng một phút những người dùng bình thường đã bắt đầu timeout.
Rate limiting giải quyết ba vấn đề khác nhau:
- Bảo vệ DoS — một IP đơn lẻ không thể chiếm hết tài nguyên server
- Sử dụng công bằng — một client nặng không thể làm giảm chất lượng dịch vụ cho những người khác
- Kiểm soát chi phí — các downstream service (database, external API) không bị tràn ngập
Hiểu Thuật Toán Token Bucket
Hãy hình dung một chiếc xô chứa các token. Mỗi request đến sẽ tiêu thụ một token. Token được nạp lại với tốc độ cố định. Nếu xô rỗng khi có request đến, request đó bị từ chối.
Hai tham số định nghĩa tất cả:
- Capacity (dung lượng) — số token tối đa xô có thể chứa (kiểm soát kích thước burst)
- Refill rate (tốc độ nạp) — token được thêm mỗi giây (kiểm soát throughput duy trì)
Token Bucket có ưu thế thực sự so với cách đếm fixed window. Fixed window nói “100 request mỗi phút” — không có gì ngăn client gửi cả 100 request trong giây đầu tiên. Token Bucket xử lý burst hợp lệ một cách linh hoạt trong khi vẫn áp dụng giới hạn tốc độ duy trì. Người dùng gửi 10 request nhanh sau khi idle vẫn được phục vụ; ai đó gọi API 1000 lần trong một vòng lặp sẽ bị throttle.
Tại Sao Tự Xây Dựng Thay Vì Dùng Thư Viện?
Bạn có thể dùng golang.org/x/time/rate — nó vững chắc và đã qua thực chiến. Nhưng hiểu được bên trong hoạt động như thế nào mới quan trọng. Khi có gì đó hỏng trong production lúc 3 giờ sáng, “tôi dùng thư viện” không giúp bạn debug được. Tự xây dựng chỉ mất 30 phút và bạn sẽ không bao giờ còn bối rối về hành vi của nó nữa. Tôi đã từng lần ra một lỗi rate limiting trong production là do vấn đề tinh tế với đồng hồ — hiểu được implementation nghĩa là sửa trong 10 phút thay vì mất cả đêm đọc source của thư viện.
Xây Dựng Rate Limiter trong Go
Cấu Trúc Dữ Liệu Cốt Lõi
Bắt đầu với bản thân chiếc xô:
package ratelimiter
import (
"sync"
"time"
)
type TokenBucket struct {
capacity float64
tokens float64
refillRate float64 // token mỗi giây
lastRefill time.Time
lastAccess time.Time
mu sync.Mutex
}
func NewTokenBucket(capacity float64, refillRate float64) *TokenBucket {
now := time.Now()
return &TokenBucket{
capacity: capacity,
tokens: capacity, // bắt đầu đầy
refillRate: refillRate,
lastRefill: now,
lastAccess: now,
}
}
Tôi dùng float64 cho token thay vì số nguyên. Điều này làm phép tính nạp lại gọn hơn — bạn không phải xử lý lỗi làm tròn khi token tích lũy theo phần nhỏ giữa các request.
Phương Thức Allow()
Hai thao tác xảy ra trong mỗi lần gọi: nạp lại token dựa trên thời gian đã trôi qua, sau đó cố gắng tiêu thụ một:
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(tb.lastRefill).Seconds()
// Thêm token dựa trên thời gian đã trôi qua
tb.tokens += elapsed * tb.refillRate
if tb.tokens > tb.capacity {
tb.tokens = tb.capacity
}
tb.lastRefill = now
tb.lastAccess = now
// Thử tiêu thụ một token
if tb.tokens >= 1 {
tb.tokens--
return true
}
return false
}
Mutex là điều bắt buộc. HTTP server của Go xử lý mỗi request trong goroutine riêng, và nếu không có đồng bộ hóa bạn sẽ gặp race condition làm hỏng số lượng token. Tôi đã gặp vấn đề này trong phiên bản đầu — stress testing cho thấy hành vi không ổn định dưới tải đồng thời mà gần như không thể tái tạo khi chạy đơn lẻ.
Rate Limiting Theo Từng Client
Một bucket toàn cục duy nhất sẽ throttle toàn bộ API của bạn cùng lúc, đó không phải là điều bạn muốn. Bạn cần một bucket riêng cho mỗi client — được đánh khóa bằng địa chỉ IP hoặc API key:
type RateLimiter struct {
clients map[string]*TokenBucket
mu sync.RWMutex
capacity float64
refillRate float64
}
func NewRateLimiter(capacity, refillRate float64) *RateLimiter {
rl := &RateLimiter{
clients: make(map[string]*TokenBucket),
capacity: capacity,
refillRate: refillRate,
}
go rl.cleanup()
return rl
}
func (rl *RateLimiter) getBucket(clientID string) *TokenBucket {
rl.mu.RLock()
bucket, exists := rl.clients[clientID]
rl.mu.RUnlock()
if exists {
return bucket
}
rl.mu.Lock()
defer rl.mu.Unlock()
// Kiểm tra lại sau khi lấy write lock
if bucket, exists = rl.clients[clientID]; exists {
return bucket
}
bucket = NewTokenBucket(rl.capacity, rl.refillRate)
rl.clients[clientID] = bucket
return bucket
}
func (rl *RateLimiter) Allow(clientID string) bool {
return rl.getBucket(clientID).Allow()
}
Chú ý pattern kiểm tra kép bên trong getBucket(). Giữa khoảng thời gian giải phóng read lock và lấy write lock, một goroutine khác có thể đã tạo cùng bucket đó. Bỏ qua bước này và bạn sẽ thấy các lần cấp phát trùng lặp ngắt quãng dưới tải cao — đây là cách tiếp cận chuẩn trong Go cho lazy initialization dưới concurrent access.
Dọn Dẹp Bucket Cũ Không Dùng Đến
Nếu không kiểm soát, map client sẽ tăng thêm một entry cho mỗi caller duy nhất — với API công khai đối mặt với hàng nghìn IP khác nhau, đó là memory tăng trưởng không giới hạn. Một goroutine nền xử lý việc thu hồi:
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
rl.mu.Lock()
for id, bucket := range rl.clients {
bucket.mu.Lock()
if time.Since(bucket.lastAccess) > 10*time.Minute {
delete(rl.clients, id)
}
bucket.mu.Unlock()
}
rl.mu.Unlock()
}
}
HTTP Middleware
Chèn đoạn này vào chuỗi middleware của bạn và xong:
func RateLimitMiddleware(rl *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Trong production sau proxy, dùng X-Forwarded-For
clientIP := r.RemoteAddr
if !rl.Allow(clientIP) {
w.Header().Set("Retry-After", "1")
w.Header().Set("X-RateLimit-Limit", "10")
http.Error(w, "Quá Nhiều Yêu Cầu", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
func main() {
// burst 10 token, duy trì 2 token/giây
limiter := NewRateLimiter(10, 2)
mux := http.NewServeMux()
mux.HandleFunc("/api/data", handleData)
handler := RateLimitMiddleware(limiter)(mux)
http.ListenAndServe(":8080", handler)
}
Kiểm Tra Hành Vi Burst và Throttle
Đừng bỏ qua phần test. Xác minh hành vi burst ngay từ đầu sẽ giúp bạn không phải điều chỉnh mù quáng trong production:
func TestTokenBucket_BurstThenThrottle(t *testing.T) {
// dung lượng 5 token, nạp lại 1 token/giây
bucket := NewTokenBucket(5, 1)
// Burst: 5 request liên tiếp đều phải được chấp nhận
for i := 0; i < 5; i++ {
if !bucket.Allow() {
t.Fatalf("kỳ vọng request %d được chấp nhận", i+1)
}
}
// Request thứ 6 bị từ chối — bucket rỗng
if bucket.Allow() {
t.Fatal("kỳ vọng request thứ 6 bị từ chối")
}
// Chờ 1 token được nạp lại
time.Sleep(1100 * time.Millisecond)
if !bucket.Allow() {
t.Fatal("kỳ vọng request được chấp nhận sau khi nạp lại")
}
}
Tinh Chỉnh và Mẹo cho Production
Chọn Tham Số Hợp Lý
Chọn sai con số là lỗi phổ biến nhất. Điểm khởi đầu của tôi cho API công khai:
- Đặt capacity bằng 2–3× burst hợp lệ dự kiến (người dùng đôi khi retry nhanh sau lỗi)
- Đặt refill rate bằng ngân sách requests-per-second duy trì của bạn
- Endpoint đã xác thực: hào phóng hơn — capacity 50 token, nạp lại 10/giây
- Endpoint chưa xác thực hoặc nhạy cảm: nghiêm ngặt — capacity 5 token, nạp lại 1/giây
Rate Limiting Phân Tán
Implementation in-memory hoạt động hoàn hảo cho một server đơn lẻ. Scale horizontally và vấn đề lộ ra ngay: mỗi server có trạng thái bucket riêng, vì vậy một client có thể gọi 10 req/giây trên mỗi trong 5 server để đạt tổng cộng 50 req/giây. Với setup phân tán, trạng thái token chuyển sang Redis sử dụng Lua script atomic. Thuật toán chuyển trực tiếp — cùng công thức toán học, backend lưu trữ khác nhau. Đó là chủ đề đáng tìm hiểu sâu riêng khi bạn đã vượt quá giới hạn của một node đơn.
Headers Cho Client Biết Chuyện Gì Đang Xảy Ra
Một response 429 không có context rất khó debug. Luôn trả về:
X-RateLimit-Limit— dung lượng bucket của bạnX-RateLimit-Remaining— số token hiện tại (đọc trước khi tiêu thụ)Retry-After— số giây cho đến khi token tiếp theo khả dụng
Các client ngoan ngoãn sẽ tự động giảm tốc khi thấy những header này. Các client hư hỗn ít nhất cũng cho bạn thứ gì đó để chỉ ra trong logs.
Những Bước Tiếp Theo
Implementation ở trên khoảng 100 dòng Go, xử lý truy cập đồng thời chính xác, tự động dọn dẹp trạng thái cũ, và cắm vào HTTP middleware chuẩn. Đủ nhỏ để đọc trong một lần ngồi — điều quan trọng khi bạn cần điều chỉnh nó, debug lúc 3 giờ sáng, hoặc giải thích từng dòng cho kỹ sư tiếp theo.
Rate limiting phải có mặt trong commit đầu tiên của bạn, không phải trong báo cáo sự cố sau đó. Bắt đầu với các giá trị mặc định ở đây, chạy load test với các endpoint thực tế của bạn, và điều chỉnh dựa trên số liệu traffic thực thay vì phỏng đoán.

