为什么还要写限流
接口限流是每个高并发系统的基本功。我们的平台在大促期间,下单和领券接口会被瞬时流量打满,如果不做限流,要么数据库连接池耗尽,要么缓存击穿引发雪崩。这篇文章记录我们从固定窗口限流演进到 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 都用时间戳(或时间戳+随机后缀),每次请求时:
- 先移除窗口外的旧记录(
ZREMRANGEBYSCORE); - 统计窗口内剩余元素数量(
ZCARD); - 未超限则把当前请求时间戳写入(
ZADD),并设置 key 过期时间; - 超限则拒绝。
关键在于:以上四步必须在 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 在窗口结束后过期,但要注意两点:
PEXPIRE要在每次请求时刷新(脚本里已做),否则持续有流量的 key 没问题,突发流量结束后遗留的 key 如果漏设过期就会永久驻留;- 被限流(返回 0)的分支我们不刷新过期也不写入,这是刻意的------拒绝请求不应该延长 key 生命周期,也不应该往 ZSET 里塞数据,否则恶意刷接口反而会撑大内存。
如果限流维度非常多(比如按 IP 粒度),可以评估改用 Redis Cell 模块(CL.THROTTLE 命令,基于令牌桶,Redis 4.0+ 可装),内存开销更小,但需要运维侧安装模块,我们当前用原生 ZSET 方案够用。
与令牌桶、漏桶的选型对比
| 算法 | burst 突发流量 | 内存开销 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 固定窗口 | 不支持(临界突刺) | 极低 | 最简单 | 粗粒度防护,不推荐生产 |
| 滑动窗口(本文) | 平滑限制,窗口内严格 | 中(ZSET) | 中 | 接口级精确限流 |
| 令牌桶 | 支持突发(桶可攒) | 低 | 中 | 允许一定突发的开放 API |
| 漏桶 | 不支持突发,恒定速率 | 低 | 中高 | 对下游保护严格的场景 |
我们的实践是:网关层用令牌桶(Spring Cloud Gateway 自带 RequestRateLimiter)做粗粒度全局限流,业务层下单、领券等核心接口用滑动窗口做按用户/按接口的精确限流,两层配合。
上线前的验证清单
- 脚本先用
redis-cli --eval在测试环境验证返回值,注意redis.call出错会直接抛异常,redis.pcall才会返回错误对象; - Java 侧
DefaultRedisScript默认有 SHA1 缓存,首次执行会EVAL,之后走EVALSHA,Redis 主从切换/故障转移后缓存失效会报 NOSCRIPT,Spring Data Redis 会自动 fallback 到 EVAL,不用自己处理,但要知道这个日志是正常现象; - 限流拒绝要返回明确的 HTTP 状态码(我们用 429)和友好提示,前端按状态码做退避重试,避免用户疯狂点击;
- 限流阈值要可配置(放配置中心),大促前临时调大/调小不用发版。
滑动窗口限流本身不复杂,真正的坑都在并发原子性、集群约束和内存细节上。希望这套方案和踩坑记录能帮你少走弯路。