設計 Rate Limiter — Token Bucket、Sliding Window 與分散式限流實戰 | 資料結構與演算法
Rate Limiter(限流器) 是系統設計中保護後端服務免受流量衝擊的核心元件。本文將深入比較 Token Bucket、Leaky Bucket、Fixed Window Counter 與 Sliding Window Counter 四種主流限流演算法,從需求分析、方案設計到完整的 JavaScript/TypeScript 與 C++ 雙語言實作,並涵蓋 Redis Lua Script 分散式限流方案與生產環境的多層限流策略,帶你徹底掌握 Rate Limiter 的設計與實戰。
前言
在前面的 39 篇文章中,我們系統性地學習了各種資料結構與演算法的核心概念。從今天開始,我們正式進入 實戰應用系列——運用前面所學的知識,解決真實世界中的系統設計問題。
第一個實戰主題就是 Rate Limiter(限流器)。
想像一個場景:你的 API 服務突然接到每秒十萬次請求——可能是用戶的爬蟲程式失控,可能是競爭對手發動 DDoS 攻擊,也可能只是某次促銷活動的流量遠超預期。如果沒有限流機制,後端資料庫會在幾秒內被壓垮,所有用戶都將無法使用服務。
Rate Limiter 就是那道「閘門」,它在請求到達後端服務之前進行攔截判斷:此時此刻,這個請求是否允許通過? 這個看似簡單的問題,背後涉及演算法選擇、記憶體管理、分散式一致性等多重考量。
本文你將學到:
- 為什麼需要 Rate Limiter,以及它的核心需求
- 四種主流限流演算法的原理、優缺點與適用場景
- 完整可運行的 JavaScript/TypeScript 與 C++ 實作
- 分散式環境下使用 Redis + Lua Script 的原子限流方案
- 生產環境中的多層限流、故障處理與監控策略
1. 需求分析
功能性需求(Functional Requirements)
- 每個使用者、IP 或 API Key 在指定時間窗口內有請求上限
- 超過限制的請求應回傳
429 Too Many Requests狀態碼 - 限流規則可針對不同端點、不同用戶等級設定(Tier-based)
- 支援多種限流維度:Global、Per-User、Per-IP、Per-Endpoint
- 回應標頭(Response Headers)應包含限流資訊
非功能性需求(Non-Functional Requirements)
- 低延遲:限流判斷本身不超過 1ms(P99),避免成為效能瓶頸
- 高可用:Rate Limiter 故障不應導致服務完全中斷
- 可擴展:支援水平擴展,多台伺服器共享同一份限流狀態
- 精確度:在分散式環境下允許少量誤差(Eventual Consistency)
- 記憶體效率:即使有千萬級用戶,記憶體消耗也要可控
核心架構
Rate Limiter 在系統中的位置非常明確——它是一個 中介層(Middleware),攔截在路由處理之前:
Client → Rate Limiter → Backend Service
↓ if over limit
429 Response
標準的回應標頭設計如下:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100 // 窗口內最大請求數
X-RateLimit-Remaining: 63 // 剩餘可用請求數
X-RateLimit-Reset: 1716854400 // 窗口重置的 Unix 時間戳
Retry-After: 37 // 僅在 429 時出現,建議等待秒數
規模估算
假設我們為一個中型 API 平台設計 Rate Limiter:
| 指標 | 數值 | 說明 |
|---|---|---|
| DAU | 1,000 萬 | 日活躍使用者 |
| 峰值 QPS | 100,000 | 每秒請求數 |
| 每用戶限制 | 100 req/min | 預設限流規則 |
| 限流規則數 | 1,000 條 | 不同端點/等級 |
| 判斷延遲目標 | < 1ms | P99 |
記憶體估算是選擇演算法的重要依據:
- Token Bucket / Counter 方案:每用戶
tokens(8B) + last_refill(8B) = 16B。1,000 萬用戶 × 16B = 160 MB——非常輕量。 - Sliding Window Log 方案:每次請求記錄
timestamp(8B) + user_id(8B) = 16B。每用戶 100 筆 = 1.6 KB,1,000 萬用戶 × 1.6 KB = 16 GB——記憶體壓力極大。
這個估算結果直接影響我們的演算法選擇。
2. 方案設計——四種限流演算法
2.1 Fixed Window Counter(固定窗口計數器)
將時間軸分成固定大小的窗口(例如每分鐘),對每個窗口獨立計數。這是最簡單的限流方案。
時間軸: |---0:00---||---1:00---||---2:00---|
[99 reqs] [99 reqs]
致命缺陷——邊界問題(Boundary Problem):
0:59 發送 100 個請求 ✓(屬於第一個窗口)
1:01 發送 100 個請求 ✓(屬於第二個窗口)
→ 兩秒內通過 200 個請求,是限流規則的 2 倍!
複雜度:時間 O(1),空間 O(1)。
2.2 Sliding Window Log(滑動窗口日誌)
儲存每個請求的精確時間戳,判斷時計算「窗口內」的有效請求數。最精確但記憶體消耗最大。
現在時間: 1:30
窗口大小: 1 分鐘
有效請求: 0:30 ~ 1:30 之間的所有請求
日誌: [0:29✗, 0:31✓, 0:45✓, 1:10✓, 1:25✓, 1:30✓]
有效計數: 5
複雜度:時間 O(n),空間 O(n)(n = 窗口內最大請求數)。
2.3 Sliding Window Counter(滑動窗口計數器)
結合 Fixed Window 的低記憶體與 Sliding Window 的平滑性。用前後兩個固定窗口的計數加權計算估算值:
前窗口計數: 80(前 1 分鐘)
當前窗口計數: 30(本分鐘已過 60%)
估算當前 1 分鐘請求數:
= 前窗口計數 × (1 - 當前窗口已過比例) + 當前窗口計數
= 80 × (1 - 0.6) + 30
= 80 × 0.4 + 30
= 32 + 30 = 62
複雜度:時間 O(1),空間 O(1)(僅儲存兩個計數器)。
2.4 Token Bucket(令牌桶)
桶中最多存放 capacity 個令牌,以固定速率 refillRate 補充。每個請求消耗一個令牌,桶空則拒絕。
refill_rate: 10 tokens/sec
↓
┌─────────────────┐
│ Token Bucket │
│ ●●●●●●●○○○ │ capacity: 10, current: 7
└────────┬────────┘
↓ 每個請求消耗 1 token
Request
核心特性:允許突發流量(Burst)。如果用戶一段時間沒有發送請求,令牌會累積到桶的最大容量,可以一次性用完。
2.5 Leaky Bucket(漏桶)
請求進入佇列(Bucket),以固定速率流出到後端服務。佇列滿則丟棄新請求。
Requests →→→ [ Queue ] →→→ Backend
(max_size) (fixed rate)
核心特性:輸出速率恆定,適合需要平滑流量的場景(如支付處理)。但對突發流量完全無法容納,且佇列增加延遲。
演算法比較表
| 演算法 | 精確度 | 記憶體 | 突發處理 | 實作難度 | 適用場景 |
|---|---|---|---|---|---|
| Fixed Window Counter | 中(邊界問題) | 極低 O(1) | 邊界處 2 倍突發 | 最簡單 | 粗粒度限流、內部服務 |
| Sliding Window Log | 精確 | 高 O(n) | 精確控制 | 中等 | 低流量、高精確需求 |
| Sliding Window Counter | 近似精確 | 極低 O(1) | 近似平滑 | 中等 | 通用首選 |
| Token Bucket | 中 | 極低 O(1) | 允許突發 | 中等 | API Gateway、允許爆發 |
| Leaky Bucket | 輸出恆定 | 中 O(queue) | 不允許 | 中等 | 支付、需平滑輸出 |
業界實際選用:
- 通用 API 限流 → Sliding Window Counter(Redis 實作)
- 允許突發的 API → Token Bucket(AWS API Gateway 採用)
- 精確計費 → Sliding Window Log
- 訊息佇列流控 → Leaky Bucket
3. 核心實作
3.1 Token Bucket — JavaScript/TypeScript
/**
* Token Bucket Rate Limiter
* - 允許突發流量(burst)
* - O(1) 時間與空間複雜度
* - 使用「懶惰補充」策略:只在消耗時才計算應補充的令牌數
*/
class TokenBucket {
private capacity: number; // 桶的最大容量
private tokens: number; // 當前令牌數
private refillRate: number; // 每秒補充速率
private lastRefillTime: number; // 上次補充時間(ms)
constructor(capacity: number, refillRate: number) {
this.capacity = capacity;
this.refillRate = refillRate;
this.tokens = capacity; // 初始滿桶
this.lastRefillTime = Date.now();
}
/** 懶惰補充:只在需要時計算經過時間並補充令牌 */
private refill(): void {
const now = Date.now();
const elapsedSeconds = (now - this.lastRefillTime) / 1000;
const tokensToAdd = elapsedSeconds * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
this.lastRefillTime = now;
}
/** 嘗試消耗 count 個令牌,回傳是否允許 */
consume(count: number = 1): boolean {
this.refill();
if (this.tokens >= count) {
this.tokens -= count;
return true; // 允許
}
return false; // 拒絕
}
getStatus(): { tokens: number; capacity: number } {
this.refill();
return { tokens: Math.floor(this.tokens), capacity: this.capacity };
}
}
/** 管理多用戶的 Token Bucket Rate Limiter */
class TokenBucketRateLimiter {
private buckets: Map<string, TokenBucket> = new Map();
private capacity: number;
private refillRate: number;
constructor(capacity: number, refillRate: number) {
this.capacity = capacity;
this.refillRate = refillRate;
}
isAllowed(userId: string): boolean {
if (!this.buckets.has(userId)) {
this.buckets.set(
userId,
new TokenBucket(this.capacity, this.refillRate)
);
}
return this.buckets.get(userId)!.consume();
}
getStatus(userId: string): { tokens: number; capacity: number } | null {
return this.buckets.get(userId)?.getStatus() ?? null;
}
/** 定期清理長時間未活躍的桶,防止記憶體洩漏 */
cleanup(ttlMs: number = 60_000): void {
console.log(`Cleanup: ${this.buckets.size} buckets in memory`);
}
}
3.2 Sliding Window Counter — JavaScript/TypeScript
/**
* Sliding Window Counter Rate Limiter
* - 精確度接近 Sliding Window Log
* - 記憶體效率如同 Fixed Window Counter
* - O(1) 時間與空間複雜度
*/
class SlidingWindowCounter {
private windowSize: number; // 窗口大小(秒)
private limit: number; // 窗口內最大請求數
private counters: Map<string, number> = new Map();
constructor(windowSizeSeconds: number, limit: number) {
this.windowSize = windowSizeSeconds;
this.limit = limit;
}
private getCurrentWindowIndex(): number {
return Math.floor(Date.now() / 1000 / this.windowSize);
}
/** 加權估算當前滑動窗口內的請求數 */
private getEstimatedCount(userId: string): number {
const now = Date.now() / 1000;
const currentWindowIndex = Math.floor(now / this.windowSize);
const previousWindowIndex = currentWindowIndex - 1;
// 當前窗口已過的比例
const currentWindowProgress =
(now % this.windowSize) / this.windowSize;
const currentCount =
this.counters.get(`${userId}:${currentWindowIndex}`) ?? 0;
const previousCount =
this.counters.get(`${userId}:${previousWindowIndex}`) ?? 0;
// 加權:前窗口貢獻 (1 - progress),當前窗口全部計入
return previousCount * (1 - currentWindowProgress) + currentCount;
}
isAllowed(userId: string): boolean {
const estimatedCount = this.getEstimatedCount(userId);
if (estimatedCount >= this.limit) {
return false; // 限流
}
// 增加當前窗口計數
const currentWindowIndex = this.getCurrentWindowIndex();
const key = `${userId}:${currentWindowIndex}`;
this.counters.set(key, (this.counters.get(key) ?? 0) + 1);
return true; // 允許
}
getRemainingRequests(userId: string): number {
const estimated = this.getEstimatedCount(userId);
return Math.max(0, this.limit - Math.ceil(estimated));
}
getResetTime(): number {
const currentWindowIndex = this.getCurrentWindowIndex();
return (currentWindowIndex + 1) * this.windowSize;
}
/** 清理舊窗口資料,只保留最近兩個窗口 */
cleanup(): void {
const currentWindowIndex = this.getCurrentWindowIndex();
const validIndexes = new Set([
`${currentWindowIndex}`,
`${currentWindowIndex - 1}`,
]);
for (const key of this.counters.keys()) {
const windowIndex = key.split(":").pop()!;
if (!validIndexes.has(windowIndex)) {
this.counters.delete(key);
}
}
}
}
3.3 Fixed Window Counter — JavaScript/TypeScript
/**
* Fixed Window Counter Rate Limiter
* - 最簡單的限流演算法
* - 有邊界問題但適合粗粒度限流
*/
class FixedWindowCounter {
private windowSize: number;
private limit: number;
private counters: Map<string, { count: number; windowIndex: number }> =
new Map();
constructor(windowSizeSeconds: number, limit: number) {
this.windowSize = windowSizeSeconds;
this.limit = limit;
}
private getCurrentWindowIndex(): number {
return Math.floor(Date.now() / 1000 / this.windowSize);
}
isAllowed(userId: string): boolean {
const currentWindowIndex = this.getCurrentWindowIndex();
const record = this.counters.get(userId);
// 窗口已更換,重置計數
if (!record || record.windowIndex !== currentWindowIndex) {
this.counters.set(userId, { count: 1, windowIndex: currentWindowIndex });
return true;
}
if (record.count >= this.limit) {
return false; // 限流
}
record.count++;
return true;
}
}
3.4 Leaky Bucket — JavaScript/TypeScript
/**
* Leaky Bucket Rate Limiter
* - 輸出速率恆定,適合需要平滑流量的場景
* - 使用佇列模擬,佇列滿時拒絕新請求
*/
class LeakyBucket {
private capacity: number; // 佇列最大容量
private leakRate: number; // 每秒流出的請求數
private queue: number[] = []; // 請求進入的時間戳
private lastLeakTime: number;
constructor(capacity: number, leakRate: number) {
this.capacity = capacity;
this.leakRate = leakRate;
this.lastLeakTime = Date.now();
}
/** 根據經過時間移除已流出的請求 */
private leak(): void {
const now = Date.now();
const elapsedSeconds = (now - this.lastLeakTime) / 1000;
const leaked = Math.floor(elapsedSeconds * this.leakRate);
if (leaked > 0) {
this.queue.splice(0, leaked); // 從前端移除已流出的請求
this.lastLeakTime = now;
}
}
isAllowed(): boolean {
this.leak();
if (this.queue.length >= this.capacity) {
return false; // 佇列已滿,拒絕
}
this.queue.push(Date.now());
return true;
}
getQueueSize(): number {
this.leak();
return this.queue.length;
}
}
3.5 HTTP Middleware 整合
將限流器包裝成中介層,統一處理回應標頭和 429 回應:
/** HTTP Rate Limit 回應結構 */
interface RateLimitResult {
allowed: boolean;
limit: number;
remaining: number;
resetTime: number;
retryAfter?: number;
}
/** 通用中介層(適用於 Express / Fastify / NestJS) */
class RateLimiterMiddleware {
private limiter: SlidingWindowCounter;
constructor(limit: number = 100, windowSizeSeconds: number = 60) {
this.limiter = new SlidingWindowCounter(windowSizeSeconds, limit);
// 每分鐘清理一次過期資料
setInterval(() => this.limiter.cleanup(), 60_000);
}
check(userId: string): RateLimitResult {
const allowed = this.limiter.isAllowed(userId);
const remaining = this.limiter.getRemainingRequests(userId);
const resetTime = this.limiter.getResetTime();
return {
allowed,
limit: 100,
remaining: allowed ? remaining : 0,
resetTime,
retryAfter: allowed
? undefined
: resetTime - Math.floor(Date.now() / 1000),
};
}
}
3.6 完整測試
// === 測試所有限流演算法 ===
function runTests(): void {
console.log("=== Token Bucket Test ===");
const tbLimiter = new TokenBucketRateLimiter(10, 2); // 容量 10,每秒補充 2
let allowed = 0;
let denied = 0;
for (let i = 0; i < 12; i++) {
if (tbLimiter.isAllowed("user-1")) {
allowed++;
} else {
denied++;
}
}
console.log(`Allowed: ${allowed}, Denied: ${denied}`);
// 輸出:Allowed: 10, Denied: 2
console.log("\n=== Sliding Window Counter Test ===");
const swLimiter = new SlidingWindowCounter(60, 5); // 每分鐘 5 個
const results: boolean[] = [];
for (let i = 0; i < 7; i++) {
results.push(swLimiter.isAllowed("user-2"));
}
console.log(`Results: ${results.map(r => r ? "✓" : "✗").join(" ")}`);
// 輸出:Results: ✓ ✓ ✓ ✓ ✓ ✗ ✗
console.log(`Remaining: ${swLimiter.getRemainingRequests("user-2")}`);
// 輸出:Remaining: 0
console.log("\n=== Fixed Window Counter Test ===");
const fwLimiter = new FixedWindowCounter(60, 3); // 每分鐘 3 個
for (let i = 0; i < 5; i++) {
const result = fwLimiter.isAllowed("user-3");
console.log(`Request ${i + 1}: ${result ? "ALLOWED" : "DENIED"}`);
}
// 輸出:前 3 個 ALLOWED,後 2 個 DENIED
console.log("\n=== Leaky Bucket Test ===");
const lbLimiter = new LeakyBucket(5, 1); // 佇列 5,每秒流出 1 個
for (let i = 0; i < 7; i++) {
const result = lbLimiter.isAllowed();
console.log(`Request ${i + 1}: ${result ? "ALLOWED" : "DENIED"}`);
}
// 輸出:前 5 個 ALLOWED,後 2 個 DENIED
}
runTests();
3.7 Token Bucket — C++ 高效能實作
/**
* High-Performance Token Bucket Rate Limiter (C++)
*
* 設計目標:
* - 使用 CAS(Compare-And-Swap)實現 lock-free 令牌消耗
* - 多執行緒安全
* - 最小化鎖競爭
*/
#include <atomic>
#include <chrono>
#include <unordered_map>
#include <mutex>
#include <memory>
#include <iostream>
#include <thread>
#include <vector>
using namespace std;
using namespace std::chrono;
/**
* 單一用戶的 Token Bucket(執行緒安全)
* 使用 scaled integer 模擬浮點數原子操作
*/
class AtomicTokenBucket {
private:
const double capacity_;
const double refill_rate_; // 每秒補充令牌數
atomic<int64_t> tokens_scaled_; // 實際值 = tokens_scaled_ / SCALE
atomic<int64_t> last_refill_ns_; // 上次補充時間(奈秒)
static constexpr int64_t SCALE = 1000; // 精度 0.001 token
int64_t now_ns() const {
return duration_cast<nanoseconds>(
steady_clock::now().time_since_epoch()
).count();
}
void refill() {
int64_t now = now_ns();
int64_t last = last_refill_ns_.load(memory_order_relaxed);
int64_t elapsed_ns = now - last;
if (elapsed_ns <= 0) return;
double elapsed_sec = static_cast<double>(elapsed_ns) / 1e9;
int64_t tokens_to_add = static_cast<int64_t>(
elapsed_sec * refill_rate_ * SCALE
);
if (tokens_to_add <= 0) return;
// CAS 更新上次補充時間
if (!last_refill_ns_.compare_exchange_strong(
last, now, memory_order_acq_rel)) {
return; // 其他執行緒已更新,跳過
}
// 補充令牌(不超過容量)
int64_t cap_scaled = static_cast<int64_t>(capacity_ * SCALE);
int64_t current = tokens_scaled_.load(memory_order_relaxed);
int64_t new_tokens = min(cap_scaled, current + tokens_to_add);
tokens_scaled_.store(new_tokens, memory_order_relaxed);
}
public:
AtomicTokenBucket(double capacity, double refill_rate)
: capacity_(capacity)
, refill_rate_(refill_rate)
, tokens_scaled_(static_cast<int64_t>(capacity * SCALE))
, last_refill_ns_(now_ns()) {}
/** CAS loop 實現 lock-free 令牌消耗 */
bool consume(double count = 1.0) {
refill();
int64_t cost_scaled = static_cast<int64_t>(count * SCALE);
int64_t current = tokens_scaled_.load(memory_order_acquire);
while (true) {
if (current < cost_scaled) {
return false; // 令牌不足,拒絕
}
int64_t desired = current - cost_scaled;
if (tokens_scaled_.compare_exchange_weak(
current, desired,
memory_order_release,
memory_order_acquire)) {
return true; // 成功消耗
}
// CAS 失敗:current 已被更新為最新值,自動重試
}
}
double get_tokens() const {
return static_cast<double>(
tokens_scaled_.load(memory_order_relaxed)
) / SCALE;
}
};
/** 多用戶 Rate Limiter 管理器 */
class RateLimiterManager {
private:
unordered_map<string, shared_ptr<AtomicTokenBucket>> buckets_;
mutable mutex map_mutex_;
double capacity_;
double refill_rate_;
public:
RateLimiterManager(double capacity, double refill_rate)
: capacity_(capacity), refill_rate_(refill_rate) {}
bool is_allowed(const string& user_id) {
shared_ptr<AtomicTokenBucket> bucket;
{
lock_guard<mutex> lock(map_mutex_);
auto it = buckets_.find(user_id);
if (it == buckets_.end()) {
auto new_bucket = make_shared<AtomicTokenBucket>(
capacity_, refill_rate_
);
buckets_[user_id] = new_bucket;
bucket = new_bucket;
} else {
bucket = it->second;
}
}
// bucket 操作不持有 map_mutex_,最大化並行度
return bucket->consume();
}
size_t bucket_count() const {
lock_guard<mutex> lock(map_mutex_);
return buckets_.size();
}
};
3.8 Sliding Window Counter — C++ 實作
/**
* Sliding Window Counter (C++ 版)
* - 執行緒安全
* - 結合 Fixed Window 的低記憶體與 Sliding Window 的平滑性
*/
class SlidingWindowCounter {
private:
const int window_size_sec_;
const int limit_;
struct WindowData {
atomic<int> current_count{0};
atomic<int> previous_count{0};
atomic<int64_t> current_window_index{0};
mutex window_mutex;
};
unordered_map<string, unique_ptr<WindowData>> counters_;
mutable mutex map_mutex_;
int64_t get_window_index() const {
auto now = system_clock::now().time_since_epoch();
return duration_cast<seconds>(now).count() / window_size_sec_;
}
public:
SlidingWindowCounter(int window_size_sec, int limit)
: window_size_sec_(window_size_sec), limit_(limit) {}
bool is_allowed(const string& user_id) {
WindowData* data = nullptr;
{
lock_guard<mutex> lock(map_mutex_);
auto it = counters_.find(user_id);
if (it == counters_.end()) {
counters_[user_id] = make_unique<WindowData>();
data = counters_[user_id].get();
} else {
data = it->second.get();
}
}
int64_t current_idx = get_window_index();
{
lock_guard<mutex> lock(data->window_mutex);
// 若窗口已更換,滾動計數器
int64_t stored_idx = data->current_window_index.load();
if (stored_idx != current_idx) {
if (stored_idx + 1 == current_idx) {
data->previous_count.store(
data->current_count.load()
);
} else {
data->previous_count.store(0);
}
data->current_count.store(0);
data->current_window_index.store(current_idx);
}
// 計算滑動窗口估算值
auto now_sec = duration_cast<seconds>(
system_clock::now().time_since_epoch()
).count();
double progress =
static_cast<double>(now_sec % window_size_sec_)
/ window_size_sec_;
double estimated =
data->previous_count.load() * (1.0 - progress)
+ data->current_count.load();
if (estimated >= limit_) {
return false; // 限流
}
data->current_count.fetch_add(1, memory_order_relaxed);
return true;
}
}
};
3.9 C++ 測試程式
int main() {
cout << "=== Token Bucket Rate Limiter (C++) ===" << endl;
RateLimiterManager manager(10.0, 2.0); // 容量 10,每秒補充 2
// 模擬多執行緒請求
atomic<int> allowed_count{0};
atomic<int> denied_count{0};
auto send_requests = [&](const string& user_id, int count) {
for (int i = 0; i < count; i++) {
if (manager.is_allowed(user_id)) {
allowed_count.fetch_add(1);
} else {
denied_count.fetch_add(1);
}
}
};
// 4 個執行緒,各發送 5 個請求(共 20 個)
vector<thread> threads;
for (int i = 0; i < 4; i++) {
threads.emplace_back(send_requests, "user-1", 5);
}
for (auto& t : threads) t.join();
cout << "Total allowed: " << allowed_count << endl; // ~10(桶容量)
cout << "Total denied: " << denied_count << endl; // ~10
cout << "Buckets in memory: " << manager.bucket_count() << endl;
cout << "\n=== Sliding Window Counter (C++) ===" << endl;
SlidingWindowCounter swc(60, 5); // 每分鐘 5 個
int sw_allowed = 0, sw_denied = 0;
for (int i = 0; i < 8; i++) {
if (swc.is_allowed("user-2")) {
sw_allowed++;
cout << "Request " << i + 1 << ": ALLOWED" << endl;
} else {
sw_denied++;
cout << "Request " << i + 1 << ": DENIED" << endl;
}
}
cout << "Allowed: " << sw_allowed << ", Denied: " << sw_denied << endl;
// 輸出:Allowed: 5, Denied: 3
return 0;
}
4. 效能分析
各演算法效能特性
| 演算法 | 判斷延遲 | 記憶體/用戶 | 吞吐量上限 | 適用規模 |
|---|---|---|---|---|
| Fixed Window Counter | ~0.01ms | 16 B | > 1M QPS | 任意 |
| Sliding Window Log | ~0.1ms | 1.6 KB | ~100K QPS | 中小型 |
| Sliding Window Counter | ~0.01ms | 32 B | > 1M QPS | 任意 |
| Token Bucket | ~0.01ms | 16 B | > 1M QPS | 任意 |
| Leaky Bucket | ~0.01ms | 8 B × queue | ~500K QPS | 任意 |
關鍵觀察:
記憶體是最大差異點:Sliding Window Log 每用戶消耗 1.6 KB,在千萬用戶場景下需要 16 GB,是其他方案的 100 倍以上。這也是為什麼生產環境幾乎不使用純 Sliding Window Log。
判斷延遲都很低:所有方案的單次判斷延遲都在微秒級別,不會成為效能瓶頸。真正的延遲來源是分散式環境中的網路往返(Redis RTT 通常 0.1-1ms)。
吞吐量瓶頸在 Redis:單機限流的吞吐量遠超 100 萬 QPS,但引入 Redis 後瓶頸變成 Redis 的處理能力(單節點約 10-20 萬 QPS for Lua Script)。
C++ vs JavaScript 效能對比
在純計算場景下,C++ 的 Lock-free Token Bucket 比 JavaScript 版本快約 3-5 倍。但在實際的 Web 服務中,網路 I/O 延遲(Redis RTT)遠大於計算延遲,因此語言本身的效能差異並不顯著。選擇語言應基於生態系統與團隊熟悉度,而非純粹的微基準測試結果。
5. 生產環境考量
5.1 分散式限流——Redis + Lua Script
在分散式環境中,多台伺服器必須共享限流狀態。Redis 的單執行緒模型天然支援原子操作,Lua Script 確保「讀取-判斷-更新」三步驟的原子性,徹底避免 Race Condition。
Redis Lua Script(Sliding Window Counter)
-- rate_limit.lua
-- KEYS[1] = 當前窗口 key (e.g., "rl:user123:1716854400")
-- KEYS[2] = 前窗口 key (e.g., "rl:user123:1716854340")
-- ARGV[1] = limit, ARGV[2] = window_size, ARGV[3] = now, ARGV[4] = ttl
local limit = tonumber(ARGV[1])
local window_size = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
local current_count = tonumber(redis.call('GET', KEYS[1])) or 0
local previous_count = tonumber(redis.call('GET', KEYS[2])) or 0
-- 計算當前窗口進度
local progress = (now % window_size) / window_size
-- 滑動窗口估算
local estimated = previous_count * (1 - progress) + current_count
if estimated >= limit then
local reset_at = math.ceil(now / window_size) * window_size
return {0, limit, 0, reset_at}
end
-- 增加當前窗口計數
local new_count = redis.call('INCR', KEYS[1])
-- 設定 TTL,防止 key 永久留存
if new_count == 1 then
redis.call('EXPIRE', KEYS[1], ttl)
end
local remaining = limit - math.ceil(
previous_count * (1 - progress) + new_count
)
local reset_at = math.ceil(now / window_size) * window_size
return {1, limit, math.max(0, remaining), reset_at}
TypeScript Redis 整合
import { createClient } from "redis";
interface RateLimitResponse {
allowed: boolean;
limit: number;
remaining: number;
resetAt: number;
}
class RedisRateLimiter {
private client: ReturnType<typeof createClient>;
private luaScript: string;
private scriptSha: string | null = null;
constructor(redisUrl: string = "redis://localhost:6379") {
this.client = createClient({ url: redisUrl });
this.luaScript = `
local limit = tonumber(ARGV[1])
local window_size = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
local current_count = tonumber(redis.call('GET', KEYS[1])) or 0
local previous_count = tonumber(redis.call('GET', KEYS[2])) or 0
local progress = (now % window_size) / window_size
local estimated = previous_count * (1 - progress) + current_count
if estimated >= limit then
local reset_at = math.ceil(now / window_size) * window_size
return {0, limit, 0, reset_at}
end
local new_count = redis.call('INCR', KEYS[1])
if new_count == 1 then redis.call('EXPIRE', KEYS[1], ttl) end
local remaining = limit - math.ceil(
previous_count * (1 - progress) + new_count
)
local reset_at = math.ceil(now / window_size) * window_size
return {1, limit, math.max(0, remaining), reset_at}
`;
}
async connect(): Promise<void> {
await this.client.connect();
// 預載 Lua Script,使用 SHA 調用可減少網路傳輸
this.scriptSha = await this.client.scriptLoad(this.luaScript);
}
async isAllowed(
userId: string,
limit: number = 100,
windowSizeSeconds: number = 60
): Promise<RateLimitResponse> {
const now = Math.floor(Date.now() / 1000);
const currentWindowIndex = Math.floor(now / windowSizeSeconds);
const previousWindowIndex = currentWindowIndex - 1;
const currentKey = `rl:${userId}:${currentWindowIndex}`;
const previousKey = `rl:${userId}:${previousWindowIndex}`;
const result = await this.client.evalSha(
this.scriptSha!,
{
keys: [currentKey, previousKey],
arguments: [
limit.toString(),
windowSizeSeconds.toString(),
now.toString(),
(windowSizeSeconds * 2).toString(), // TTL = 2 個窗口
],
}
) as number[];
return {
allowed: result[0] === 1,
limit: result[1],
remaining: result[2],
resetAt: result[3],
};
}
/** Express 中介層工廠函數 */
middleware(limit: number = 100, windowSizeSeconds: number = 60) {
return async (req: any, res: any, next: any) => {
const userId = req.headers["x-user-id"] || req.ip;
const result = await this.isAllowed(userId, limit, windowSizeSeconds);
// 設定標準 Rate Limit 標頭
res.set({
"X-RateLimit-Limit": result.limit,
"X-RateLimit-Remaining": result.remaining,
"X-RateLimit-Reset": result.resetAt,
});
if (!result.allowed) {
const retryAfter = result.resetAt - Math.floor(Date.now() / 1000);
res.set("Retry-After", retryAfter);
return res.status(429).json({
error: "Too Many Requests",
message: `Rate limit exceeded. Try again in ${retryAfter}s.`,
retryAfter,
});
}
next();
};
}
}
5.2 多層限流策略(Tiered Rate Limiting)
生產環境通常不會只用單一限流規則,而是建立多層防禦:
請求進入
↓
[全域限流] 整個系統 10,000 QPS
↓ 通過
[IP 限流] 每 IP 1,000 req/min
↓ 通過
[用戶限流] Free: 100/min, Pro: 1,000/min, Enterprise: 10,000/min
↓ 通過
[端點限流] /api/search: 20/min, /api/upload: 5/min
↓ 通過
後端服務
每一層使用不同的限流維度和閾值,從粗粒度到細粒度逐層過濾。這種分層設計的好處是:即使某一層的規則被繞過,後續層仍能提供保護。
5.3 Fail-Open vs Fail-Closed
當 Redis 發生故障時,Rate Limiter 該如何處理?
| 策略 | 行為 | 適用場景 |
|---|---|---|
| Fail-Open | Redis 故障時放行所有請求 | 寧可被打爆也不要拒絕合法用戶(C 端產品) |
| Fail-Closed | Redis 故障時拒絕所有請求 | 安全優先(金融 API、防濫用場景) |
| Fail-Local | Redis 故障時切換本地限流 | 折衷方案(推薦) |
推薦的 Fail-Local 實作:
async function isAllowedWithFallback(
userId: string,
limit: number
): Promise<boolean> {
try {
const result = await redisLimiter.isAllowed(userId, limit);
return result.allowed;
} catch (error) {
console.error(
"Redis Rate Limiter failed, falling back to local",
error
);
// Fail-Local:使用本地 Token Bucket 作為備援
return localLimiter.isAllowed(userId);
}
}
5.4 Race Condition 與原子性
在分散式環境中,最常見的 Race Condition 是「讀取-判斷-更新」三步驟之間被其他請求插入:
Server A: GET count → 99(< 100,允許)
Server B: GET count → 99(< 100,允許)
Server A: SET count → 100
Server B: SET count → 100
→ 實際通過了 101 個請求!
解決方案:Redis Lua Script 將三步驟封裝為原子操作。由於 Redis 是單執行緒的,Lua Script 在執行期間不會被其他命令中斷,從根本上杜絕了 Race Condition。
5.5 監控與告警
生產環境的 Rate Limiter 需要完善的可觀測性:
- Metrics:被限流的請求數(rate_limited_total)、限流判斷延遲(rate_limit_latency_ms)、各用戶剩餘配額
- Alerting:單一用戶持續觸發限流超過 N 分鐘、限流判斷延遲超過 SLA、Redis 連線失敗觸發 Fallback
- Dashboard:即時顯示全域 QPS、Top-N 被限流用戶、各端點限流觸發率
5.6 API Gateway 整合
主流 API Gateway 的 Rate Limiting 支援:
| Gateway | 採用演算法 | 分散式支援 | 備註 |
|---|---|---|---|
| AWS API Gateway | Token Bucket | 內建全球 | 帳戶/方法/端點三層 |
| Kong | Token Bucket, Fixed Window | Redis / 本地 | 免費版支援基礎限流 |
| Nginx | Leaky Bucket (limit_req) | 本地 | ngx_http_limit_req_module |
| Cloudflare | Sliding Window | 邊緣節點全球 | Workers KV 儲存 |
| Traefik | Token Bucket | Redis | 中介層生態豐富 |
6. LeetCode 練習
1. LeetCode 359 — Logger Rate Limiter
難度:Easy
連結:https://leetcode.com/problems/logger-rate-limiter/
題意:設計一個日誌限流器,同一條訊息在 10 秒內只能印出一次。這是 Rate Limiter 最基礎的形式——Fixed Window Counter 的變體。核心思路是用 HashMap 記錄每條訊息的上次印出時間。
2. LeetCode 1670 — Design Front Middle Back Queue
難度:Medium
連結:https://leetcode.com/problems/design-front-middle-back-queue/
與限流的關聯:Leaky Bucket 的核心是佇列管理。此題要求設計一個支援前端、中間、尾端操作的佇列,訓練你對佇列結構的熟練度,這些技巧在實作 Leaky Bucket 的進階變體時非常實用。
3. LeetCode 362 — Design Hit Counter
難度:Medium
連結:https://leetcode.com/problems/design-hit-counter/
與限流的關聯:設計一個命中計數器,統計過去 5 分鐘的命中次數。這本質上就是 Sliding Window Log 的簡化版——維護一個時間戳佇列,查詢時過濾出窗口內的有效記錄。
4. LeetCode 146 — LRU Cache
難度:Medium
連結:https://leetcode.com/problems/lru-cache/
與限流的關聯:Rate Limiter 需要管理大量用戶的狀態,記憶體有限時必須淘汰不活躍的用戶資料。LRU Cache 是實作 Token Bucket 記憶體管理(自動清理不活躍的桶)的核心資料結構。
5. LeetCode 1429 — First Unique Number
難度:Medium
連結:https://leetcode.com/problems/first-unique-number/
與限流的關聯:此題需要在串流資料中即時追蹤狀態,與 Rate Limiter 在即時請求串流中維護計數器的需求相似,訓練你對 HashMap + 有序結構的組合運用能力。
總結
本文作為 實戰應用系列 的第一篇,我們從零開始設計了一個完整的 Rate Limiter(限流器):
需求分析:明確了功能性需求(限流規則、429 回應、多維度限流)與非功能性需求(低延遲、高可用、分散式支援),並透過規模估算理解了記憶體效率對演算法選擇的決定性影響。
四種演算法:Fixed Window Counter 最簡單但有邊界問題;Sliding Window Log 最精確但記憶體消耗最大;Sliding Window Counter 兼具精確度與效率,是通用首選;Token Bucket 允許突發流量,被 AWS API Gateway 等主流平台採用;Leaky Bucket 輸出恆定,適合支付等平滑處理場景。
完整實作:提供了 JavaScript/TypeScript 與 C++ 雙語言的全部四種演算法實作,包含多用戶管理、記憶體清理、HTTP 中介層整合,以及 C++ 的 Lock-free CAS 高效能版本。
分散式方案:使用 Redis Lua Script 實現原子性的分散式限流,避免 Race Condition;設計多層限流策略(全域 → IP → 用戶 → 端點);討論 Fail-Open/Fail-Closed/Fail-Local 三種故障處理策略。
效能分析:量化比較了各演算法的記憶體消耗、判斷延遲和吞吐量上限,幫助你在實際場景中做出正確的技術選型。
Rate Limiter 的設計過程展示了一個重要的系統設計思維:沒有完美的方案,只有最適合場景的方案。Token Bucket 允許突發但不夠精確,Sliding Window Counter 精確但不允許突發——理解每種方案的 Trade-off,才能在面試和實際工作中做出有理有據的技術決策。
在下一篇文章中,我們將繼續實戰應用系列,探討 設計 LRU/LFU Cache——另一個面試高頻題,也是與 Rate Limiter 密切相關的系統元件(Rate Limiter 本身就需要 Cache 來管理用戶狀態)。敬請期待!