直接用incr+expire会漏限流,因二者非原子操作:请求a执行incr后被挂起,请求b完成incr和expire,a恢复后expire失败导致key永不过期;必须用lua脚本封装incr、pexpire和阈值判断以保证原子性。

直接用 Incr + Expire 组合在 Gin 中做计数器,高并发下大概率漏限流或 key 永不过期——这不是配置问题,是原子性缺失导致的必然结果。
为什么 Gin 中直接调用 Incr + Expire 会失效
看似两步操作很直白,但中间存在竞态窗口:
- 请求 A 执行
Incr得到 99,还没来得及调Expire,就被调度挂起 - 请求 B 同样执行
Incr得到 100,接着成功调用Expire - 请求 A 恢复后继续执行
Expire,但此时 key 已存在,Expire返回 false,且不会重设过期时间 - 结果:key 永不过期,后续所有请求都绕过限流
更隐蔽的问题是网络抖动或 panic 导致 Expire 根本没发出去,key 就卡在 Redis 里长期存活。
必须用 Lua 脚本封装 INCR + PEXPIRE + 阈值判断
Redis 单线程保证脚本内命令串行执行,这才是真正原子的解法。Go 里用 go-redis/v9 的 Eval 或预编译 NewScript:
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
- 脚本里用
PEXPIRE(毫秒级),比EXPIRE更稳,避免秒级精度在高并发下冲突 -
KEYS[1]是带业务前缀的 key,比如"rate:ip:192.168.1.100"或"rate:uid:abc123:api:/v1/pay" -
ARGV[1]是窗口毫秒数(如"60000"),ARGV[2]是阈值(如"100") - 返回
0表示被限流,>0 是当前计数值,别混淆redis.Nil和真实错误
示例脚本:
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("PEXPIRE", KEYS[1], tonumber(ARGV[1]))
end
if current > tonumber(ARGV[2]) then
return 0
end
return current
Gin 中注册限流中间件的关键细节
不是把脚本丢进去就完事,Gin handler 里要控制好上下文和错误分支:
- 每个请求必须用独立的
context.WithTimeout,超时设 300ms 内,别用context.Background() - 提取限流标识时,优先用
c.ClientIP(),但注意反向代理场景要配gin.SetTrustedProxies,否则拿到的是 Nginx IP - 拼 key 时加业务前缀,避免不同接口或用户共用一个计数器,例如
"rate:ip:" + ip + ":api:" + c.Request.URL.Path - 脚本调用失败(网络错误、Redis 不可用)不能直接 panic 或 500,得降级处理:记录告警 + 放行,否则故障会放大成雪崩
连接池与超时配置不达标,再好的脚本也卡死
go-redis 默认连接池太小,QPS 上千就会排队等连接,日志里只报 redis: connection pool timeout,看不出根因:
-
PoolSize至少设为 50,别盲目堆到 100+,单 Redis 实例maxclients通常 ≤5000 - 显式设置
MinIdleConns: 5,冷启动时不每请求建连,首屏延迟低 -
MaxConnAge: 30 * time.Minute强制老化长连接,防 NAT 断连后连接假活 - 初始化后立刻
rdb.Ping(ctx).Err(),别等第一个请求才暴露连不通
最易被忽略的是:脚本本身没问题,但连接池耗尽或超时未设,导致整个中间件阻塞 Gin 的 goroutine,QPS 断崖下跌,监控看到的是 runtime.NumGoroutine() 暴涨,而不是 Redis 告警。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










