滑动窗口限流不能只用INCR+EXPIRE,因其无法实现任意1秒内精确计数;ZSet通过时间戳score+ZCOUNT/ZREMRANGEBYSCORE支持毫秒级滑动统计,需用Lua脚本保证原子性。

滑动窗口限流为什么不能只用 INCR + EXPIRE
因为固定窗口(比如每秒重置)会导致临界流量突增,而真正滑动窗口需要“任意 1 秒内”的请求数统计——INCR 配合单 key 过期做不到时间维度的滑动。ZSet 的核心优势是:每个请求能以时间戳为 score 记录,再用 ZCOUNT 或 ZREMRANGEBYSCORE 动态截取最近 N 秒的数据。
ZADD + ZCOUNT 实现毫秒级滑动计数
关键不是存请求,而是存「带时间戳的唯一标识」,比如 user:123:request,score 设为毫秒时间戳。每次请求执行三步:
- 用
ZADD写入当前毫秒时间戳(ZADD user:123:request <code>$(date +%s%3N)req_abc) - 用
ZCOUNT查过去 1000ms 内数量:ZCOUNT user:123:request (current_ms-1000) +inf - 用
ZREMRANGEBYSCORE清理过期项:ZREMRANGEBYSCORE user:123:request 0 (current_ms-1000)
注意:ZCOUNT 的区间是左闭右闭,所以要用 (current_ms-1000) 表示「严格大于」该时间点,避免把边界请求重复计入两次窗口。
为什么推荐用 EVAL 封装成 Lua 脚本
网络往返和多命令并发会导致竞态:比如两个请求几乎同时查到 99 次,都允许通过,结果变成 101 次。Lua 脚本能保证原子性。一个典型脚本逻辑是:
local key = KEYS[1]
local window_ms = tonumber(ARGV[1])
local max_count = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local score_min = now - window_ms
redis.call('ZREMRANGEBYSCORE', key, 0, score_min)
local count = redis.call('ZCARD', key)
if count < max_count then
redis.call('ZADD', key, now, ARGV[4])
return 1
else
return 0
end调用时传入 key、窗口毫秒数、最大次数、当前毫秒时间戳、唯一请求 ID;返回 1 表示放行,0 表示拒绝。别漏传 ARGV[4],否则 ZADD 会失败且不报错。
实际部署时 ZSet 内存增长比预想快得多
高频请求下,每个用户每秒可能写入几十个 score,ZSet 不会自动压缩,哪怕只保留最近 1 秒数据,也可能积压数万成员。必须配合 TTL 使用:EXPIRE key 3600 给 key 设个兜底过期时间,否则内存只增不减。另外,如果业务允许粗粒度限流(如按分钟),直接用 HSET + EXPIRE 分桶更省资源——滑动窗口的精度越高,代价越大。

















