Redis+Lua 实现滑动窗口限流:从固定窗口踩坑到生产可用的完整方案

为什么还要写限流

接口限流是每个高并发系统的基本功。我们的平台在大促期间,下单和领券接口会被瞬时流量打满,如果不做限流,要么数据库连接池耗尽,要么缓存击穿引发雪崩。这篇文章记录我们从固定窗口限流演进到 Redis+Lua 滑动窗口限流的全过程,包含 4 个真实踩坑点,代码可以直接拿去改改就用。

技术栈:Spring Boot + Redis(Cluster 模式)+ Lua,思路与语言无关,其他技术栈同样适用。

坑点1:固定窗口的临界突刺问题

最早我们用最直观的固定窗口算法:以接口+用户为 key,窗口 1 秒,用 INCR 计数,超过阈值就拒绝。

java 复制代码
Long count = redisTemplate.opsForValue().increment("rate:" + api + ":" + userId);
if (count != null && count == 1L) {
    redisTemplate.expire("rate:" + api + ":" + userId, 1, TimeUnit.SECONDS);
}
if (count != null && count > threshold) {
    throw new RateLimitException("请求过于频繁");
}

上线后压测发现问题:假设阈值是 100 次/秒,在第 0.9 秒进来 100 个请求,第 1.1 秒(新窗口)又进来 100 个请求------两个窗口各自都没超限,但 0.2 秒内实际通过了 200 个请求,后端在窗口边界被打了个猝不及防。这就是固定窗口的临界突刺。

另一个隐患是 INCR 和 EXPIRE 两条命令非原子:如果 INCR 成功后应用宕机,key 永不过期,该用户会被永久限流(我们线上真的遇到过,表现为个别用户反馈"一直提示系统繁忙",排查到这个 key 没有 TTL)。

滑动窗口原理:用 ZSET 记录请求时间戳

滑动窗口的思路是:把窗口内每个请求的时间戳记到 ZSET 中,score 和 member 都用时间戳(或时间戳+随机后缀),每次请求时:

  1. 先移除窗口外的旧记录(ZREMRANGEBYSCORE);
  2. 统计窗口内剩余元素数量(ZCARD);
  3. 未超限则把当前请求时间戳写入(ZADD),并设置 key 过期时间;
  4. 超限则拒绝。

关键在于:以上四步必须在 Redis 中原子执行,否则并发下计数不准。Redis 执行 Lua 脚本是单线程原子的,所以把逻辑写成一段 Lua。

Lua 脚本实现

lua 复制代码
-- KEYS[1] = 限流 key
-- ARGV[1] = 窗口大小(毫秒)
-- ARGV[2] = 阈值
-- ARGV[3] = 当前时间戳(毫秒)
-- ARGV[4] = 成员唯一标识(时间戳+随机数,避免同毫秒覆盖)
-- ARGV[5] = key 过期时间(秒)

local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]
local expire = tonumber(ARGV[5])

-- 1. 清除窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 2. 当前窗口内请求数
local current = redis.call('ZCARD', key)

if current >= limit then
    return 0  -- 被限流
end

-- 3. 记录本次请求
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, window)
return 1  -- 放行

Java 侧封装:

java 复制代码
private static final String LUA_SCRIPT = """
    local key = KEYS[1]
    local window = tonumber(ARGV[1])
    local limit = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    local member = ARGV[4]
    redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
    local current = redis.call('ZCARD', key)
    if current >= limit then
        return 0
    end
    redis.call('ZADD', key, now, member)
    redis.call('PEXPIRE', key, window)
    return 1
    """;

private final DefaultRedisScript<Long> rateScript;

{
    rateScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
}

public boolean allow(String api, Long userId, int windowMs, int limit) {
    String key = "rate:slide:" + api + ":" + (userId == null ? "anon" : userId);
    long now = System.currentTimeMillis();
    // member 必须唯一:同毫秒并发请求不能互相覆盖
    String member = now + "-" + ThreadLocalRandom.current().nextLong(100000);
    Long result = redisTemplate.execute(
            rateScript,
            Collections.singletonList(key),
            String.valueOf(windowMs),
            String.valueOf(limit),
            String.valueOf(now),
            member,
            String.valueOf(windowMs / 1000 + 1)
    );
    return result != null && result == 1L;
}

坑点2:member 用纯时间戳导致同毫秒覆盖

初版脚本里 ZADD key now now,score 和 member 都是时间戳。压测时发现 QPS 一高,计数明显偏小------同一毫秒内的多个请求 member 相同,ZADD 变成更新 score 而不是新增元素,ZCARD 永远小于真实请求数,限流被悄悄绕过。member 必须保证唯一,加上随机后缀或 UUID 解决。

坑点3:Redis Cluster 下的 KEYS 问题

我们生产是 Redis Cluster。Lua 脚本里如果操作多个 key,这些 key 必须落在同一个 slot,否则直接报错 CROSSSLOT Keys in request don't hash to the same slot。

我们的限流脚本只操作单个 key,天然没问题;但如果要做"全局总限流 + 单用户限流"两层组合,就会涉及两个 key。解决方案是用 hash tag 强制同 slot:

java 复制代码
// 两层限流 key 用相同 hash tag
String hashTag = "{rate:" + api + "}";
String globalKey = hashTag + ":global";
String userKey = hashTag + ":u" + userId;

另外脚本里不要用 redis.call('TIME') 取时间------Cluster 主从复制时,主节点和副本节点时间不一致会导致脚本非确定性,Redis 直接拒绝执行。时间戳由应用服务器传入(ARGV3),注意多台应用服务器要做 NTP 时钟同步,时钟漂移超过窗口大小会让限流失效。

坑点4:ZSET 内存膨胀与冷 key 清理

ZSET 里每个请求一个成员,高 QPS 接口的 key 会很大。虽然脚本里有 PEXPIRE 让 key 在窗口结束后过期,但要注意两点:

  1. PEXPIRE 要在每次请求时刷新(脚本里已做),否则持续有流量的 key 没问题,突发流量结束后遗留的 key 如果漏设过期就会永久驻留;
  2. 被限流(返回 0)的分支我们不刷新过期也不写入,这是刻意的------拒绝请求不应该延长 key 生命周期,也不应该往 ZSET 里塞数据,否则恶意刷接口反而会撑大内存。

如果限流维度非常多(比如按 IP 粒度),可以评估改用 Redis Cell 模块(CL.THROTTLE 命令,基于令牌桶,Redis 4.0+ 可装),内存开销更小,但需要运维侧安装模块,我们当前用原生 ZSET 方案够用。

与令牌桶、漏桶的选型对比

算法 burst 突发流量 内存开销 实现复杂度 适用场景
固定窗口 不支持(临界突刺) 极低 最简单 粗粒度防护,不推荐生产
滑动窗口(本文) 平滑限制,窗口内严格 中(ZSET) 中 接口级精确限流
令牌桶 支持突发(桶可攒) 低 中 允许一定突发的开放 API
漏桶 不支持突发,恒定速率 低 中高 对下游保护严格的场景

我们的实践是:网关层用令牌桶(Spring Cloud Gateway 自带 RequestRateLimiter)做粗粒度全局限流,业务层下单、领券等核心接口用滑动窗口做按用户/按接口的精确限流,两层配合。

上线前的验证清单

  1. 脚本先用 redis-cli --eval 在测试环境验证返回值,注意 redis.call 出错会直接抛异常,redis.pcall 才会返回错误对象;
  2. Java 侧 DefaultRedisScript 默认有 SHA1 缓存,首次执行会 EVAL,之后走 EVALSHA,Redis 主从切换/故障转移后缓存失效会报 NOSCRIPT,Spring Data Redis 会自动 fallback 到 EVAL,不用自己处理,但要知道这个日志是正常现象;
  3. 限流拒绝要返回明确的 HTTP 状态码(我们用 429)和友好提示,前端按状态码做退避重试,避免用户疯狂点击;
  4. 限流阈值要可配置(放配置中心),大促前临时调大/调小不用发版。

滑动窗口限流本身不复杂,真正的坑都在并发原子性、集群约束和内存细节上。希望这套方案和踩坑记录能帮你少走弯路。

相关推荐
IT大白鼠5 天前
Redis 系列 · 第 01 篇——认知入门:Redis 是什么
redis·nosql
彧azz5 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
lbb 小魔仙5 天前
OpenClaw + cpolar 实战:远程 NAS、分享小游戏、RDP,再配置公网 AI 入口
数据库·人工智能·redis·oracle·prometheus
夕除5 天前
redis--018
redis
两锭子5 天前
Redis基础
redis
烟沙九洲5 天前
Redis 缓存穿透、缓存击穿、缓存雪崩
数据库·redis
字节探索5 天前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
于樱花森上飞舞5 天前
【Redis】哨兵详解
java·开发语言·数据库·redis
Rain的Java大神之路6 天前
如何快速上传10G文件
java·spring boot·redis·后端·mysql·spring cloud·面试
Wang's Blog6 天前
Java 接入Redis: 通用命令与键管理
java·服务器·redis