结论:固定窗口限流用 incr+expire 最简单稳定,但存在原子性与窗口边界问题,需lua保障原子性;滑动窗口必须用zset,因其支持按时间排序、范围删除和精确计数,list或string无法满足高qps要求。

直接说结论:用 INCR + EXPIRE 组合做固定窗口限流,最简单、最稳定、上线最快;但如果你的业务对“时间滑动精度”敏感(比如 1 分钟内严格不能超 100 次,且不允许跨窗口突增),就必须切到 ZSET 实现滑动窗口 —— 后者代码量翻倍,Lua 脚本或事务逻辑绕不开。
为什么固定窗口限流最容易踩坑?
很多人照着示例写完 INCR key 紧跟 EXPIRE key 60,就以为万事大吉。实际线上一压测,立刻暴露两个问题:
- 并发高时
INCR和EXPIRE不是原子操作:可能 key 自增了,但EXPIRE失败,导致 key 永久存在,后续所有请求都被误拒 - 窗口边界失效:用户在 00:59.999 和 01:00.001 各发 1000 次,两次都在各自窗口内“合法”,但实际 2ms 内打了 2000 次
解决第一个问题,必须用 Lua 脚本封装 INCR 和 EXPIRE;第二个问题,则无解——固定窗口算法本身就有这个缺陷,别硬扛。
滑动窗口必须用 ZSET,别试 LIST 或 STRING
ZSET 是唯一能同时满足「按时间排序」+「范围删除」+「精确计数」的数据结构。常见错误是想用 LIST 存时间戳再遍历过滤,这在 QPS > 500 时 Redis CPU 直接拉满。
- key 设计:建议用
"rate:ip:{ip}:method:{method}",避免不同维度混在一起 - score 存毫秒级时间戳(不是秒),否则
ZCOUNT会因精度丢失漏统计 - 每次请求前先执行
ZREMRANGEBYSCORE key 0 (current_timestamp - window_ms)清旧数据,再ZADD新时间戳,最后ZCARD判数量 - 注意:
ZREMRANGEBYSCORE的开区间写法(很关键,否则刚过期的那条可能被多留一轮
RateLimiter 注解方案里最容易忽略的配置点
用 AOP + 注解方式(如 @RateLimiter(time = 60, count = 100))很清爽,但三个地方不显眼却致命:
-
key参数若为空,默认拼接方法名,但没处理重载方法 —— 两个同名不同参的方法会共用一个限流计数器,得手动加类名前缀 -
time单位是秒,但底层存EXPIRE时如果传了小数(比如 0.5 秒),Redis 会截断成 0,导致 key 永不过期 - 没配置
RedisTemplate的序列化器时,INCR返回值可能被反序列化成Long包装类,和脚本里tonumber()类型不匹配,Lua 报attempt to compare number with nil
真正难的不是写通逻辑,而是确认你选的窗口类型是否匹配业务容忍度 —— 固定窗口适合后台管理类接口,滑动窗口才是支付、登录、下单等核心链路的底线要求。别等被刷垮了才想起看 ZCARD 返回值是不是总比预期少 1。











