3. 令牌桶算法详解

令牌桶(Token Bucket)是限流与流量整形领域最经典的算法之一,在催收系统的外呼限流、AI 模型 Serving 的请求排队中均有广泛应用。其核心思想是:以恒定速率向桶中放入令牌,请求到达时必须获取到令牌才能被处理。桶的容量决定了最大突发流量,放入速率决定了长期平均处理速率。

3.1 算法原理

令牌桶由三个核心要素构成:

  • 令牌生成器:以固定速率 r(单位:个/秒)向桶中添加令牌。
  • 桶:最大容量 b(单位:个),用于存储未使用的令牌。
  • 请求消费:每个请求到达时,从桶中取出 1 个(或 N 个)令牌;若桶中令牌不足,则请求被拒绝或等待。

当桶满时,新生成的令牌直接丢弃,不会累积。这种机制使得系统在空闲时能够“攒”下令牌,在突发流量到来时一次性释放,从而允许短时间内的流量尖峰,同时保证长期平均速率不超过 r。

关键特性: 令牌桶允许突发(Burst),漏桶(Leaky Bucket)强制平滑。在催收场景中,外呼系统需要在催收策略触发时短时间发起大量呼叫,令牌桶比漏桶更适合。
令牌桶算法核心流程 令牌桶算法核心流程 令牌生成器 速率 r = 10 个/秒 放入令牌 令牌桶 容量 b = 20 当前令牌数 = 15 桶满则丢弃新令牌 取出令牌 请求处理 通过 / 拒绝 令牌不足:请求被拒绝 说明:令牌生成器以恒定速率向桶中放入令牌;请求到达时从桶中取出令牌,桶空则拒绝请求。 桶容量 b 决定了最大突发流量大小,速率 r 决定了长期平均处理速率。

3.2 参数配置:速率与容量

令牌桶的两个核心参数直接影响限流效果:

参数 符号 含义 配置建议(催收外呼) 配置建议(Model Serving)
令牌放入速率 r 每秒向桶中放入的令牌数,决定长期平均处理速率 根据外呼线路并发上限设定,如 50 QPS 根据模型推理 GPU 吞吐量设定,如 100 req/s
桶容量 b 桶最多能存储的令牌数,决定最大突发流量 通常设为 r × (1~3) 秒,允许短时间呼叫爆发 通常设为 r × (0.5~2) 秒,避免模型实例过载
参数调优经验: 在催收系统中,若外呼线路供应商允许短时间超频(如 1 秒内 100 并发,但平均 50 QPS),可将桶容量设为 r × 2。在 Model Serving 中,若模型有请求排队机制,桶容量可适当减小,以保护 GPU 显存不被突发请求打满。

3.3 适用场景:突发流量

令牌桶最典型的应用场景是允许突发流量。以下对比表展示了不同场景下的适用性:

场景 流量特征 令牌桶适用性 原因
催收外呼策略触发 策略触发瞬间大量呼叫请求涌入 ✅ 非常适合 桶容量允许短时间爆发,不影响长期平均速率
AI 模型批量推理 请求到达不均匀,有静默期和高峰 ✅ 非常适合 静默期攒令牌,高峰时一次性释放,提高 GPU 利用率
API 网关全局限流 需要严格平滑流量 ⚠️ 需配合漏桶 若要求绝对平滑,令牌桶的突发特性可能不符合要求
数据库连接池保护 连接数必须严格限制 ❌ 不推荐 连接池更适合信号量或固定线程池模型

3.4 代码实现示例

以下是一个基于 Python 的令牌桶实现,支持线程安全操作,适用于催收外呼限流或 Model Serving 请求排队:

import time
import threading

class TokenBucket:
    """
    令牌桶限流器
    :param rate: 令牌放入速率(个/秒)
    :param capacity: 桶容量(最大令牌数)
    """
    def __init__(self, rate: float, capacity: int):
        self.rate = rate
        self.capacity = capacity
        self.tokens = capacity  # 初始时桶是满的,允许立即突发
        self.last_refill_time = time.monotonic()
        self.lock = threading.Lock()

    def _refill(self):
        """按时间间隔补充令牌"""
        now = time.monotonic()
        elapsed = now - self.last_refill_time
        new_tokens = elapsed * self.rate
        if new_tokens > 0:
            self.tokens = min(self.capacity, self.tokens + new_tokens)
            self.last_refill_time = now

    def try_acquire(self, tokens: int = 1) -> bool:
        """
        尝试获取 tokens 个令牌
        :return: True 表示获取成功,False 表示令牌不足
        """
        with self.lock:
            self._refill()
            if self.tokens >= tokens:
                self.tokens -= tokens
                return True
            return False

    def acquire(self, tokens: int = 1, timeout: float = None) -> bool:
        """
        阻塞获取令牌,直到获取成功或超时
        :param tokens: 需要的令牌数
        :param timeout: 超时时间(秒),None 表示一直等待
        :return: True 表示获取成功,False 表示超时
        """
        start_time = time.monotonic()
        while True:
            if self.try_acquire(tokens):
                return True
            if timeout is not None and (time.monotonic() - start_time) >= timeout:
                return False
            time.sleep(0.001)  # 短暂休眠,避免忙等

# 使用示例
if __name__ == "__main__":
    # 初始化:速率 10 个/秒,容量 20
    bucket = TokenBucket(rate=10, capacity=20)

    # 模拟 30 个并发请求
    for i in range(30):
        if bucket.try_acquire():
            print(f"请求 {i+1}: 通过")
        else:
            print(f"请求 {i+1}: 被限流")
        time.sleep(0.05)  # 模拟请求间隔 50ms
生产环境注意事项:
  • 上述实现使用 time.monotonic() 而非 time.time(),避免系统时间调整导致限流失效。
  • 在分布式环境中,需使用 Redis 等中间件实现分布式令牌桶,保证多实例间限流一致性。
  • 对于高并发场景(如 QPS > 10,000),建议使用无锁算法或原子操作替代线程锁。
令牌桶参数配置与突发流量示意 令牌桶参数配置与突发流量示意 参数配置 • 速率 r = 10 个/秒 • 容量 b = 20 个 • 初始令牌 = 20(桶满) • 长期平均速率 = 10 QPS • 最大突发 = 20 个请求 桶容量越大,突发能力越强 突发流量处理示意 T0 T1(突发到达) T2(恢复) 5 个请求同时到达 桶中剩余令牌 ≥ 5 → 全部通过 说明:由于桶容量为 20,且之前空闲期已攒满令牌, 突发 5 个请求可以一次性通过,不会触发限流。

3.5 令牌桶 vs 漏桶:选型对比

在限流算法选型时,令牌桶与漏桶是最常被对比的两种方案。下表从多个维度进行对比:

对比维度 令牌桶 漏桶
突发处理 ✅ 允许突发,桶容量越大突发越强 ❌ 强制平滑,不允许突发
长期速率控制 ✅ 由放入速率 r 保证 ✅ 由漏出速率保证
实现复杂度 中等(需维护时间戳和计数器) 简单(队列 + 定时器)
内存开销 低(仅存储令牌数和时间戳) 高(需存储请求队列)
适用场景 催收外呼、Model Serving、API 网关(允许突发) 数据库写入、日志收集、消息队列(要求严格平滑)
选型建议: 在《催收限流熔断与 Model Serving 应用》课程体系中,令牌桶是默认推荐算法。催收外呼需要应对策略触发时的呼叫爆发,Model Serving 需要利用静默期积累令牌以提高 GPU 利用率,这两点都与令牌桶的特性高度契合。