因为单机内存令牌桶无法共享状态,多实例部署下各节点独立计数导致限流失效;redis限流必须用lua脚本原子执行读、判、扣、设过期全流程,避免incr+expire竞态引发超发或key永久残留。

为什么不用内存版令牌桶做生产限流
因为单机内存计数器在多实例部署下完全失效。你本地测试 TokenBucketLimiter 能跑通,上线后流量打到不同机器,每个实例各自维护一套桶,等于没限。真实场景中,哪怕只是两台负载均衡后的 Gin 服务,限流阈值就会翻倍——用户刷个接口,轻松绕过限制。
常见错误现象:429 Too Many Requests 偶发不触发、压测时限流形同虚设、日志里看到同一 IP 在不同节点反复通过。
- 只适合开发联调或单实例离线工具类服务
- 无法共享状态,不满足分布式一致性要求
- 重启即清空,不具备持久化兜底能力
Redis 实现令牌桶必须用 EVAL 原子脚本
直接用 INCR + EXPIRE 组合有竞态:两次 Redis 请求之间可能被其他客户端插入,导致超发令牌。gin-vue-admin 的 SetLimitWithTime 就踩了这个坑——它用 TxPipeline 包装,但 pipeline 不保证原子性,只是批量发送。
正确做法是把“读当前令牌数、判断是否够用、扣减/填充、设置过期”全部塞进 Lua 脚本,由 Redis 单线程执行:
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local rate = tonumber(ARGV[4])
local tokens = redis.call("GET", key)
if not tokens then
redis.call("SET", key, capacity, "EX", 60)
return 1
end
tokens = tonumber(tokens)
if tokens > 0 then
redis.call("DECR", key)
return 1
else
return 0
end
- 别信“pipeline 很快所以差不多”的说法——高并发下毫秒级窗口就足够出问题
-
EVAL脚本里不能依赖 Redis 服务端时间(如TIME),必须由客户端传入now时间戳校准 - 桶容量和填充速率要按业务峰值反推,比如每秒 50 次请求,建议桶容量设为 100,避免突发打满
IP 粒度限流不够用,得支持多维 key 构造
纯靠 c.ClientIP() 做 key,在 NAT 环境或 CDN 后面会误伤大量用户。更危险的是,攻击者只要换一个出口 IP 就能绕过——实际攻防中,这几乎是默认操作。
你应该组合多个维度生成限流 key:
- 优先取
X-Real-IP或X-Forwarded-For头(需 Nginx 透传并校验可信代理) - 对登录用户追加
user_id(从 JWT 或 session 解析),实现“每人每小时 100 次” - 对敏感接口加入
path或method:PATH,避免 /login 和 /pay 共用同一桶 - 必要时加设备指纹(如 UA + IP 哈希),但注意 GDPR 合规风险
示例 key:rate:uid_12345:POST:/api/v1/withdraw,比 rate:192.168.1.1 精准且可控得多。
漏桶 vs 令牌桶:别被原理图带偏,看实际响应曲线
文档里画的“漏桶恒速流出”是理想模型。真实 Redis 实现中,漏桶用 go.uber.org/ratelimit 是基于 client-side sleep 模拟的,服务端根本没做任何控制——它只是告诉客户端“你该等多久”,而客户端完全可以忽略这个提示继续发包。
真正起作用的只有服务端拦截型方案,而令牌桶天然适配这种模式:每次请求来都尝试 DECR,成功才放行,失败直接 429。漏桶若想真落地,得配合 Redis List + BRPOP 定时消费,复杂度陡增,还引入延迟。
- 选令牌桶不是因为它“高级”,而是它在 Redis 场景下更容易写出正确、低延迟的原子逻辑
- 所谓“容忍突发”,本质是桶容量给了缓冲空间;这个空间大小比算法名字重要得多
- 如果你发现限流后仍有请求穿透,先检查 key 是否重复、脚本是否真被执行、Redis 连接是否复用错实例











