想象这样一个场景:活动上线当晚,同一个手机号 1 秒内收到 20 条短信验证码,短信费哗哗地烧——一查,发短信的接口压根没做限流,别说恶意刷,用户手滑连点都能把服务打挂。
这就是没做限流的代价。这篇把限流从简单的计数器到 Redis 令牌桶 + Lua 的演进梳理一遍,真要做的时候,心里先有张图。
这篇按演进顺序讲透限流:计数器 → 滑动窗口 → 令牌桶/漏桶 → Redis Lua 落地。每种算法讲清原理、缺陷和代码,最后给选型表。代码按 Spring Boot 落地,Lua 脚本实跑验证过。
一、先想清楚:限流限的是谁
限流不是"防黑客",是保护自己:让系统在流量超过承载能力时,优雅地拒绝多余请求,而不是被压垮。先分清三种角色:
| 角色 | 例子 | 限流目标 |
|---|---|---|
| 防刷 | 短信接口被脚本狂刷 | 限制"同一个用户/手机号"的频率 |
| 防爆 | 秒杀瞬间千万请求 | 限制"全局"的总吞吐 |
| 防雪崩 | 下游服务变慢 | 限制对下游的调用速率 |
二、方案 v1:固定窗口计数器(最简单,但有临界突刺)
// 思路:每分钟一个 key,count 累加,超过阈值拒绝
public boolean tryAcquire(String key, int limit) {
String counterKey = "rate:" + key + ":" +
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
Long count = redisTemplate.opsForValue().increment(counterKey);
if (count == 1) {
redisTemplate.expire(counterKey, Duration.ofMinutes(1)); // 每分钟重置
}
return count <= limit;
}缺陷:临界突刺。窗口边界处会出现"双倍流量":
分钟 1 的最后 10 秒:999 个请求(累计 999)
分钟 2 的最初 10 秒:999 个请求(重新计数)
→ 20 秒内放过了 1998 个请求,限流形同虚设三、方案 v2:滑动窗口(把窗口切成小格,消除突刺)
固定窗口的根因是"整点重置"——把窗口切成多个小格,每次判断用最近一个完整窗口的请求总数,而不是当前格子:
// 滑动窗口:用 zset 记录请求时间戳,统计最近 1 分钟内的请求数
public boolean tryAcquire(String key, int limit) {
long now = System.currentTimeMillis();
String windowKey = "rate:sliding:" + key;
// 移除窗口外的旧时间戳
redisTemplate.opsForZSet().removeRangeByScore(windowKey, 0, now - 60_000);
Long count = redisTemplate.opsForZSet().zCard(windowKey);
if (count >= limit) {
return false; // 窗口内已满,拒绝
}
redisTemplate.opsForZSet().add(windowKey, String.valueOf(now), now);
redisTemplate.expire(windowKey, Duration.ofSeconds(120));
return true;
}特点:
- ✅ 无临界突刺(窗口是连续滑动的)
- ⚠️ zset 每个请求一个成员,请求量大时内存和清理开销上升
四、方案 v3:令牌桶 / 漏桶(把"限速"变成"匀速")
计数器系限流的问题是"要么放行要么拒绝",没有速率概念。令牌桶引入"匀速补充令牌"的机制:
令牌桶算法:
- 桶里最多放 N 个令牌(burst 容量)
- 每 1/r 秒补一个令牌(r = 允许的速率)
- 请求来了取一个令牌:取到放行,取不到拒绝(或排队)漏桶算法:
- 请求进桶,桶按固定速率"漏"出去处理
- 桶满则新请求被丢弃 → 输出永远匀速| 对比点 | 令牌桶 | 漏桶 |
|---|---|---|
| 突发流量 | ✅ 允许(桶里攒的令牌可一次性用掉) | ❌ 不允许(输出恒速) |
| 平滑度 | 允许抖动 | 绝对平滑 |
| 典型用途 | 接口限流(允许短时突发) | 流量整形(保护下游) |
五、方案 v4:Redis Lua 令牌桶落地(原子 + 零竞态)
Java 里写令牌桶逻辑有并发竞态(多个线程同时取令牌)。把整个"补令牌 + 取令牌"逻辑写进 Lua 脚本,Redis 单线程执行,天然原子:
-- lua/rate_limiter.lua
-- KEYS[1]: 令牌桶 key
-- ARGV[1]: 桶容量 capacity
-- ARGV[2]: 补充速率 refillRate(每秒补充多少个)
-- ARGV[3]: 本次请求的令牌数(通常 1)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local tokens = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
local currentTokens = tonumber(bucket[1] or capacity)
local lastRefill = tonumber(bucket[2] or 0)
-- 按时间补充令牌(补充量 = 经过时间 × 速率)
local now = redis.call('TIME')[1]
local elapsed = math.max(now - lastRefill, 0)
currentTokens = math.min(capacity, currentTokens + elapsed * refillRate)
if currentTokens >= tokens then
currentTokens = currentTokens - tokens
redis.call('HMSET', key, 'tokens', currentTokens, 'lastRefill', now)
return 1 -- 放行
else
redis.call('HMSET', key, 'tokens', currentTokens, 'lastRefill', now)
return 0 -- 拒绝
end// Java 侧调用:DefaultRedisScript 加载 Lua
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaText, Long.class);
public boolean tryAcquire(String key, int capacity, double refillRate) {
Long result = redisTemplate.execute(script,
List.of("rate:bucket:" + key),
String.valueOf(capacity), String.valueOf(refillRate), "1");
return result != null && result == 1L;
}为什么必须用 Lua:取令牌是"读-改-写"三步,Java 里拆开做就有竞态(两个线程同时读到 tokens=1,都判定放行);Lua 脚本在 Redis 里一次性原子执行,杜绝竞态。
六、落地对比:算法选型表
| 算法 | 精度 | 内存/性能 | 实现 | 适用 |
|---|---|---|---|---|
| 固定窗口 | 低(临界突刺) | 极低 | 极简 | 粗粒度防刷 |
| 滑动窗口 | 中 | 中(zset) | 简单 | 一般接口限流 |
| 令牌桶(Lua) | 高(允许突发) | 低 | 中 | 大多数接口限流 |
| 漏桶 | 高(绝对平滑) | 低 | 中 | 保护下游/整形 |
| Sentinel | - | - | 接入框架 | 生产级(熔断降级一起管) |
生产落地建议:先接 Sentinel(规则可视化配置、熔断降级一站式),热点接口再叠加 Redis Lua 令牌桶做分布式限流。别自己造轮子造到一半又要熔断——限流、熔断、降级是三个配套能力,Sentinel 一起给你了。
秒杀场景里限流怎么和整个架构配合,看《秒杀系统架构》;限流用的 Redis 基础设施见《Redis 快速入门》。
验证记录:文中 Redis Lua 令牌桶脚本已实跑验证——临时 Redis 容器加载脚本,容量 3、速率 1/s 时:连续 3 次请求返回 1(放行)、第 4 次返回 0(拒绝);等待 1 秒后请求再次返回 1(令牌补充),符合令牌桶语义。固定窗口计数、滑动窗口 zset 逻辑已按 Redis 标准命令验证。
