必须用Lua脚本原子执行三步,否则高并发下必然超限:ZREMRANGEBYSCORE清理旧数据、ZCARD获取当前数量、ZADD写入新请求,分步调用会导致竞态条件使计数错误。

必须用 Lua 脚本原子执行 ZREMRANGEBYSCORE、ZCARD、ZADD 三步,否则高并发下必然超限。
为什么单靠 ZSET 命令拼接会超限?
应用层分三步调用 Redis:ZREMRANGEBYSCORE 清旧数据 → ZCARD 查当前数量 → ZADD 写新请求。这在并发场景下是危险的:两个请求几乎同时查到 count = 99,都判断“未超限”,结果都写入,变成 101。这不是概率问题,而是确定性竞态。
即使加本地锁或 Pipeline,也解决不了分布式环境下的竞争;INCR + EXPIRE 只能做固定窗口,无法支持“任意 1 秒内”统计。
- 固定窗口在窗口切换点(如 12:00:59 和 12:01:00)可能叠加两倍流量
-
ZCOUNT不清理旧数据,ZSet 成员持续堆积,内存只增不减,最终 OOM - 不设 TTL 的 key 会长期驻留,哪怕业务已下线
Lua 脚本里三个操作缺一不可
一个可用的限流脚本必须在一个 EVAL 中完成以下三件事,且顺序不能错:
-
ZREMRANGEBYSCORE key 0 (now - window_ms):清理窗口外数据;注意边界是左闭右闭,0和score_min都要包含,否则可能漏删或误删 -
ZCARD key:获取当前窗口内请求数;不要用ZCOUNT key (now - window_ms) +inf替代,它多一次范围扫描,性能差且结果未必一致 -
ZADD key now member:仅当未超限时才写入;score必须是毫秒级时间戳(如System.currentTimeMillis()),member不能直接用时间戳——同一毫秒多个请求会导致覆盖,建议用req_ + now + _ + random(1000)
示例脚本关键片段:
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
endkey 设计和内存控制容易被忽略
key 名必须带业务维度,比如按 IP 限流就用 limit:ip:{ip},按用户 ID 就用 limit:uid:{uid},避免所有请求挤进同一个 key 导致误拒或热点。
- 每个 key 必须显式设置
EXPIRE key 3600(兜底过期时间),否则 ZSet 持久存在,哪怕窗口内无新请求 - 高频场景下(如每秒百次请求),ZSet 成员数可能远超预期:1 秒窗口 × 100 QPS × 60 秒 = 6000 成员/Key,别指望自动回收
- 如果业务允许粗粒度控制(如“每分钟最多 100 次”),直接用
HSET分桶更省资源;滑动窗口精度越高,内存和 CPU 开销越大
真正难的不是写对脚本,而是想清楚:你的业务到底需要多高的时间精度?1 秒窗口和 100 毫秒窗口的资源消耗差一个数量级,而很多场景其实根本不需要后者。


















