一文搞懂令牌桶限流:平均速率与突发流量的本质(分布式令牌桶实现思路)
在做后端系统设计、网关限流或高并发控制时,令牌桶(Token Bucket)是一个绕不开的核心算法。很多人知道它“能限流”,但真正难理解的,是这句话:
令牌桶限制的是平均处理速率,但允许一定程度的突发流量
这篇文章将基于一个完整的问答推导过程,用时间线 + 场景的方式,帮你一次性真正搞懂这句话背后的含义。
一、先明确:令牌桶到底在干什么?
令牌桶算法的核心思想可以总结为一句话:
系统以固定速率往桶中放入令牌,请求只有拿到令牌才能被处理
这里有三个关键要素:
- 令牌生成速率(rate):比如 100 个 / 秒
- 桶容量(capacity):比如最多存 100 个令牌
- 请求消耗令牌:每个请求通常消耗 1 个令牌
限流器本质上做的事情只有一件:
按照时间持续生成令牌,并维护桶中令牌数量不超过上限
当桶满时,新生成的令牌会被直接丢弃。
二、什么叫「限制平均处理速率」?
假设我们设置:
令牌生成速率:100 token / 秒 桶容量:100这意味着什么?
- 每 1 秒,系统最多新增 100 个可用处理资格(令牌)
- 10 秒内,最多生成 1000 个令牌
- 1 分钟内,最多生成 6000 个令牌
无论请求如何变化:
系统在较长时间尺度上的处理能力是被严格限制的
也就是说,你不可能让系统长期稳定地跑在 200 QPS、300 QPS。
这就是所谓的:
限制的是平均处理速率
三、那什么是「允许突发流量」?
关键就在于:令牌是可以提前积累的。
我们还是用上面的参数,通过时间线来看。
四、完整时间线推演(核心理解)
阶段 1:系统启动后的 1 秒内没有请求
t = 0s ~ 1s- 限流器持续生成令牌
- 桶容量是 100
结果:
桶中令牌 = 100(已满)阶段 2:t = 1s,瞬间来了 100 个请求
注意关键词:瞬间(可能是 1ms 内)
处理结果:
- 每个请求消耗 1 个令牌
- 桶中正好有 100 个令牌
100 个请求 → 全部立即通过 桶中令牌 = 0这一步非常重要:
虽然配置的是 100 QPS,但这一刻的并发远远超过了 100 QPS
这就是:
允许突发流量
五、这为什么不违反「100 QPS」?
因为QPS 是一个“时间平均值”概念。
我们看一个完整时间段:
| 时间段 | 处理请求数 |
|---|---|
| 第 0~1 秒 | 0 |
| 第 1~2 秒 | 100 |
| 合计 | 100 |
平均下来:
100 / 2 = 50 QPS完全没有超过限制。
令牌桶限制的是长期平均速率,而不是瞬时峰值。
六、突发之后会发生什么?
阶段 3:突发请求消耗完令牌后
t > 1s 桶中令牌 = 0此时限流器仍然在工作:
- 按速率继续生成令牌
- 100 token / 秒 ≈ 每 10ms 生成 1 个
阶段 4:这时又瞬间来了 100 个请求
假设只过去了 10ms:
桶中令牌 ≈ 1这 100 个请求会怎样?
七、两种工程实践结果(非常重要)
情况一:不允许等待(最常见于网关)
- 1 个请求拿到令牌 → 立即通过
- 剩余 99 个请求 → 直接被限流(返回 429)
此时你的理解:
“只能一个过去”
是完全正确的。
情况二:允许等待(服务内部常见)
- 1 个请求立即通过
- 剩余 99 个请求进入等待队列
- 后续每生成 1 个令牌,就放行 1 个请求
最终表现为:
输出速率被平滑成 100 QPS瞬时峰值被削掉,请求被“平滑接住”
八、那「允许一定程度」到底是多少?
答案只有一个:
桶容量(capacity)
- 桶容量 = 100 → 最多允许瞬间 100 个请求
- 桶容量 = 1000 → 最多允许瞬间 1000 个请求
公式级理解:
最大突发能力 = 桶容量九、和漏桶算法的一句话对比
很多人会把令牌桶和漏桶混在一起,这里给你一句话区分:
令牌桶限制平均速率,允许突发;漏桶限制输出速率,不允许突发。
十、一句话总结
你可以这样总结令牌桶:
令牌桶算法通过限制令牌的生成速率来约束系统的长期平均处理能力,同时允许在桶容量范围内消耗提前积累的令牌,从而在短时间内承受一定规模的突发请求;当令牌耗尽后,请求将只能以固定速率被放行或直接被限流。
如果你理解到这里,说明你已经真正理解了:
什么叫“限制平均速率,但允许突发流量”
十一、Redis 分布式令牌桶实现思路(工程实践)
在单机内存中实现令牌桶并不复杂,但在多实例部署(微服务 / 网关集群)的场景下,就必须借助 Redis 来实现分布式限流,以保证多节点之间的令牌一致性。
下面给出一个经典且工程上可落地的 Redis 分布式令牌桶设计思路。
1、 核心设计目标
使用 Redis 实现令牌桶,需要解决三个问题:
- 令牌全局唯一:多个实例共享同一个桶
- 原子性:生成令牌 + 消耗令牌不能被并发破坏
- 高性能:限流逻辑必须非常快
因此,Lua 脚本 + Redis 单线程原子执行是最优解。
2、 Redis 中需要维护的关键数据
通常使用一个 Redis Key 表示一个令牌桶,例如:
rate_limiter:{api_name}该 Key 对应的数据结构中,需要维护:
tokens:当前桶中剩余令牌数last_timestamp:上一次补充令牌的时间戳
逻辑上可以理解为:
(tokens, last_timestamp)3、 请求到来时的完整执行流程
每个请求到来,都会在 Redis 中原子执行以下步骤:
- 获取当前时间
now - 根据时间差计算应补充的令牌数:
new_tokens = (now - last_timestamp) * rate- 更新桶中令牌数(不能超过容量):
tokens = min(capacity, tokens + new_tokens)- 判断是否有足够令牌:
如果
tokens >= 1:tokens = tokens - 1- 请求放行
否则:
- 请求被限流
- 更新
last_timestamp = now
以上逻辑必须在一个 Lua 脚本中完成,否则并发下会出现超发令牌的问题。
4、 Lua 脚本伪代码示例
下面是一个简化版伪代码(便于理解思路):
localrate=tonumber(ARGV[1])-- 每秒生成令牌数localcapacity=tonumber(ARGV[2])-- 桶容量localnow=tonumber(ARGV[3])localtokens=tonumber(redis.call('HGET',KEYS[1],'tokens'))orcapacitylocallast=tonumber(redis.call('HGET',KEYS[1],'last'))ornowlocaldelta=math.max(0,now-last)localnew_tokens=delta*rate tokens=math.min(capacity,tokens+new_tokens)iftokens<1thenredis.call('HSET',KEYS[1],'tokens',tokens)redis.call('HSET',KEYS[1],'last',now)return0elsetokens=tokens-1redis.call('HSET',KEYS[1],'tokens',tokens)redis.call('HSET',KEYS[1],'last',now)return1end返回值:
1→ 请求通过0→ 请求被限流
5、 为什么 Redis 令牌桶是“软限流”?
Redis 令牌桶本质上是:
- 立即判断是否有令牌
- 不负责请求排队
因此它通常用于:
- API 网关限流
- 外部接口防刷
- 秒杀前置拦截
是否“等待令牌”通常由:
- 客户端重试
- 上层业务队列
来实现,而不是限流器本身。
6、 工程实践中的几个关键细节
时间单位统一:建议统一使用毫秒时间戳
桶初始化:第一次访问时默认令牌为
capacityKey 过期策略:可设置较长 TTL,避免冷 Key 堆积
限流粒度:
- 按接口限流
- 按用户限流
- 按 IP 限流
7、 一句话工程总结
Redis 分布式令牌桶通过 Lua 脚本保证限流逻辑的原子性,使多实例系统在共享令牌的情况下,依然能够严格控制整体平均速率,同时保留令牌桶对突发流量的天然支持能力。