不能用 INCR + EXPIRE 实现滑动窗口,因其非原子操作易导致漏限或误限,且仅为固定窗口而非真正滑动窗口;必须用 Lua 脚本原子执行清理、统计、写入三步,并依赖 Redis 服务端时间戳确保一致性。

为什么不能用 INCR + EXPIRE 实现滑动窗口
因为 INCR 和 EXPIRE 是两个独立命令,中间断连、超时或 Redis 服务重启,就会导致 key 永久存在(漏限)或永久丢失(误限)。更关键的是,它根本不是滑动窗口——只是每 N 秒硬切一次的固定窗口,临界点流量能翻倍。比如窗口设为 60 秒,第 59 秒进 100 个请求,第 60 秒末过期,第 61 秒又进 100 个,系统认为合法,实际 2 秒内已压垮下游。
ZSET 必须配合 Lua 脚本原子执行三步操作
滑动窗口的核心动作是「清理旧数据 → 统计当前数量 → 写入新请求」,这三步在高并发下一旦拆开,必然出现竞态:A 和 B 同时看到 99 条,都写入第 100 条,结果第 101 条悄悄放行。唯一解法是把它们压进一个 Lua 脚本,在 Redis 单线程里原子执行。
-
ZREMRANGEBYSCORE必须放在ZADD之前,否则刚插入的请求可能被下一波清理掉 - score 必须是毫秒级整数,不能用秒级时间戳,否则
ZREMRANGEBYSCORE范围计算失效 - member 不能重复,建议用
fmt.Sprintf("%d_%s", now, uuid.NewString()[:8])避免同一毫秒内覆盖 - Lua 脚本里所有
ARGV都是字符串,必须用tonumber()转型后再参与计算
Go 客户端调用 EvalSha 的安全写法
别每次 Eval 都传完整脚本体,网络开销大还容易超时失败;也别把 Lua 写成拼接字符串,有注入风险。正确做法是预加载并缓存 SHA1 值。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
- 初始化时用
client.ScriptLoad(luaScript).Result()获取evalSha,全局复用 - 调用时用
client.EvalSha(ctx, evalSha, []string{key}, nowStr, beforeStr, windowSecStr, limitStr) -
KEYS数组只放业务 key(如"rate:ip:192.168.1.100"),ARGV放所有动态值 - 返回值统一用 int64 判断:
result.(int64) == 1表示允许,避免 JSON 解析开销
时间戳必须来自 Redis 服务端,不能用 time.Now().UnixMilli()
多实例部署时,各机器本地时间差 > 窗口粒度(比如窗口 1 秒,A 机快 1.2 秒),B 机就会把刚进来的请求判定为「已过期」,而 A 机还在计数,QPS 实际翻倍也不自知。必须让 Lua 脚本调 redis.call("TIME") 获取服务端时间。
立即学习“go语言免费学习笔记(深入)”;
-
redis.call("TIME")返回{seconds, microseconds},拼成毫秒级时间戳:seconds * 1000 + math.floor(microseconds / 1000) - Redis 服务端必须开启 NTP 同步(推荐 chrony),误差控制在 100ms 内,用
ntpq -p检查 offset - 别在 Go 层算好时间戳再传入
ARGV——失去原子性前提
真正难的不是写对脚本,而是让所有服务节点的时间误差稳定压在窗口长度以内;只要这个没控住,再严谨的 Lua 逻辑也会在生产环境失效。

















