必须用Lua脚本原子执行ZREMRANGEBYSCORE、ZCARD和ZADD,否则并发下清理与计数非原子会导致漏判超限或阈值突破;ZSET因毫秒时间戳支持精确滑动,而INCR+EXPIRE仅能实现固定窗口。

滑动窗口为什么不能只用 ZSET 的 ZREMRANGEBYSCORE 清旧数据
因为 Redis 的 ZSET 本身不保证原子性删除 + 计数,如果先 ZREMRANGEBYSCORE 再 ZCARD,中间可能有新请求插入,导致漏判超限;更糟的是,若并发高,多个协程可能同时看到“未超限”而一并写入,彻底突破阈值。
必须把“清理过期成员 + 判断当前数量 + 添加新成员”三步压进一个 Lua 脚本里执行:
- 用
redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, ARGV[1])清理早于窗口左边界的时间戳 - 再用
redis.call("ZCARD", KEYS[1])获取当前有效请求数 - 如果
count ,才 <code>redis.call("ZADD", KEYS[1], ARGV[2], ARGV[2])(用时间戳作 score 和 member,简化去重) - 最后返回
count 的布尔结果(Lua 中 1/0)
Go 客户端调用 Lua 脚本的关键参数顺序和类型
Redis 的 EVAL 命令要求:脚本内容、键数量、键列表、参数列表。Go 用 redis.Client.Eval() 时容易传反 KEYS 和 ARGV:
-
KEYS[1]必须是业务唯一标识,比如"rate:login:192.168.1.100",不能写成硬编码的固定 key -
ARGV[1]是窗口左边界时间戳(time.Now().UnixMilli() - windowMs) -
ARGV[2]是当前请求时间戳(time.Now().UnixMilli()),同时作为 score 和 member - 注意:Lua 中所有
ARGV都是字符串,要用tonumber()转数字再比较,否则时间戳字符串比大小会出错
示例片段:
立即学习“go语言免费学习笔记(深入)”;
script := `local count = redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, tonumber(ARGV[1]))
count = redis.call("ZCARD", KEYS[1])
if count < tonumber(ARGV[3]) then
redis.call("ZADD", KEYS[1], ARGV[2], ARGV[2])
return 1
else
return 0
end`
// 注意:ARGV[3] 是 limit,提前传入避免在 Lua 里硬编码
result, err := client.Eval(ctx, script, []string{key}, strconv.FormatInt(leftBound, 10), strconv.FormatInt(now, 10), strconv.Itoa(limit)).Result()
为什么不用 INCR + EXPIRE 组合实现滑动窗口
因为 INCR 只能做固定窗口(如每秒清零),无法表达“过去 60 秒内最多 100 次”这种连续时间切片。强行用多个 key 模拟滑动(比如按秒分桶)会导致 key 数爆炸、内存占用高、且跨桶统计需额外读取 N 个 key,网络往返和原子性都难保障。
ZSET 方案虽有少量 CPU 开销(ZREMRANGEBYSCORE 是 O(log N + M),M 是删除数量),但胜在单 key、单 round-trip、语义准确。真正瓶颈通常不在 Redis,而在 Go 侧频繁构造 key 或没复用 redis.Client 连接池。
- 务必为每个限流维度生成稳定 key,例如
fmt.Sprintf("rate:%s:%s", action, identifier) - 设置合理的
redis.Options.PoolSize(默认 10 太小,建议 50–100) - 避免在限流逻辑里做耗时操作(如查 DB、HTTP 调用),失败应快速 fallback(如允许通过或返回 429)
测试时容易忽略的边界:系统时钟回拨和 Redis 时间精度
如果服务器发生 NTP 校正导致时间回拨,ZSET 里存的毫秒时间戳可能“变老”,造成大量本该过期的请求被误保留,窗口计数虚高。Redis 本身没有本地时钟,完全依赖客户端传入的时间戳。
- 生产环境务必开启
chrony或ntpd的平滑校正模式(-x参数),禁用跳变式同步 - 可加一层保护:在 Lua 脚本中检查
ARGV[2]是否明显小于ARGV[1](比如差 5 秒以上),直接拒绝该请求 - 不要依赖
TIME命令获取 Redis 服务端时间——它和客户端不同步,且无毫秒精度
滑动窗口的“滑”字,本质是靠时间戳的单调递增性来维持的,这个前提一旦松动,整个逻辑就不可信。


















