設計 Rate Limiter — Token Bucket、Sliding Window 與分散式限流實戰 | 資料結構與演算法

2026/07/23
設計 Rate Limiter — Token Bucket、Sliding Window 與分散式限流實戰 | 資料結構與演算法

Rate Limiter(限流器) 是系統設計中保護後端服務免受流量衝擊的核心元件。本文將深入比較 Token BucketLeaky BucketFixed Window CounterSliding Window Counter 四種主流限流演算法,從需求分析、方案設計到完整的 JavaScript/TypeScriptC++ 雙語言實作,並涵蓋 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:

指標數值說明
DAU1,000 萬日活躍使用者
峰值 QPS100,000每秒請求數
每用戶限制100 req/min預設限流規則
限流規則數1,000 條不同端點/等級
判斷延遲目標< 1msP99

記憶體估算是選擇演算法的重要依據:

  • 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.01ms16 B> 1M QPS任意
Sliding Window Log~0.1ms1.6 KB~100K QPS中小型
Sliding Window Counter~0.01ms32 B> 1M QPS任意
Token Bucket~0.01ms16 B> 1M QPS任意
Leaky Bucket~0.01ms8 B × queue~500K QPS任意

關鍵觀察

  1. 記憶體是最大差異點:Sliding Window Log 每用戶消耗 1.6 KB,在千萬用戶場景下需要 16 GB,是其他方案的 100 倍以上。這也是為什麼生產環境幾乎不使用純 Sliding Window Log。

  2. 判斷延遲都很低:所有方案的單次判斷延遲都在微秒級別,不會成為效能瓶頸。真正的延遲來源是分散式環境中的網路往返(Redis RTT 通常 0.1-1ms)。

  3. 吞吐量瓶頸在 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-OpenRedis 故障時放行所有請求寧可被打爆也不要拒絕合法用戶(C 端產品)
Fail-ClosedRedis 故障時拒絕所有請求安全優先(金融 API、防濫用場景)
Fail-LocalRedis 故障時切換本地限流折衷方案(推薦)

推薦的 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 GatewayToken Bucket內建全球帳戶/方法/端點三層
KongToken Bucket, Fixed WindowRedis / 本地免費版支援基礎限流
NginxLeaky Bucket (limit_req)本地ngx_http_limit_req_module
CloudflareSliding Window邊緣節點全球Workers KV 儲存
TraefikToken BucketRedis中介層生態豐富

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(限流器)

  1. 需求分析:明確了功能性需求(限流規則、429 回應、多維度限流)與非功能性需求(低延遲、高可用、分散式支援),並透過規模估算理解了記憶體效率對演算法選擇的決定性影響。

  2. 四種演算法Fixed Window Counter 最簡單但有邊界問題;Sliding Window Log 最精確但記憶體消耗最大;Sliding Window Counter 兼具精確度與效率,是通用首選;Token Bucket 允許突發流量,被 AWS API Gateway 等主流平台採用;Leaky Bucket 輸出恆定,適合支付等平滑處理場景。

  3. 完整實作:提供了 JavaScript/TypeScript 與 C++ 雙語言的全部四種演算法實作,包含多用戶管理、記憶體清理、HTTP 中介層整合,以及 C++ 的 Lock-free CAS 高效能版本。

  4. 分散式方案:使用 Redis Lua Script 實現原子性的分散式限流,避免 Race Condition;設計多層限流策略(全域 → IP → 用戶 → 端點);討論 Fail-Open/Fail-Closed/Fail-Local 三種故障處理策略。

  5. 效能分析:量化比較了各演算法的記憶體消耗、判斷延遲和吞吐量上限,幫助你在實際場景中做出正確的技術選型。

Rate Limiter 的設計過程展示了一個重要的系統設計思維:沒有完美的方案,只有最適合場景的方案。Token Bucket 允許突發但不夠精確,Sliding Window Counter 精確但不允許突發——理解每種方案的 Trade-off,才能在面試和實際工作中做出有理有據的技術決策。

在下一篇文章中,我們將繼續實戰應用系列,探討 設計 LRU/LFU Cache——另一個面試高頻題,也是與 Rate Limiter 密切相關的系統元件(Rate Limiter 本身就需要 Cache 來管理用戶狀態)。敬請期待!

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →