滑动窗口限流必须用Lua脚本原子执行ZREMRANGEBYSCORE清理、ZCARD统计、ZADD插入三步,因INCR+EXPIRE非原子易致漏限、误限及永久key残留;ZSET是唯一合理选择,支持毫秒级时间戳score区间操作,确保动态时间窗口精度。

滑动窗口限流不能靠 INCR + EXPIRE 拼凑,否则必然出现漏限、误限、数据永久残留等问题;必须用 Lua 脚本在 Redis 服务端原子执行「清理过期时间戳 → 统计当前数量 → 插入新时间戳」三步。
为什么 ZSET 是滑动窗口的唯一合理选择
滑动窗口的本质是「任意时间点往前推 N 秒内请求数 ≤ 阈值」,这要求能按时间范围精确筛选和计数。只有 ZSET 支持基于 score(毫秒时间戳)做区间操作:
-
ZREMRANGEBYSCORE key 0 (now_ms - window_ms)可一次性剔除所有过期时间戳 -
ZCARD key直接返回当前窗口内有效请求数,不依赖客户端计数 -
ZADD key now_ms now_ms插入新请求,member复用score节省内存,避免 UUID 或随机字符串
用 String + INCR 只能实现固定窗口,且无法区分「第 59 秒的请求」和「第 1 秒的请求」;用 LIST 则需客户端拉取全部元素再过滤,网络和 CPU 开销大,且无法原子化。
Lua 脚本必须传入服务端时间戳,不能用 Redis 的 TIME 命令
脚本里 now_ms 必须由 Java 侧调用 System.currentTimeMillis() 传入,而不是在 Lua 中执行 redis.call('TIME'):
-
TIME在 Redis 集群中各节点返回值可能不同(时钟漂移),导致窗口边界错乱 - 客户端传入时间虽有网络延迟,但误差在毫秒级,远小于典型窗口(如 60 秒),可接受
- 脚本中所有时间比较都基于同一基准,避免跨节点时间不一致引发的统计偏差
示例关键行:local now_ms = tonumber(ARGV[3]),对应 Java 调用时第三个参数填 System.currentTimeMillis()。
Spring Boot 中必须预加载 Lua 脚本 SHA1,禁用每次传字符串
别用 StringRedisTemplate.eval() 直接传脚本源码,会触发 Redis 重复编译、SHA1 计算和网络传输开销,压测时 QPS 直降 30%+:
- 启动时用
RedisScript加载脚本并缓存其 SHA1 值 - 运行时统一调用
redisTemplate.execute(RedisScript, keys, args...) -
KEYS[1]必须带业务维度前缀,比如"rate_limit:uid:" + userId,防止不同用户共用 key 导致误限
错误示范:template.eval("return redis.call('ZCARD', KEYS[1])", ...);正确做法是把完整脚本存在 sliding_window.lua 文件中,用 ResourceLoader 读取后注册为 DefaultRedisScript Bean。
容易被忽略的三个生产细节
这三个点线上最容易出问题,但文档几乎不提:
- Redis 连接池要调大:限流是高频小请求,
lettuce.pool.max-active至少设为 16,否则连接争抢导致限流判断超时失败 - ZSET 不清空会持续膨胀:脚本里
ZREMRANGEBYSCORE后没做ZCARD == 0判断,key 会长期存在;建议加一句if redis.call('ZCARD', key) == 0 then redis.call('DEL', key) end - 降级开关必须有:Redis 故障时不能直接放行,应 fallback 到本地 Guava
RateLimiter(按实例维度保底),避免雪崩











