直接用 incr+expire 会失效,因为二者非原子操作,中间存在竞态窗口:a 请求刚 incr 完未执行 expire 就中断,b 请求进来发现 key 存在却无过期时间,导致 key 永久存在、全放行;且主从时钟不一致会使 expire 精度漂移,实际窗口偏离预期。

为什么直接用 INCR + EXPIRE 在 Gin 中会失效
因为 INCR 和 EXPIRE 是两个独立命令,中间存在竞态窗口:Gin 中间件并发处理请求时,可能 A 请求刚 INCR 完,还没来得及 EXPIRE 就崩溃或网络中断;B 请求紧接着进来,发现 key 已存在(但没过期时间),直接 INCR 后又跳过 EXPIRE ——结果 key 永久存在,后续所有请求全放行。
更隐蔽的问题是:Redis 主从时钟不一致时,EXPIRE 的秒级精度会导致窗口漂移,实际限流窗口比预期长或短。
go-redis/v9 调用 Lua 脚本的正确姿势
别每次请求都拼 Lua 字符串或新建 redis.NewScript,应在 init() 或服务启动时预编译一次:
var rateLimitScript = redis.NewScript(`local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0
end
return current`)
-
KEYS[1]必须传非空切片,例如[]string{"rate:ip:" + c.ClientIP()} -
ARGV[1]是窗口毫秒数(如"60000"),必须字符串传入,Lua 里用tonumber()转换 -
ARGV[2]是阈值(如"100"),别硬编码成数字,避免类型转换失败 - 返回值是
int64,0表示拒绝,>0 表示当前计数(可用于日志记录)
Gin 中间件里构造限流 key 的关键点
key 决定限流粒度,错配就等于没限。常见错误是只用 c.ClientIP(),但真实场景需要组合维度:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
- 接口级限流:
"rate:api:" + c.Request.Method + ":" + c.FullPath() - 用户+接口组合:
"rate:uid:" + userID + ":path:" + c.FullPath()(需提前从 token 解析 userID) - 防爆破登录:
"rate:login:ip:" + c.ClientIP() + ":user:" + username - 绝对不要用固定 key 如
"rate:global",否则全站请求挤一个桶
注意:Gin 的 c.FullPath() 包含路由参数(如 /user/:id → /user/123),若想按路径模板限流,得用 c.HandlerName() 或正则提取路由模式。
滑动窗口限流为何比固定窗口更可靠
固定窗口(如每分钟重置)在窗口边界容易突刺:大量请求集中在上一分钟末和下一分钟初,两波叠加远超阈值。滑动窗口用 ZSET 存时间戳,每次请求都清理 ZREMRANGEBYSCORE 过期项再 ZCOUNT,精度更高但开销略大。
- 脚本里必须用客户端传入的毫秒时间戳(
time.Now().UnixMilli()),不能依赖 Redis 的TIME命令——主从时钟差会导致窗口错乱 -
EXPIRE时间要设为窗口长度 + 1 秒,防止 key 提前被 Redis 自动删除 - 高频场景下,ZSET 的
ZCARD可能成为瓶颈,建议窗口粒度不低于 100ms
真正难的不是写脚本,而是 key 设计是否覆盖业务意图、时间戳是否跨节点一致、以及拒绝后是否返回明确状态码(如 429 Too Many Requests)和 Retry-After 头——这些细节漏掉,前端重试逻辑就会失控。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










