Nginx 限流还是 Redis 令牌桶?大促翻车后我把整套方案推翻了

🔑 关键词:限流方案对比,Nginx limit_req,Redis令牌桶,分布式限流,流量模型

📖 摘要:大促时限流阈值从头到尾没超,接口却挂了半小时。复盘后发现真正的问题在流量形状。这篇记录了 Nginx limit_req 的 burst/nodelay 陷阱、Redis 令牌桶 Lua 脚本里那个测试环境测不出来的时间戳 bug、热 key 打散方案,以及为什么限流阈值应该从下游连接池倒推而不是从业务目标倒推。

先说结论免得有人白看:限流放 Nginx 还是放 Redis,这个问题本身就问偏了。真正决定方案的是两件事——你的流量是平稳型还是脉冲型,以及超限的请求你打算让它去哪。这两件事没想清楚,选什么组件都是拍脑袋。

图片

去年 11 月一次大促,我们的订单创建接口挂了半个小时。复盘的时候发现一个特别蠢的事:限流阈值配的是 5000 QPS,监控大盘上的峰值也就 4800,从头到尾没超标。但把网关的 access log 拽出来,按 100ms 一个窗口切,20:00:00.000 到 20:00:00.200 这 200ms 里进来了 1900 个请求,后面 800ms 只有 300 个。全天平均 QPS 是 41。也就是说瞬时 QPS 干到了 9500,是配置阈值的将近两倍,而平均值看起来岁月静好。

这个形状,JMeter 是测不出来的。默认线程组是匀速发压,你把 ramp-up 设成 0 它也是按固定间隔循环发,很难复现这种"一个尖峰加一条长尾"的模式。要用 Synchronizing Timer,把 Timeout 设成 0,让线程卡在栅栏上一起放,才能模拟出瞬时并发。当时压测报告上写的 P99 是 78ms,看起来完全没问题,因为压测流量本身就没有尖峰。

Nginx limit_req 那几个参数,网上讲得都很糊

我们最早的方案是 Nginx 层限流,配置长这样:

limit_req_zone $binary_remote_addr zone=order_api:10m rate=200r/s;
limit_req_status 429;



![图片](http://img2.baidu.com/it/u=2469391744,2839749464&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=667)


server {
    location /api/order/create {
        limit_req zone=order_api burst=500 nodelay;
    }
}

有几个点值得掰开说:

rate=200r/s 不是"每秒放 200 个",是每 5 毫秒放一个(1000 / 200)。它是漏桶,出水口是匀速的,这一点决定了它天然不适合对抗脉冲——脉冲进来的时候,桶满了就拒绝,桶没满也是慢慢漏出去。

burst=500 是桶容量,超了直接返回 limit_req_status 指定的状态码。Nginx 默认返回 503,不少人不知道可以改成 429,但客户端和监控那边对这两个码的处理逻辑完全不一样,改之前记得跟上下游对齐。

nodelay 这个词极具误导性。不加它,burst 里的 500 个请求会按 5ms 一个的速度排队处理,500 × 5ms = 2.5 秒才能排完,客户端早就超时了。加上它,burst 内的请求立刻放行——这带来一个反直觉的结果:桶配得越大,瞬时打到后端的并发就越大。500 的 burst 意味着后端要在瞬间接住 500 个并发,如果 Tomcat 线程池只有 200,那这层限流等于没有,只是把超时从 Nginx 挪到了应用层。

zone=order_api:10m 这 10MB 共享内存,官方口径是 1MB 大约存 16000 个状态,10MB 差不多 16 万个 IP。按 $binary_remote_addr 限流在移动网络下特别容易误伤,一个运营商出口 NAT 后面蹲着几万用户,在 Nginx 眼里全是同一个 IP。

图片

Redis 令牌桶:一个测试环境永远测不出来的 bug

后来我们改成应用层加 Redis 令牌桶,Lua 脚本大概是这个逻辑:

-- KEYS[1]: 限流 key
-- ARGV[1]: rate 每秒补充令牌数
-- ARGV[2]: capacity 桶容量
-- ARGV[3]: now 当前毫秒时间戳
-- ARGV[4]: need 本次消耗令牌数
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or tonumber(ARGV[2])
local last   = tonumber(b[2]) or tonumber(ARGV[3])
local now    = tonumber(ARGV[3])
local delta  = math.max(0, now - last)
tokens = math.min(tonumber(ARGV[2]), tokens + delta * tonumber(ARGV[1]) / 1000)
if tokens < tonumber(ARGV[4]) then
  redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', last)
  redis.call('PEXPIRE', KEYS[1], 60000)
  return 0
end
tokens = tokens - tonumber(ARGV[4])
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 60000)
return 1

注意 return 0 那个分支,ts 存的是 last 不是 now。我第一版就是这么写的,后果是——被限流的 key 时间戳永远停在原地,等恢复的时候会一次性补一大桶令牌出来,然后又是一波尖峰,等于把限流变成了一个放大器。这个 bug 在测试环境死活测不出来,因为测试环境基本不触发限流,是上线第二天被一个爬虫刷出来的。

时间戳谁给也是坑。客户端传的话,两台机器 NTP 漂移几十毫秒很正常,多算少算令牌我都见过。用 Redis 脚本里的 TIME 命令更稳,不用担心主从不一致——Redis 3.2 之后 Lua 脚本的复制策略已经改成 effect replication,脚本里调 TIME 不会导致主备数据分叉。

图片

实测数据:单 Redis 实例跑这个脚本大概 6-8 万 QPS(4 核 8G,走 unix socket),P99 增加 0.4-0.6ms;如果跨机房调用,1.5-3ms,对一个本身只要 1ms 的缓存接口来说直接翻几倍。所以限流 key 一定要和业务在同一个机房,别省那点服务器钱。

还有热 key。我们按 user_id 做 key,集群 8 分片,某个大客户的账号一天 200 多万次请求,全落在一个分片上,那个分片 CPU 常年 90%+。后来改用 crc32(user_id) % 8 的结果当 hash tag,写成 rate:{5} 这种形式,把大 key 摊开了。代价是加一个分片就得重新洗一遍数据,这个后面细说。

我现在的新项目,第一步是翻日志不是选组件

现在做新项目,我的第一步不是选组件,是去拉日志。具体流程:

  1. 从 OpenResty 的 access log 取 $msec(毫秒时间戳),按 100ms 一个桶聚合,把过去 7 天的数据跑一遍,输出每个接口的 P50 / P95 / P99 / P999 和最大瞬时 QPS。
  2. 算一个我自己起的指标,叫峰均比:200ms 窗口内最大 QPS ÷ 全天平均 QPS。刚才那个订单接口算出来是 47。正常业务接口大概在 3-8 之间,超过 20 的都得单独拿出来设计。
  3. 压测用真实流量回放,工具是 tcpcopy 或者 jvm-sandbox-repeater。偷懒的话把 access log 的时间戳读出来 sleep 一下重放也行,重点是要保留原始请求的时间间隔分布,别自己造一个匀速的。

图片

这一步做完,方案基本就自己浮出来了。峰均比 5 以内的接口,Nginx limit_req 完全够用,别上 Redis 折腾;峰均比 30 以上的,Nginx 那套漏桶根本兜不住,必须用带桶容量的令牌桶,而且桶容量要按瞬时并发反推。

我的观点:限流阈值不该从业务目标倒推

限流阈值应该从下游容量倒推,而且限流必须带一个降级出口,否则等于没做。

很多团队的做法是老板说"要支持 10 万 QPS",然后限流就配 10 万。这完全反了。阈值应该等于下游最慢那个依赖的容量上限。拿我们的订单库举例:

  • 连接池 maxActive = 50,创单事务平均耗时 8ms,理论上限 ≈ 50 / 0.008 = 6250 QPS
  • 但 P99 是 40ms,如果请求都堵在 P99 那个区间,实际上限 ≈ 50 / 0.04 = 1250 QPS

6250 是理想值,1250 是保守值。我们最后定的阈值是 3000,介于两者之间,而且这个数字是每个月重新跑一遍日志重新算的,不是拍一次就完事。

图片

更关键的是降级出口。超限的请求去哪?大部分方案直接 429 打回去,用户体验很差。我们的做法是超限请求不碰 DB,直接返回"当前下单的人有点多,稍后再试",同时往 MQ 塞一条消息,异步给这个用户发一张 5 元券补偿。这个出口让限流从"拒绝用户"变成了"损失可控"。只做限流的系统设计,是不完整的。

最后给个速查表

场景 方案 关键参数
单机 IP 粗粒度 Nginx limit_req rate = 实测峰值 × 1.2~1.5,burst 不超过后端连接池的 30%,必加 nodelay,必须配 limit_req_status 429
按用户/租户维度 Redis + Lua 令牌桶 单实例别超 6 万 QPS,P99 预算 1ms 以内,cluster 模式用 hash tag 打散热 key
集群级精确控制 Sentinel 或自研 滑动窗口,窗口切 10-20 格,别用固定窗口

固定窗口的老问题还是得提一句:rate = 100/s 的前提下,00:00:00.999 进来 100 个请求,00:00:01.000 又进来 100 个,1 毫秒内实际通过了 200 个。这是它被淘汰的唯一原因,跟性能无关。

至于前面说的分片数变化问题,我最后的解法有点土——分片数固定写死成 2 的幂(我们用的 16),扩容按 2 倍扩,业务侧的 hash tag 算出来只取低 4 位,这样扩到 32 分片的时候只需要迁移一半数据,代码不用动。这个做法不优雅,但比每次扩分片都改一遍所有调用方强。要是你们有更好的办法,欢迎来骂我。

🏷️ 标签: