滑动窗口限流不能只用INCR+EXPIRE,因其只能实现固定窗口,导致窗口切换时流量突增;需用ZSET存时间戳配合Lua脚本实现原子性滑动统计。

滑动窗口限流为什么不能只用 INCR + EXPIRE
因为单个 key 无法表达“过去 N 秒内”的动态时间切片。用 INCR 配合固定过期时间,实际是「固定窗口」——比如每分钟重置一次,会导致流量在窗口切换时突增 2 倍。滑动窗口要求:每个请求都要检查「以它为终点、向前推 T 秒」这个区间内的请求数。Redis 原生命令不直接支持这种查询,必须靠数据结构配合客户端逻辑协同完成。
用 ZSET 存时间戳是最稳妥的选择
ZSET 天然支持按 score(即时间戳)范围查询和清理,适合表达「带时间维度的请求记录」。每个请求存一个唯一 member(如请求 ID 或毫秒级时间戳+随机后缀),score 设为当前 Unix 毫秒值。关键操作有三个:
-
ZREMRANGEBYSCORE key -inf (current_timestamp - window_ms):剔除窗口外旧请求 -
ZCARD key:获取当前窗口内请求数 -
ZADD key timestamp member:添加新请求(注意用XX不加NX,避免覆盖 score)
注意:score 必须用毫秒级整数,否则 ZREMRANGEBYSCORE 范围计算会出错;member 不能重复,否则同一时间戳的多次请求会被覆盖——建议拼接 fmt.Sprintf("%d_%s", time.Now().UnixMilli(), uuid.NewString()[:8])。
Go 客户端要自己封装原子性,别依赖单条命令
Redis 没有内置「先删旧、再查数量、再加新、再判断是否超限」的原子指令,所以必须用 Lua 脚本或 Pipeline。推荐 Lua,因为一次网络往返就能完成全部逻辑,且 Redis 保证脚本执行期间的原子性。示例脚本片段:
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
立即学习“go语言免费学习笔记(深入)”;
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local max_count = tonumber(ARGV[3])
local member = ARGV[4]
<p>redis.call("ZREMRANGEBYSCORE", key, "-inf", "(" .. (now - window_ms))
local count = redis.call("ZCARD", key)
if count < max_count then
redis.call("ZADD", key, now, member)
redis.call("PEXPIRE", key, window_ms + 1000) -- 留点余量防 key 漏删
return 1
else
return 0
endGo 中调用时注意:client.Eval(ctx, script, []string{key}, time.Now().UnixMilli(), windowMS, maxCount, member);返回 1 表示放行,0 表示拒绝。
漏桶和令牌桶更适合匀速限流,滑动窗口更适合突发容忍
如果你的业务允许短时脉冲(比如用户连续点击 5 次提交按钮,但希望 1 秒内最多 3 次),滑动窗口比漏桶更贴合语义。但它有代价:每次请求都触发一次 ZSET 的范围清理和插入,QPS 上万时 ZREMRANGEBYSCORE 可能成为瓶颈。实测中,当窗口内平均请求数超过 500,延迟开始明显上升。这时要么缩小窗口(如从 60 秒改为 10 秒),要么改用分片 ZSET(如按 user_id % 16 分 16 个 key),但分片会让统计精度下降——这是权衡点,不是 bug。

















