Skip to content

Zoom-in: Rate Limiter

Karify98·
Cover Image for Zoom-in: Rate Limiter

Gửi quá nhiều request liên tiếp lên một API, và bạn nhận về mã lỗi quả quyết: “429 Too Many Requests”.

graph LR
    C(["💻 Client"]) -->|"Request liên tục"| RL["🛡️ Rate Limiter"]
    RL -->|"Vượt ngưỡng"| Reject["❌ 429 Too Many Requests"]
    RL -->|"Hợp lệ"| API["🖥️ API Service"]
    style C fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style RL fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
    style Reject fill:#1e293b,stroke:#e11d48,color:#fda4af
    style API fill:#1a3a2a,stroke:#22c55e,color:#86efac

Phóng to dần vào đó.


Layer 1 — Token Bucket: Thuật toán cấp phát cơ bản

Để chặn bớt request thừa, hệ thống cần một cách đếm tần suất đơn giản và tốn ít bộ nhớ nhất. Thuật toán phổ biến nhất là Token Bucket.

graph TD
    Refill["💧 Nạp mới: +2 token/giây"] --> Bucket{"🪣 Token Bucket\n(Max: 5 tokens)"}
    Req["📥 Request mới"] --> Check{"Kiểm tra Bucket"}
    Check -->|"Còn token (Tiêu tốn 1)"| Allow["✅ Cho qua"]
    Check -->|"Hết token"| Reject["❌ Trả về lỗi 429"]
    Bucket -.-> Check

    style Refill fill:#1e293b,stroke:#475569,color:#cbd5e1
    style Bucket fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
    style Req fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd

Mỗi client (phân biệt qua IP hoặc API Key) được gán một "chiếc xô" ảo chứa các token. Xô có dung lượng tối đa (ví dụ: 5 tokens) và được tự động nạp đầy lại theo một tỷ lệ cố định (ví dụ: 2 tokens mỗi giây).

Mỗi request gửi đến sẽ tiêu tốn 1 token trong xô. Nếu xô hết sạch token, request lập tức bị từ chối với mã lỗi 429. Thuật toán này rất nhẹ và cho phép hệ thống chịu được các đợt bùng nổ traffic ngắn hạn (burst traffic) khi xô đang đầy.

Vấn đề còn lại: các thuật toán cửa sổ cố định (Fixed Window) đơn giản có thể bị kẻ tấn công khai thác bằng cách gửi lượng request gấp đôi ở biên giới chuyển giao giữa hai chu kỳ thời gian.

Layer 2 — Sliding Window Counter: Độ chính xác theo thời gian trượt

Để giải quyết nhược điểm của việc đếm theo chu kỳ cố định (ví dụ: tối đa 10 request từ phút 1:00 đến 2:00, rồi reset lại ở phút 2:00), Rate Limiter áp dụng thuật toán cửa sổ trượt (Sliding Window Counter).

graph LR
    subgraph Chu kỳ trước: 10 requests
        W1["Cửa sổ cũ (phút trước)"]
    end
    subgraph Chu kỳ hiện tại: 2 requests
        W2["Cửa sổ mới (phút này)"]
    end
    W1 -.->|"Trượt 30% thời gian"| SW["Cửa sổ trượt thực tế"]
    W2 -.-> SW
    style W1 fill:#1e293b,stroke:#334155,color:#94a3b8
    style W2 fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style SW fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d

Thay vì reset bộ đếm về 0 khi sang phút mới, thuật toán ước tính số lượng request dựa trên tỷ lệ trượt thời gian.

Ví dụ: Nếu ta đang ở giây thứ 18 của phút hiện tại (trượt 30% thời gian), Rate Limiter sẽ tính số request bằng công thức: Số request cửa sổ cũ * 70% + Số request cửa sổ mới. Cách này đảm bảo attacker không thể gửi dồn dập request vào thời điểm chuyển giao chu kỳ để vượt ngưỡng.

Vấn đề còn lại: khi hệ thống scale ra chạy trên nhiều server song song, làm thế nào để các server đồng bộ bộ đếm của cùng một client một cách nhất quán mà không gặp lỗi race condition?

Layer 3 — Distributed Limiting: Đồng bộ nguyên tử với Redis và Lua Script

Khi ứng dụng chạy trên cluster gồm nhiều server, việc lưu trữ bộ đếm trên bộ nhớ RAM của từng server đơn lẻ sẽ làm mất tác dụng của Rate Limiter. Ta cần một bộ lưu trữ tập trung như Redis.

sequenceDiagram
    participant S1 as App Server 1
    participant R as Redis (Tập trung)
    participant S2 as App Server 2

    Note over S1,S2: Cùng lúc nhận được request của IP 1.2.3.4
    S1->>R: Thực thi Lua Script (Kiểm tra & trừ token)
    Note over R: Xử lý đơn luồng (Atomic)<br/>Trừ 1 token thành công
    R-->>S1: Đồng ý (Cho qua)

    S2->>R: Thực thi Lua Script (Kiểm tra & trừ token)
    Note over R: Hết token!
    R-->>S2: Từ chối (Trả về 429)

Nếu hai server cùng đọc giá trị đếm từ Redis lên, cộng thêm 1 rồi ghi ngược lại, một lỗi race condition sẽ xảy ra khiến bộ đếm bị ghi đè sai lệch.

Để giải quyết, các kỹ sư gửi toàn bộ logic kiểm tra và cập nhật xuống Redis dưới dạng một đoạn Lua Script. Redis chạy đơn luồng (single-threaded) sẽ thực thi đoạn mã Lua này một cách nguyên tử (atomic) — đảm bảo không có hai request nào có thể tranh chấp ghi đè dữ liệu của nhau.


Full picture

sequenceDiagram
    participant C as Client
    participant LB as Load Balancer
    participant App as App Server
    participant Redis as Redis Cache

    C->>LB: GET /api/resource (IP: 1.2.3.4)
    LB->>App: Chuyển tiếp Request
    App->>Redis: EVALSHA rate_limit.lua (Key: limit:1.2.3.4)
    Note over Redis: Thực thi Lua Script nguyên tử:<br/>1. Đọc số token hiện tại<br/>2. Nếu còn, trừ 1 và cập nhật TTL
    alt Còn token trong xô
        Redis-->>App: Trả về: [1, số token còn lại]
        App->>App: Xử lý nghiệp vụ chính
        App-->>C: 200 OK
    else Hết token
        Redis-->>App: Trả về: [0, 0]
        App-->>C: 429 Too Many Requests (Retry-After: 30s)
    end

Takeaway

Rate Limiter là lá chắn bảo vệ hệ thống trước các tác nhân gây quá tải ở tầng ứng dụng. Thiết kế một hệ thống rate limit phân tán đòi hỏi sự cân bằng giữa độ chính xác và hiệu năng: việc thực thi Lua Script trên Redis tập trung mang lại sự chính xác tuyệt đối nhưng cũng biến Redis thành một điểm nghẽn cổ chai (bottleneck) mới nếu lượng request gửi đến quá lớn.


Bài viết được hỗ trợ bởi Amy 🌸 - AI Assistant. Nội dung đã được kiểm duyệt bởi tác giả.

Related Posts