滑动窗口限流必须用ZSET+Lua脚本,因INCR+EXPIRE仅为固定窗口,存在临界流量翻倍、多实例时间不同步、并发计数错乱等问题;ZSET需配合Lua原子执行ZADD/ZCOUNT/ZREMRANGEBYSCORE,score用毫秒级服务端时间,member防重复,ARGV需tonumber转换,返回值为int64,且须每次请求清理过期数据。

滑动窗口限流必须用 ZSET + Lua 脚本,不能靠 INCR + EXPIRE 模拟。后者只是固定窗口,临界点流量翻倍、多实例时间不同步、并发下计数错乱——上线后扛不住秒杀,不是调参问题,是模型错误。
为什么 INCR + EXPIRE 不是滑动窗口
它本质是“每 N 秒清零一次”,比如窗口设为 60 秒,第 59 秒进 100 个请求,第 60 秒末 key 过期,第 61 秒又进 100 个——系统认为这是两个独立窗口,但真实流量在 2 秒内已达 200 次。
- 并发下
INCR和EXPIRE可能被中断:key 永久存在(漏限)或永久丢失(误限) - 无法表达「以当前请求时间为终点、向前推 T 秒」这个动态区间
- 多实例部署时,各机器本地时间差 > 窗口粒度(如 1 秒),结果直接错乱
ZSET 必须配合 Lua 脚本原子执行三步操作
ZADD、ZCOUNT、ZREMRANGEBYSCORE 拆成多次 Redis 命令调用,高并发下必然漏判超限。必须用 Lua 在服务端一次完成:
- 清理旧数据:
ZREMRANGEBYSCORE放在ZADD之前,否则刚插入的请求可能被下一波清理掉 - score 必须是毫秒级整数,用
redis.call("TIME")获取服务端时间并拼成seconds * 1000 + microseconds / 1000,不能传time.Now().UnixMilli() - member 不能重复,建议用
fmt.Sprintf("%d_%s", now, uuid.NewString()[:8])避免同一毫秒内覆盖 - 脚本中所有
ARGV都是字符串,必须用tonumber()转型后再参与计算,否则时间戳字符串比大小会出错
Go 调用 Eval 时 KEYS 和 ARGV 容易传反
client.Eval(ctx, script, []string{key}, args) 的第二个参数是 KEYS 列表,第三个才是 ARGV;KEYS 只能传业务标识(如 "rate:ip:192.168.1.100"),不能硬编码;ARGV 按顺序传窗口左边界、当前时间戳、最大请求数、member 值。
- 返回值是
int64,不是bool,Go 侧要显式转:result.(int64) == 1 - 别用 pipeline 模拟原子性——网络延迟、重试会让中间状态不可控
- Redis 服务端时间必须开启 NTP 同步(推荐
chrony),误差控制在 100ms 内
ZSET 内存膨胀必须主动清理,不能依赖过期
每个请求都塞一个 member,QPS 1k 的服务一天就是 8600 万条,不清理会撑爆 Redis 内存。Lua 脚本里的 ZREMRANGEBYSCORE 是唯一可靠清理时机,且必须每次请求都执行。
最容易被忽略的是:ZSET 不支持 key 级自动过期,EXPIRE 对整个 key 生效,但窗口内请求数归零后 key 还在,下次请求又得重建;而真正的清理必须靠 score 范围删除——这一步只在 Lua 里做才安全。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











