Skip to content

想象这样一个场景:活动上线当晚,同一个手机号 1 秒内收到 20 条短信验证码,短信费哗哗地烧——一查,发短信的接口压根没做限流,别说恶意刷,用户手滑连点都能把服务打挂。

这就是没做限流的代价。这篇把限流从简单的计数器到 Redis 令牌桶 + Lua 的演进梳理一遍,真要做的时候,心里先有张图。

这篇按演进顺序讲透限流:计数器 → 滑动窗口 → 令牌桶/漏桶 → Redis Lua 落地。每种算法讲清原理、缺陷和代码,最后给选型表。代码按 Spring Boot 落地,Lua 脚本实跑验证过。


一、先想清楚:限流限的是谁

限流不是"防黑客",是保护自己:让系统在流量超过承载能力时,优雅地拒绝多余请求,而不是被压垮。先分清三种角色:

角色例子限流目标
防刷短信接口被脚本狂刷限制"同一个用户/手机号"的频率
防爆秒杀瞬间千万请求限制"全局"的总吞吐
防雪崩下游服务变慢限制对下游的调用速率

二、方案 v1:固定窗口计数器(最简单,但有临界突刺)

java
// 思路:每分钟一个 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:滑动窗口(把窗口切成小格,消除突刺)

固定窗口的根因是"整点重置"——把窗口切成多个小格,每次判断用最近一个完整窗口的请求总数,而不是当前格子:

java
// 滑动窗口:用 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
-- 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
// 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 标准命令验证。