直接用 redis.incr 会漏统计,因为自增虽原子,但后续判断是否超限和设置过期时间非原子组合,中间可能插入其他请求导致超限未拦截;必须用 lua 脚本将 incr、expire、阈值判断三步串行执行以保证原子性。

为什么直接用 redis.Incr 会漏统计并发请求
多个请求几乎同时到达时,redis.Incr 自增操作本身是原子的,但后续的判断(比如“是否超过阈值”)和设置过期时间(redis.Expire)不是原子组合。中间可能插入其他请求,导致同一窗口内计数超限却未被拦截。
真正安全的做法是把「自增 + 判断 + 设置过期」三步压进一个 Lua 脚本执行。Redis 保证脚本内命令串行执行,无竞态。
示例 Lua 脚本逻辑:
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0
end
return current
调用时传入 KEYS[1](如 "rate:ip:192.168.1.100")、ARGV[1](窗口秒数,如 "60")、ARGV[2](最大次数,如 "100")。
Go 中如何封装成可复用的限流器结构体
不要每次手写 redis.Eval 调用。定义一个 RateLimiter 结构体,持有 *redis.Client 和预编译的 *redis.Script:
关键点:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
-
redis.NewScript必须在初始化阶段调用一次,复用脚本对象,避免重复解析开销 - key 拼接要带业务前缀(如
"rate:api:" + userID),避免不同限流策略冲突 - 返回值为
int64:> 0 表示放行(值为当前计数),0 表示拒绝 - 错误需区分
redis.Nil(脚本执行成功但返回 nil?不可能)和网络类错误,后者应记录告警而非直接拒绝
如何适配 Gin 框架做中间件拦截
在 Gin 的 HandlerFunc 中提取限流标识(IP、用户 ID、API 路径等),拼出 Redis key,再调用限流器方法:
常见陷阱:
- 用
c.ClientIP()获取 IP 时,若服务前有 Nginx,必须配置proxy_set_header X-Real-IP $remote_addr;并改用c.GetHeader("X-Real-IP"),否则全是127.0.0.1 - 对登录用户限流,别在中间件里查 DB 拿
userID——这会拖慢所有请求。应在认证中间件后把userID写入c.Set("user_id", uid),此处直接取 - 响应时建议返回标准 HTTP 429,并加
X-RateLimit-Remaining头(需额外一次redis.Get查询,权衡是否必要)
滑动窗口 vs 固定窗口:为什么这里推荐固定窗口 + Lua
滑动窗口(如用 ZSET 存时间戳)精度高,但每次请求都要 ZREMRANGEBYSCORE + ZCARD,性能开销大,且 Lua 脚本更难写对;而固定窗口实现简单、性能稳定,适合绝大多数接口保护场景。
真实瓶颈往往不在 Redis,而在 Go 应用层处理请求的耗时。如果业务逻辑本身要花 300ms,那 ±1s 的窗口边界误差完全可接受。
只有当你的业务要求「每分钟最多 60 次,且任意连续 60 秒都不能超」时,才值得上滑动窗口——此时务必用 ZSET + Lua,且注意 ZREMRANGEBYSCORE 的 O(log N) 成本随请求数增长。
Redis 原生不支持滑动窗口原子指令,别指望靠几个单独命令凑出来。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










