必须用Lua脚本原子封装INCR和PEXPIRE,因分开调用存在竞态窗口:两请求同时INCR后仅一者设过期,或Expire失败导致key永不过期,造成漏限流甚至彻底失效。

直接用 INCR + EXPIRE 会漏限流,必须用 Lua 脚本原子封装。
为什么 redis.Incr() + redis.Expire() 不安全
这两个操作分开调用不是原子的,中间存在竞态窗口。典型现象是:两个请求几乎同时到达,都执行了 Incr 得到 99 和 100,但只有第一个成功设置了过期时间;第二个发现 key 已存在,跳过 Expire,又没做二次阈值检查,直接放行第 101 次请求。
更危险的是:如果 Expire 因网络抖动失败,key 永不过期,后续所有请求全被放行——这不是漏限流,是彻底失效。
- 别信“先
SETNX再Incr”,它依然不是原子组合 - Redis 的
INCR本身是原子的,但判断、设置过期、比较阈值这三步必须串在一起 - 必须用
PEXPIRE(毫秒级),避免秒级时间戳在高并发下冲突
go-redis/v9 中预编译 Lua 脚本的正确写法
每次调用都新建 redis.NewScript 会重复解析 Lua 字符串,有性能开销。应在包初始化或服务启动时预编译一次:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
var rateLimitScript = redis.NewScript(<pre class="brush:php;toolbar:false;">`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]:带业务前缀的 key,如"rate:ip:192.168.1.100"或"rate:uid:abc123:api:/v1/pay" -
ARGV[1]:窗口毫秒数,如"60000"(1 分钟) -
ARGV[2]:阈值,如"100" - 返回值是
int64:> 0表示放行(当前计数),0表示拒绝
固定窗口 vs 滑动窗口:选哪个取决于你的容忍点
固定窗口实现最简,但有边界问题:用户在第 59 秒发起 100 次请求,第 1 秒又发起 100 次,实际 2 秒内 200 次,却全被放行。
滑动窗口能解决这个问题,但代价是复杂度上升:
- 要用
ZSET存每个请求时间戳,score必须用time.Now().UnixMilli() - 每次请求前得用
zremrangebyscore清理过期项,再zcard统计当前窗口请求数 - 这两步也得用 Lua 原子封装,否则清理和统计之间可能插入新请求
滑动窗口真正落地时,容易忽略 zremrangebyscore 的范围边界(开闭区间)、ZSET key 的命名粒度、以及 Lua 脚本里 redis.call("ZCARD", ...) 返回 nil 的处理逻辑——这些细节一旦出错,就退化成固定窗口甚至完全失效。

















