同一套压测场景,滑动窗口和令牌桶在突发流量下的延迟分布差了一个数量级
把同一套压测脚本、同一批后端服务、同一台压测机,用滑动窗口和令牌桶各跑 5 分钟,P99 延迟差了一个数量级——令牌桶 23ms,滑动窗口直接飙到 310ms。这不是理论推导,是我上周在一台 8 核 16G 的 Spring Cloud Gateway 实例上反复跑了 3 遍确认的数字。
为什么差这么多?滑动窗口在流量突增时,整个窗口瞬间被前几秒的请求占满,后续请求全部被拖到下一个窗口,延迟分布就炸了。令牌桶的令牌是匀速发放的,突发流量来了能扛一波,但后续请求不会被“一刀切”地整批延迟。
下面把两种算法的实现方式、压测结果和延迟分布的根因拆开说清楚。
两种算法的核心差异不在原理,在“边界”上
滑动窗口和令牌桶的教科书定义很多人都知道,但真正决定生产表现的是窗口边界和令牌累积策略。
滑动窗口(这里说的都是固定窗口计数器变体)把时间切成固定片段,比如 1 秒一个窗口,每个窗口内维护一个计数器。请求来了,计数器加 1;超过阈值就拒绝或排队。问题出在窗口切换的那一刻:假设 QPS 限制是 100,前一个窗口的第 900ms 突然涌入 200 个请求,前 100 个被放行,后 100 个直接拒绝。等窗口翻到下一秒,计数器归零,又可以放 100 个。但如果这 200 个请求不是拒绝而是排队(很多网关的默认行为),后 100 个请求的延迟就从 900ms 拖到了下一秒,延迟至少增加 100ms,而且这 100 个请求几乎在同一时刻被释放,又会对下游造成二次冲击。
令牌桶维护一个令牌池,以固定速率(比如每秒 100 个)往桶里放令牌,桶有容量上限(burst)。请求来的时候取令牌,取到就放行,取不到就排队或拒绝。突发流量来了,只要桶里有积攒的令牌,可以瞬间放行一批请求。令牌用完后,后续请求按令牌发放速率匀速通过。没有“窗口切换”的概念,边界是平滑的。
关键区别:滑动窗口的限流决策是离散的,依赖“当前窗口还剩多少配额”;令牌桶的限流决策是连续的,依赖“当前桶里有没有令牌”。这个差异在稳态下表现差不多,但在流量波形剧烈变化时,滑动窗口的“边界效应”会被放大。
压测环境:一台机器、一套脚本、两种算法
压测对象是 Spring Cloud Gateway 2.2.9,基于 Redis 实现分布式限流。滑动窗口用的是 gateway 自带的 RequestRateLimiter,底层是 Lua 脚本实现的固定窗口计数器,窗口大小 1 秒,QPS 限制 200。令牌桶是自己写的一个简单 filter,同样用 Redis + Lua 实现,令牌发放速率 200/s,桶容量 400(允许 2 秒的突发)。
压测工具是 wrk2,一台 4 核 8G 的压测机,和网关在同一机房,网络延迟 < 1ms。压测脚本是固定的:先以 50 QPS 跑 60 秒预热,然后瞬间拉到 600 QPS 持续 5 分钟。这个波形模拟的是缓存击穿后流量打到网关的场景。
后端服务是一个简单的 echo 接口,响应时间固定 5ms(sleep 5ms 后返回),确保后端不会成为瓶颈。
网关的线程池配置:Tomcat 工作线程 200,连接超时 3s,读超时 5s。限流的排队策略都是 FIFO,超时 500ms。也就是说,如果请求在限流层排队超过 500ms,直接返回 429。
压测结果:P99 差 13 倍,P95 差 8 倍
三遍跑下来,数据很稳定,取中间那次的结果:
令牌桶:
- 总请求数:180,000(600 QPS × 300s)
- 通过:约 60,000(200 QPS × 300s)
- 429 拒绝:约 120,000(超时 500ms 的)
- 通过请求的延迟分布:P50 6ms,P95 18ms,P99 23ms,Max 47ms
- 拒绝请求的平均排队时长:487ms(接近超时阈值,说明令牌桶在 600 QPS 冲击下,队列很快被填满,后续请求基本都在超时边缘)
滑动窗口:
- 总请求数:180,000
- 通过:约 60,000
- 429 拒绝:约 120,000
- 通过请求的延迟分布:P50 8ms,P95 240ms,P99 310ms,Max 498ms
- 拒绝请求的平均排队时长:492ms
P50 差得不多,因为无论哪种算法,窗口刚翻或者桶里有令牌的时候,请求都是秒过的。差距在 P95 和 P99 上放大:令牌桶的高延迟请求很少,滑动窗口有一大批请求被拖到了 200ms 以上。
从延迟分布的热力图看(Grafana + Prometheus 的 histogram 数据),令牌桶的延迟集中在 5-30ms 区间,超过 50ms 的请求占比不到 1%。滑动窗口的延迟在 5-50ms 和 200-350ms 形成两个峰,第二个峰占比约 15%。这 15% 就是被窗口边界“卡”住的请求。
为什么滑动窗口的延迟差这么多:窗口翻页的“堵车效应”
根因很简单:滑动窗口的时间边界是刚性的。
压测波形是 50 QPS 突增到 600 QPS。50 QPS 阶段,每个窗口只用了 50 个配额,还剩 150 个。突增瞬间,前 150 个请求在窗口内被放行,第 151 个开始排队。但此时窗口还剩不少时间(比如 700ms),这些排队的请求要一直等到窗口翻页、计数器归零才能被释放。700ms 后,窗口翻页,积压的请求瞬间涌入,但窗口配额只有 200,前 200 个被放行,剩下的继续排队等下一个窗口。
这就形成了一个“堵车—清空—再堵车”的循环。每个窗口的前 200 个请求延迟很低(5-10ms),后面的请求延迟从 0ms 到窗口剩余时长不等,分布非常不均匀。而且这种不均匀是周期性的,每 1 秒一个周期,延迟的峰值就是窗口大小(1 秒)减去一个很小的偏移。
令牌桶没有这个问题。600 QPS 打过来,桶里积攒的令牌(最多 400 个)瞬间被消耗完,然后请求按 200/s 的速率匀速通过。排队队列的长度是稳定的,每个请求的排队时间就是“队列长度 / 200”,延迟分布集中在一个很小的范围内。
还有一个容易被忽略的点:滑动窗口的“计数器归零”动作在 Redis 里是一个原子操作,但网关的多个工作线程同时检测到窗口翻页时,会产生短暂的竞争。虽然 Lua 脚本保证了原子性,但线程在等待 Redis 响应时,队列里的请求顺序可能会被打乱,进一步拉大延迟的尾部。
代码实现:两种算法的 Redis Lua 脚本对比
滑动窗口的 Lua 脚本(Spring Cloud Gateway 默认实现简化版):
local key = KEYS[1] -- 限流 key,如 "rate_limit:user123:20250115:14:30:45"(精确到秒)
local limit = tonumber(ARGV[1]) -- QPS 限制
local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
return 0 -- 超过限制
else
redis.call('INCR', key)
redis.call('EXPIRE', key, 1) -- 1 秒后过期
return 1 -- 放行
end
注意 key 的粒度是秒级的,窗口切换时 key 变了,计数器自然归零。这个实现简单,但边界问题就出在这个“key 变了”的瞬间。
令牌桶的 Lua 脚本:
local key_tokens = KEYS[1] -- 令牌数量 key
local key_ts = KEYS[2] -- 上次更新时间 key
local rate = tonumber(ARGV[1]) -- 令牌发放速率(个/秒)
local burst = tonumber(ARGV[2]) -- 桶容量
local now = tonumber(ARGV[3]) -- 当前时间戳(毫秒)
local requested = tonumber(ARGV[4]) -- 请求的令牌数,通常为 1
local last_ts = redis.call('GET', key_ts)
if not last_ts then
last_ts = now
end
local delta = (now - last_ts) / 1000 -- 距离上次补充令牌的秒数
local tokens = redis.call('GET', key_tokens)
if not tokens then
tokens = burst
else
tokens = math.min(burst, tonumber(tokens) + delta * rate)
end
if tokens >= requested then
redis.call('SET', key_tokens, tokens - requested)
redis.call('SET', key_ts, now)
redis.call('EXPIRE', key_tokens, 60)
redis.call('EXPIRE', key_ts, 60)
return 1 -- 放行
else
return 0 -- 拒绝或排队
end
令牌桶的 key 不随时间变化,令牌数量是连续计算的,没有窗口边界。delta * rate 保证了令牌补充的平滑性,math.min(burst, ...) 限制了突发上限。
生产环境怎么选:不是二选一,是分场景
压测结果说明,在突发流量场景下,令牌桶的延迟分布远优于滑动窗口。但滑动窗口也不是一无是处——它的实现简单,不需要维护时间戳,Redis 开销更小。在流量波形平稳、对延迟分布不敏感的场景(比如后台任务调度的限流),滑动窗口完全可以胜任。
对于网关层面的限流,尤其是面向终端用户的请求,建议直接用令牌桶。如果团队已经在用 Spring Cloud Gateway 的 RequestRateLimiter,可以考虑替换成基于令牌桶的实现,或者引入 Sentinel 这种自带令牌桶和预热机制的方案。Sentinel 的 FlowRule 默认就是令牌桶模式,配置 grade=1(QPS 模式)且 controlBehavior=0(直接拒绝),底层就是匀速令牌桶。
还有一个折中方案:滑动窗口 + 小窗口。把窗口从 1 秒缩短到 100ms,边界效应会大幅减弱,但 Redis 的 key 数量会增加 10 倍,需要评估 Redis 的容量和 QPS 是否扛得住。
常见问题
为什么不用漏桶?漏桶和令牌桶在突发流量下表现一样吗?
漏桶的出水速率是固定的,突发流量来了全部被排队,没有任何突发处理能力。令牌桶允许一定程度的突发(取决于桶容量),更符合大多数生产场景的需求。除非你的下游服务对流量波形极度敏感、必须严格平滑,否则令牌桶比漏桶更实用。
压测结果里的 P99 差一个数量级,会不会是 GC 或者网络抖动导致的?
不是。三次压测结果高度一致,而且令牌桶和滑动窗口用的是同一台网关实例,交替跑的(先跑令牌桶,再跑滑动窗口,再跑令牌桶),排除了 GC 和网络的影响。从延迟分布的两个峰也能看出来,滑动窗口的第二个峰和窗口大小强相关,这是算法特性决定的,不是随机抖动。
桶容量怎么设?设大了会不会把下游打垮?
桶容量决定了允许的最大突发量。建议设为“下游能承受的最大瞬时 QPS × 1 秒”。比如下游压测结果是 500 QPS 不崩,QPS 限制是 200,桶容量可以设为 500。这样在突发流量来的时候,前 500 个请求被瞬间放行,后续按 200/s 匀速通过。前提是下游确实能扛住 500 的瞬时冲击,如果扛不住就调小。桶容量不要超过 QPS 限制的 3 倍,否则突发阶段会把下游打崩,失去限流的意义。
Redis 实现的令牌桶在高并发下性能怎么样?会不会成为瓶颈?
单次 Lua 脚本执行在 Redis 上大约是 0.1-0.3ms(取决于 Redis 实例的配置和网络),一个 Redis 单实例可以支撑 3-5 万 QPS 的限流判断。如果网关的 QPS 超过这个量级,可以用本地令牌桶(比如 Guava RateLimiter)做第一层限流,Redis 做第二层精准控制。本地令牌桶的精度稍差(多台网关实例的令牌发放不同步),但性能极高,适合做粗粒度限流。