单机令牌桶在大促压测中失效,因其内存态结构无法跨进程同步,10台机器导致实际流量达理论值10倍,引发数据库连接池耗尽、redis热key阻塞及服务雪崩。

为什么单机令牌桶在大促压测中直接失效
因为单机内存态的 tokenbucket 结构完全无法跨进程同步,10 台机器各自维护一套桶,实际总流量是理论限流值的 10 倍。压测时 wrk 报告 QPS 5000,真实打到后端的可能是 50000,数据库瞬间连接池耗尽、Redis 热 key 阻塞、服务雪崩。
常见错误现象包括:压测曲线平滑但线上突增 409(Conflict)或 429(Too Many Requests)响应激增;同一用户从不同节点访问,限流计数不一致;突发流量下限流“漏出”明显,比如配置 1000 QPS,实测峰值冲到 3000+。
- 单机限流中间件只适合开发联调或低频内部接口,绝不能用于大促网关层
- 所有基于
sync.Mutex或atomic的本地计数器,在集群部署下等同于没限 - 即使用了
time.Now().UnixMilli()做时间窗口,也无法解决多节点时钟漂移导致的窗口错位问题
用 Redis + Lua 实现原子化分布式令牌桶
核心是把桶状态存在 Redis,每次请求通过 Lua 脚本一次性完成“计算令牌增量 + 判断是否放行 + 更新剩余令牌”,避免网络往返和并发竞争。Gin 中间件只需调用 redis.Eval() 即可。
关键点不是“用不用 Redis”,而是 Lua 脚本必须包含完整令牌逻辑,不能拆成 GET + INCR + SET 三步——否则高并发下必然超发。
- Lua 脚本里用
redis.call("TIME")获取服务端时间,规避客户端时钟误差 - 桶容量和速率建议按“单节点能力 × 节点数 × 0.7”预估,留出缓冲空间防抖动
- Redis 连接必须用连接池(如
github.com/go-redis/redis/v8的NewClient),禁用短连接 - 脚本返回值应为整数:1 表示放行,0 表示拒绝,避免 JSON 解析开销
大促压测前必须验证的三个边界场景
很多团队压测只跑平均流量,漏掉真实大促中最要命的三种毛刺:秒杀开抢瞬间、支付回调洪峰、风控规则动态加载触发的批量重验。这三类请求往往携带大 body、触发多层嵌套校验,对限流中间件的 CPU 和 RT 影响极大。
- 用
wrk -t12 -c1000 -d30s --script=burst.lua模拟 1 秒内 5000 请求的脉冲,观察限流拦截率是否稳定在 95% 以上 - 在中间件里加
defer func() { if r := recover(); r != nil { log.Error("rate limiter panic") } }(),防止 Lua 执行异常导致整个请求链崩断 - 检查 Redis 监控里的
latency和connected_clients,若 P99 延迟 > 5ms 或连接数持续 > 80% 配置上限,说明限流已成瓶颈,需扩容 Redis 或降级为本地 fallback
别忽略限流失败后的降级路径
限流中间件本身也是服务的一部分,当 Redis 不可用或超时,如果直接 panic 或返回 500,等于把稳定性风险转嫁给上游。生产环境必须有明确 fallback 策略。
最简可行方案是:Lua 调用失败时,降级到本地 rate.Limiter(来自 golang.org/x/time/rate),按保守速率(比如原配额的 10%)兜底,而不是彻底放开。
- 不要在中间件里做重试——Redis 失败就失败,重试只会放大雪崩
-
rate.Limiter初始化参数必须固定,禁止根据当前负载动态调整,否则压测结果不可复现 - 降级开关建议用配置中心(如 Nacos)控制,大促前统一打开,避免代码发布引入新变量
真正难的不是写一个能跑的限流中间件,而是让它的失败行为比不限流更可控。大促期间,你宁可少放行 10% 请求,也不能让一次 Redis 故障引发全站 500。











