不能直接用incr+expire做滑动窗口,因为二者非原子操作,incr成功但expire失败会导致key永久存在,并发下易漏计数或重复计数;滑动窗口需动态维护时间范围内请求记录,必须用lua脚本封装清理、添加、统计三步并借助zset和redis.call("time")保证原子性与时间一致性。

为什么不能直接用 INCR + EXPIRE 做滑动窗口
因为这两条命令不是原子的,INCR 成功但 EXPIRE 失败时,key 会永久存在;并发场景下还可能漏计数或重复计数。Redis 6.0+ 虽支持 INCRBYEX,但它只解决“带过期的递增”,不支持按时间窗口剔除旧请求——滑动窗口需要动态维护一个时间范围内的请求记录,必须靠 Lua 脚本保证原子性。
EVAL 执行 Lua 脚本实现滑动窗口限流的核心逻辑
脚本要完成三件事:清理过期时间戳、添加当前请求时间、判断窗口内请求数是否超限。关键点在于用 ZSET 存储时间戳(score = 时间戳,member = 请求唯一标识,如 UUID 或毫秒级时间戳),利用 ZREMRANGEBYSCORE 清理过期项,再用 ZCARD 统计剩余数量。
- 脚本入参:
KEYS[1]是限流 key(如"rate:login:192.168.1.1"),ARGV[1]是窗口大小(单位:秒),ARGV[2]是最大请求数 - 当前时间必须用
redis.call("TIME")获取服务端时间,避免客户端时钟偏差 - 插入 member 时推荐用
redis.call("ZADD", KEYS[1], now, now)(用时间戳作 member),省去生成 UUID 的开销,也方便调试 - 注意
ZREMRANGEBYSCORE的边界:应使用(now - window)(左开区间),防止刚好卡在边界的时间点被误删
local key = KEYS[1]
local window = tonumber(ARGV[1])
local max = tonumber(ARGV[2])
local now = tonumber(redis.call("TIME")[1])
<p>redis.call("ZREMRANGEBYSCORE", key, 0, "(" .. (now - window))
redis.call("ZADD", key, now, now)
redis.call("EXPIRE", key, window + 1)</p><p>local count = redis.call("ZCARD", key)
if count > max then
return 0
else
return 1
end</p>
Go 客户端调用 EVAL 时的常见坑
用 github.com/go-redis/redis/v9 时,别直接拼接 Lua 脚本字符串传给 Eval 方法——脚本内容变更后 Redis 不会自动重新加载,且无法复用 SHA1 缓存。正确做法是先用 ScriptLoad 加载,后续用 EvalSha 执行。
-
ScriptLoad返回的 SHA1 值需缓存(全局变量或 sync.Once),否则每次调用都发一次SCRIPT LOAD请求,增加延迟 - 如果脚本里用了
redis.call("TIME"),就不能用EVALSHA在只读副本上执行(Redis 禁止在 replica 上调用 TIME),生产环境务必确保连接的是 master - Go 中传参要用
redis.Args{}.Add(...).Add(...)或切片,别漏掉KEYS数量(哪怕只有 1 个 key)——Eval第三个参数是[]string,第一个元素是 key,后面才是ARGV - 返回值是
int64,需显式转为bool判断:allowed, _ := ret.Val().(int64); if allowed == 1 { ... }
性能和边界情况比你想象的更敏感
ZSET 滑动窗口在 QPS 上万时,ZREMRANGEBYSCORE 和 ZCARD 的耗时会明显上升,尤其当窗口大、写入频繁时,ZSET 的底层 skiplist 可能产生较多内存碎片。这不是 Lua 脚本的问题,而是数据结构选型问题。
- 如果窗口固定(比如 60 秒),可考虑降级为“固定窗口”+
INCRBYEX(Redis 6.2+),吞吐高、延迟稳,只是精度略低 - 若必须滑动窗口且量极大,建议把窗口缩小(如 10 秒),再用多级限流(Nginx 限总并发 + Redis 限单用户)分摊压力
- 测试时别只压测单个 key,真实场景是海量不同 key(如用户 ID),要验证 Redis 的 key 数量增长和内存占用是否可控
滑动窗口的“滑”字,意味着它永远在做清理和统计两件事——只要窗口时间跨度大于 0,就不可能有纯 O(1) 的解法。Lua 是为了原子性,不是为了性能。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











