必须用Lua脚本原子执行ZADD、ZREMRANGEBYSCORE、ZCARD三步操作,否则高并发下因竞态条件必然超限;需合理设计key、传入毫秒级时间戳、显式设置EXPIRE。

直接上结论:用 redis.ZAdd + redis.ZRemRangeByScore + redis.ZCard 三步组合,在 Lua 脚本里原子执行,是当前最稳的滑动窗口限流方案。别信 pipeline 模拟,也别手写多次 round-trip 请求——并发下计数必错。
为什么必须用 Lua 脚本保证原子性
滑动窗口的核心动作是“加一个时间戳、删旧数据、查当前总数”,这三步在高并发下一旦拆开,就可能出现 A 看到 99 个请求放行,B 同时也看到 99 个请求放行,结果第 101 个请求悄悄溜进去了。Redis 单线程执行 Lua 是唯一能堵住这个漏洞的方式。
-
ZADD插入当前毫秒级时间戳作为 score,请求 ID 或空字符串作为 member -
ZREMRANGEBYSCORE清理所有score 的旧记录 -
ZCARD获取剩余元素数量,和 limit 比较 - 整个脚本用
client.Eval或ScriptLoad+EvalSha执行,避免每次传输脚本体
Go 代码里怎么安全调用这个 Lua 脚本
别把 Lua 写在字符串里拼接,容易被注入;也别每次 Eval 都传完整脚本——网络开销大,还可能因超时失败。正确做法是预加载脚本并缓存 SHA1 值。
- 初始化时用
client.ScriptLoad(luaScript).Result()获取evalSha,全局复用 - 调用时用
client.EvalSha(ctx, evalSha, []string{key}, nowStr, beforeStr, windowSecStr, limitStr) - 注意参数顺序:KEYS 数组只放 key(防误删),ARGV 放所有动态值(时间、阈值等)
- 返回值统一用整型:
1表示允许,2表示拒绝,避免 JSON 解析开销
连接池和超时设置不当,限流器自己先挂了
限流逻辑本身不能成为瓶颈。见过太多团队把 PoolSize 设成默认 10,结果 Redis 连接排队,HTTP 请求卡在限流判断上,比下游还慢。
立即学习“go语言免费学习笔记(深入)”;
-
PoolSize: 50是生产环境起步值,QPS 超 5k 时建议调到 100+ -
Timeout: 100ms必须设,超时直接返回 false 或 fallback 到本地限流,绝不阻塞主流程 - 如果 Redis 不可用,立刻降级到
golang.org/x/time/rate.Limiter,按 IP 或 UID 维度兜底,别裸奔 - 别忘了给 key 加
EXPIRE,否则冷用户长期占着 ZSet 内存;但 expire 要和 ZAdd 同一 pipeline 或 Lua 里一起发,不然有竞态
粒度选错,限流等于没限
同一个 key 用在所有接口上?那 /login 和 /pay 共享阈值,攻击者刷登录接口就能把支付接口一起干掉。限流 key 必须带业务上下文。
- 用户维度:
fmt.Sprintf("rate:user:%s:%s", userID, endpoint) - IP+路径维度:
fmt.Sprintf("rate:ip:%s:%s", c.ClientIP(), c.Request.URL.Path) - 资源维度(如商品 ID):
fmt.Sprintf("rate:item:%s:buy", itemID) - 避免用纯时间戳或随机字符串做 key,会导致无法聚合统计和 debug
最容易被忽略的是 fallback 机制——Redis 挂了不是“限流失效”,而是“切换策略”。你得提前想好:本地限流用令牌桶还是滑动窗口?降级响应码是 429 还是 503?这些不写进代码,出事时只能干瞪眼。


















