Bỏ qua đến nội dung chính
rate limitingAPI securityRedisbackendDDoS protection

Rate Limiting: Token Bucket vs Sliding Window — Code Mẫu

Rate limiting với token bucket vs sliding window: thuật toán, code Redis Lua, distributed limit, header chuẩn 429. Implement đúng cho API production.

Xuất bản 9 phút đọc

Rate limiting là tuyến phòng thủ đầu tiên của mọi API production. Bài này so sánh 4 thuật toán phổ biến (fixed window, sliding window, token bucket, leaky bucket), kèm code Redis Lua chạy distributed được.

Vì sao cần rate limit?

Không có rate limit, một bot script có thể:

  • Brute-force password — 1000 request/giây vào /login
  • Scrape data — đọc hết 1M user trong 1 giờ
  • Cost attack — gọi endpoint /generate-pdf để đốt CPU/memory
  • Abuse free tier — tạo 1000 account thử nghiệm

Rate limit không chống được attacker có nguồn lực vô hạn (DDoS distributed) — đó là việc của Cloudflare/CDN. Nhưng nó chặn 99% abuse từ single source.

1. Fixed Window — đơn giản nhưng có flaw

Counter reset mỗi N giây. Đơn giản nhất:

// 100 request / 60 giây
async function fixedWindow(redis, key) {
  const window = Math.floor(Date.now() / 60000)
  const k = `rl:${key}:${window}`
  const count = await redis.incr(k)
  if (count === 1) await redis.expire(k, 60)
  return count <= 100
}

Flaw: cho phép burst gấp đôi ở rìa window. User có thể gửi 100 request lúc 10:59:59 và 100 request lúc 11:00:00 — tổng 200 request trong 1 giây.

2. Sliding Window — chính xác hơn

Đếm request trong khoảng thời gian "lùi N giây từ bây giờ" thay vì window cố định. Implement bằng sorted set Redis:

-- Redis Lua: atomic check + add
-- KEYS[1] = bucket key, ARGV: now (ms), window (ms), limit, request_id
local now    = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit  = tonumber(ARGV[3])
local id     = ARGV[4]

-- Xoá entries cũ
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)

local count = redis.call('ZCARD', KEYS[1])
if count >= limit then
  return 0   -- rejected
end

redis.call('ZADD', KEYS[1], now, id)
redis.call('PEXPIRE', KEYS[1], window)
return 1     -- allowed
import { createClient } from 'redis'
const redis = createClient(); await redis.connect()

const SLIDING_WINDOW_LUA = `...` // lua trên

async function slidingWindow(key, limit, windowMs) {
  const now = Date.now()
  const id = `${now}-${Math.random()}`
  const ok = await redis.eval(SLIDING_WINDOW_LUA, {
    keys: [`rl:${key}`],
    arguments: [now.toString(), windowMs.toString(), limit.toString(), id],
  })
  return ok === 1
}

Chính xác nhưng tốn memory (lưu mỗi request). Phù hợp khi limit nhỏ (≤ 100/min).

3. Token Bucket — cho phép burst hợp lý

Bucket có capacity N, refill R token/giây. Mỗi request tốn 1 token. Hết token → reject.

-- KEYS[1] = bucket key
-- ARGV: capacity, refill_per_sec, now (sec), cost
local capacity = tonumber(ARGV[1])
local refill   = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local cost     = tonumber(ARGV[4])

local data = redis.call('HMGET', KEYS[1], 'tokens', 'last')
local tokens = tonumber(data[1]) or capacity
local last   = tonumber(data[2]) or now

-- Refill dựa trên thời gian trôi qua
local elapsed = math.max(0, now - last)
tokens = math.min(capacity, tokens + elapsed * refill)

local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refill) * 2)

return { allowed, tokens }

Token bucket cho phép user "tiết kiệm" và burst sau — UX tốt hơn cho API public. Cloudflare, AWS, Stripe đều dùng pattern này.

4. Leaky Bucket — smoothing traffic

Tương tự token bucket nhưng request vào queue, drain ra với rate cố định. Phù hợp khi cần smoothing (e.g. SMS gateway giới hạn 10 msg/sec gửi về provider).

Response chuẩn khi bị rate limit

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1735000800

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Quá giới hạn request. Thử lại sau 30 giây.",
    "retry_after": 30
  }
}

Header Retry-After là chuẩn HTTP — client library tự retry. X-RateLimit-* không chuẩn nhưng phổ biến (GitHub, Stripe, Twitter API).

Middleware Express dùng được ngay

function rateLimit({ windowMs, max, keyFn }) {
  return async (req, res, next) => {
    const key = keyFn(req)
    const ok = await slidingWindow(key, max, windowMs)
    res.setHeader('X-RateLimit-Limit', max)
    if (!ok) {
      res.setHeader('Retry-After', Math.ceil(windowMs / 1000))
      return res.status(429).json({
        error: { code: 'RATE_LIMITED', message: 'Quá giới hạn request' }
      })
    }
    next()
  }
}

// Áp dụng tier khác nhau theo endpoint
app.use('/auth/login', rateLimit({
  windowMs: 60_000, max: 5,
  keyFn: (req) => `login:${req.ip}`
}))

app.use('/api/', rateLimit({
  windowMs: 60_000, max: 100,
  keyFn: (req) => req.user ? `user:${req.user.id}` : `ip:${req.ip}`
}))

Layer nhiều rate limit

Một limit không đủ. Production nên có:

LayerGiới hạnMục đích
CDN (Cloudflare)10k req/min/IPChặn DDoS volumetric
API Gateway1k req/min/API keyChống abuse partner
Application — chung100 req/min/userFair usage
Application — sensitive5 req/min/IP cho /loginBrute-force
Application — expensive10 req/hour/user cho /generate-reportCost protection

Distributed rate limit

Multi-instance Node.js: in-memory counter không chia sẻ. Bắt buộc dùng Redis. Lua script đảm bảo atomic — không bao giờ có race condition cho phép vượt limit.

Với load cực cao (>100k QPS), Redis có thể là bottleneck. Giải: shard theo key, hoặc dùng approximate counter (count-min sketch) — tối ưu memory, sai số chấp nhận được. Tham khảo Backend caching Redis để hiểu sâu pattern Redis production.

Kết luận

Rate limiting đúng chuẩn không phải optional — nó là baseline security. Token bucket + Redis Lua + layered defense là setup chuẩn cho 95% API production. Bắt đầu bằng limit lỏng, monitor 1-2 tuần, siết dần dựa trên dữ liệu thực — đừng tunable từ ngày đầu.

Zalo