接口限流是高并发系统的第一道防线。假设你的订单接口每秒最多允许100次访问,一旦瞬时流量超过系统承载能力,数据库连接池被打满、服务雪崩就是分分钟的事。常见的限流算法有固定窗口、滑动窗口、漏桶、令牌桶几种,其中滑动窗口因为实现简单、精度够用,配合Redis可以轻松支撑分布式环境,成为很多团队的首选方案。这篇文章就来把Redis实现滑动窗口限流的原理和代码完整讲清楚。

为什么固定窗口有限流临界问题
先看最朴素的固定窗口算法:把时间按固定长度切分,比如每1秒一个窗口,用INCR命令给当前窗口计数,超过阈值就拒绝请求。实现上只需要一个key,每个窗口对应一个计数器,可以用EXPIRE设置过期时间自动清理。
这个方案的问题在于窗口边界。假设限流阈值是每秒100次,如果用户在第0.9秒到1.0秒之间打了99次请求,又在1.0秒到1.1秒之间打了99次请求,两个窗口各自都没超限,但实际上在0.9秒到1.1秒这200毫秒内,系统承受了198次请求。这就是所谓的临界突刺问题,流量在窗口切换的瞬间可能出现双倍峰值,对于脆弱的下游服务来说这可能就是压垮骆驼的最后一根稻草。
滑动窗口的思路就是解决这个问题的:不再把窗口看作一个固定的时间段,而是让窗口的起点跟着当前时间走。任意时刻往回看1秒,这1秒内的请求数量才是判断依据。这样无论流量怎么分布,任意连续1秒内的请求数都不会超过阈值,突刺问题自然就消失了。
滑动窗口的两种实现思路
第一种是滑动窗口日志法。用Redis的有序集合(Sorted Set)记录每个请求的时间戳,成员可以是唯一ID或时间戳加随机数,分值就是请求发生的毫秒时间。判断限流时,先删掉窗口之外的老数据,再统计集合中剩余的成员数量,数量超过阈值就拒绝,否则把当前请求加入集合并放行。这种方式精度最高,精确到每一个请求,但存储量和请求量成正比,高QPS场景下内存占用需要留意。
第二种是滑动窗口计数法,也就是把大窗口切成多个小格子。比如1秒的窗口切成10个100毫秒的格子,每个格子维护一个计数器,判断时把最近10个格子的计数加起来和阈值比较。这种方式内存占用固定,但精度受格子粒度限制,本质上是对滑动窗口的近似。Sentinel等框架内部就是采用的这种格子思想。下面主要讲解基于有序集合的日志法实现,它更直观也更常用。
用Redis有序集合实现滑动窗口限流
核心数据结构选ZSET,分值存储请求的毫秒时间戳,成员需要保证唯一性,否则同一毫秒内的多个请求会互相覆盖导致计数偏小。常见做法是用时间戳加随机数,或者用请求的唯一标识。整理一下完整的判断流程:
- 记录当前时间戳
now,计算窗口起点now - windowSize - 用
ZREMRANGEBYSCORE移除分值小于窗口起点的所有旧记录 - 用
ZCARD统计集合内剩余的请求数量 - 如果数量大于等于阈值,返回拒绝;否则用
ZADD添加当前请求,并用PEXPIRE给key设置一个略大于窗口的过期时间防止冷key堆积
这几步操作必须保证原子性。如果在应用层分多条命令执行,两个并发请求可能同时读到相同的计数,双双通过校验,导致限流形同虚设。解决办法就是把全部逻辑塞进Lua脚本,Redis执行Lua脚本是原子的,执行期间不会有其他命令插队。脚本如下:
-- KEYS[1]: 限流key
-- ARGV[1]: 窗口大小(毫秒)
-- ARGV[2]: 最大请求数
-- ARGV[3]: 当前时间戳(毫秒)
-- ARGV[4]: 唯一成员ID
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]
-- 1. 移除窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 2. 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < limit then
-- 3. 未超限,记录本次请求
redis.call('ZADD', key, now, member)
-- 4. 设置key过期时间,略大于窗口即可
redis.call('PEXPIRE', key, window)
return 1
else
return 0
end
Java侧借助Jedis或RedisTemplate调用这段脚本,示例代码如下:
public boolean tryAcquire(String key, int limit, long windowMillis) {
String now = String.valueOf(System.currentTimeMillis());
String member = now + "-" + UUID.randomUUID().toString();
Object result = redisTemplate.execute(rateLimitScript,
Collections.singletonList(key),
String.valueOf(windowMillis),
String.valueOf(limit),
now,
member);
return "1".equals(String.valueOf(result));
}
调用方拿到tryAcquire的返回值决定放行还是拒绝,被限流的请求可以返回429状态码或者抛出业务异常。注意Lua脚本中的时间戳建议由客户端传入而不是在脚本里调用TIME命令,因为带随机性质的命令在某些主从场景下会导致脚本无法正确复制,虽然新版本Redis已经用效果复制机制解决了这个问题,但显式传参依然是最稳妥的写法。
和其他限流算法的对比与选型建议
漏桶算法以恒定速率放行请求,超出桶容量的直接丢弃,特点是整流效果好,能保证下游以平稳速率被访问,但无法应对正常的短时突发流量。令牌桶则相反,令牌以固定速率生成放入桶中,请求拿到令牌才放行,桶里攒下的令牌允许一定程度的突发,Guava的RateLimiter就是经典实现。滑动窗口介于两者之间,它限制的是任意时间段内的总量,逻辑清晰,语义和“每秒最多N次”这类需求直接对应。
选型上,如果需求是保护脆弱的下游服务,希望流量尽可能平滑,漏桶更合适;如果允许一定突发又要控制平均速率,令牌桶更好;如果只是简单地控制接口调用频次,比如防刷、API配额控制,滑动窗口是最直观的方案。另外要提醒一点,窗口大小的设置要结合业务,比如验证码接口通常按1分钟限制5次,订单查询可能按1秒限制50次,窗口粒度和阈值需要压测验证后再定。
分布式部署时还有几个细节值得注意:所有服务节点必须连接同一个Redis实例或集群,时钟要尽量同步,因为窗口判断依赖时间戳;Redis挂掉时的降级策略要提前想好,可以选择本地限流兜底或者直接放行并记录日志告警;对于超高QPS的接口,ZSET的写入压力和网络开销不可忽视,此时可以考虑换成格子计数法或者直接在网关层做限流。理解了这些原理和取舍,你就能根据自己的业务场景搭建出一套可靠的分布式限流体系。
Redis限流滑动窗口Redis Lua脚本修改时间:2026-09-08 14:41:11