不能用incr+expire实现滑动窗口,因其本质是固定窗口,存在临界流量翻倍、并发下漏限或误限、无法表达动态时间区间及多实例时间差错乱等问题;真滑动必须用zset存毫秒时间戳作score、唯一member,并通过lua脚本原子执行zremrangebyscore清理、zadd写入、zcount统计三步。

为什么不能用 INCR + EXPIRE 做滑动窗口
因为 INCR 和 EXPIRE 是两个独立命令,Redis 不保证原子性。高并发下容易出现:
- INCR 成功但 EXPIRE 失败 → key 永久存在,后续所有请求都被误限
- 多个客户端同时发现 key 不存在,各自执行 INCR → 计数从 1 重新开始,漏限
- 时间切片硬隔离 → 比如第 59 秒进 100 次,第 0 秒又进 100 次,实际 2 秒内 200 次,但被算作两个独立窗口
这种组合本质是固定窗口,不是滑动窗口。现象常为监控里 TTL 返回 -2、ERR no such key 频发、QPS 曲线突刺明显。
滑动窗口必须用 ZSET + Lua 原子封装
ZSET 能按 score(时间戳)排序,天然适合滑动窗口;但单靠 ZADD + ZREMRANGEBYSCORE + ZCOUNT 三步分开调用仍会出错——中间插入其他请求会导致刚加的条目被删掉,或统计漏掉新条目。
必须把这三步压进一个 Lua 脚本,由 Redis 单线程顺序执行:
-
ZREMRANGEBYSCORE放在ZADD前:避免刚插入就被自己清理 - 时间戳必须用
redis.call("TIME")在服务端获取:多实例 NTP 偏差 > 窗口长度(如 1 秒)时,客户端传入的time.Now().UnixMilli()会导致窗口错位 - member 推荐用唯一标识,如
"req:" .. tostring(tonumber(ARGV[3])),防止同毫秒 score 冲突覆盖
示例脚本(sliding_window.lua):
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
local key = KEYS[1]
local window_ms = tonumber(ARGV[1])
local max_req = tonumber(ARGV[2])
local redis_time = redis.call("TIME")
local now_ms = redis_time[1] * 1000 + math.floor(redis_time[2] / 1000)
local window_start = now_ms - window_ms
<p>redis.call("ZREMRANGEBYSCORE", key, 0, window_start)
redis.call("ZADD", key, now_ms, ARGV[3])
local count = redis.call("ZCOUNT", key, window_start, "+inf")</p><p>if count > max_req then
return 0
end
return 1</p>
Go 侧调用 go-redis 执行 Lua 的关键点
很多人拼字符串传 Lua,结果参数错位、返回值解析失败。核心规则是:
-
KEYS[1]对应 Go 调用时[]string{"rate:login:192.168.1.100"}的第一个元素 -
ARGV[i]对应script.Run(ctx, rdb, keys, arg1, arg2, ...)中第 i 个interface{}参数 - Go 的
int64、string会自动映射为 Lua 的number/string,但nil变成false,容易误判 - 返回值是
interface{},需显式类型断言,比如result.(int64) == 1
调用示例:
script := redis.NewScript(luaContent)
result, err := script.Run(ctx, rdb, []string{"rate:login:192.168.1.100"}, 60000, 100, "req:"+uuid.NewString()).Result()
if err != nil {
// handle
}
allowed := result.(int64) == 1
ZSET 内存不清理会撑爆 Redis
每个请求都塞一个 member,高频接口一天可能上亿条。仅靠 PEXPIREAT 没用——它只给 key 设过期,不影响内部 member 存活;必须靠 ZREMRANGEBYSCORE 主动清理。
但清理时机很关键:
- 必须在
ZADD前执行,否则刚插入就删,计数偏少 - 脚本里用
0作为 min_score 下界(而非-inf),避免误删未来时间戳(虽然罕见,但 Redis 时间回拨时可能发生) - 建议对 key 加前缀并定期扫描,比如用
SCAN+KEYS rate:*配合 TTL 监控,防止长尾 key 残留
真正麻烦的不是写对脚本,而是确认所有服务实例用的是同一套时间源、同一套键命名规则、同一套清理逻辑——漏掉任意一环,滑动窗口就退化成不可靠的“伪滑动”。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










