滑动窗口限流必须用zset+lua脚本实现,因incr+expire仅为固定窗口,存在临界流量翻倍、并发不一致、时间差错乱等问题;zset需配合lua原子执行清理、统计、写入,并用服务端时间戳、毫秒score、唯一member及严格参数顺序确保正确性。

滑动窗口限流不能靠 INCR + EXPIRE 实现,因为那是固定窗口,临界点流量会翻倍;真滑动必须用 ZSET + Lua 脚本原子执行清理、统计、写入三步。
为什么 INCR + EXPIRE 不是滑动窗口
它本质是“每 N 秒清零一次”,比如窗口设为 60 秒,第 59 秒进 100 个请求,第 60 秒末过期,第 61 秒又进 100 个——实际 2 秒内 200 次,但系统认为两个窗口互不干扰。这不是滑动,是硬切。
- 并发下
INCR和EXPIRE中断会导致 key 永久存在(漏限)或永久丢失(误限) - 无法表达「以当前请求时间为终点、向前推 T 秒」这个动态区间
- 多实例部署时,各机器本地时间差 > 窗口粒度(如 1 秒),结果直接错乱
ZSET 存时间戳必须配合 Lua 原子脚本
单条 ZADD 或 ZCOUNT 都不能保证一致性:ZSET 是有序集合,但清理旧数据(ZREMRANGEBYSCORE)、查当前数量(ZCARD 或 ZCOUNT)、写新请求(ZADD)这三步必须在 Redis 服务端一次执行完,否则高并发下会漏判超限甚至突破阈值。
- 脚本里必须用
redis.call("TIME")获取服务端时间戳,拼成毫秒级整数:seconds * 1000 + microseconds / 1000,不能传time.Now().UnixMilli()进去 -
score必须是毫秒级整数,否则ZREMRANGEBYSCORE范围计算失效 -
member不能重复,建议用fmt.Sprintf("%d_%s", now, uuid.NewString()[:8])避免同一毫秒内多次请求被覆盖 - 清理操作
ZREMRANGEBYSCORE必须放在ZADD之前,否则刚插入的请求可能被下一波清理掉
Go 调用 Eval 时 KEY 和 ARGV 容易传反
client.Eval(ctx, script, []string{key}, args...) 的第二个参数是 KEYS 列表,第三个才是 ARGV;KEYS 只能传业务标识(如 "rate:ip:192.168.1.100"),不能硬编码;ARGV 按顺序传窗口左边界、当前时间戳、最大请求数、member 值。
- Lua 脚本中所有
ARGV都是字符串,必须用tonumber()转型后再参与计算,否则时间戳字符串比大小会出错 - 返回值是
int64,不是bool,Go 侧要显式转:result.(int64) == 1 - 别用
pipeline模拟原子性——网络延迟、重试会让中间状态不可控 - Redis 服务端时间必须开启 NTP 同步(推荐 chrony),误差控制在 100ms 内
ZSET 内存膨胀必须主动清理
每个请求都塞一个 member,QPS 1k 的服务一天就是 8600 万条,不清理会撑爆 Redis 内存。Lua 脚本里的 ZREMRANGEBYSCORE 是唯一可靠清理时机,且必须每次请求都执行。
- 不要依赖
EXPIRE或PEXPIRE自动过期——ZSET 的过期是 key 级,不是 member 级,旧数据仍占内存 - 高频场景下可考虑加
PEXPIREAT设定 key 的绝对过期时间(比PEXPIRE更准),但核心仍是靠ZREMRANGEBYSCORE清理 - 线上需监控
ZCARD结果和memory usage,避免水位悄悄爬升
真正难的不是写对 Lua 脚本,而是让所有实例信任同一个时间源、确保 member 不重复、以及把清理逻辑死死绑在每次请求的原子路径里——漏掉任意一环,滑动窗口就退化成固定窗口,或者更糟,变成不可预测的漏斗。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











