限流的四种算法:固定窗口、滑动窗口、漏桶、令牌桶
限流 1 秒 100 个请求,却在 0.2 秒内放行了 200 个——固定窗口的临界突刺是怎么发生的?这篇从四种算法的原理讲到 Redis + Lua 的分布式实现,把「为什么网关都用令牌桶」这件事说透。
一、一个限流没生效的场景
线上配了一条规则:某接口每秒最多放行 100 个请求。压测跑下来,监控上每个 1 秒窗口的计数都不超过 100,规则确实生效了。
然后服务挂了。
看日志才发现问题:第 0.9 秒涌进来 100 个请求,全部放行;第 1.1 秒又涌进来 100 个请求,也全部放行。两个窗口各自都没超限,但在 0.2 秒的实际时间跨度里,系统承受了 200 个请求——瞬时压力翻倍。
这就是临界突刺。它不是配置错误,是固定窗口算法本身的缺陷。理解这件事,是理解后面三种算法的起点。
二、四种算法
1. 固定窗口计数器(Fixed Window)
原理:把时间轴按固定长度切分(比如 1 秒一个窗口),每个窗口维护一个计数器。请求到达时计数器 +1,超过阈值就拒绝,窗口结束后计数器清零。
窗口 1 (0.0s - 1.0s) 窗口 2 (1.0s - 2.0s)
count = 100 → 满了 count = 100 → 满了
↑ 第 0.9s 打进来 100 个 ↑ 第 1.1s 又打进来 100 个
└──────── 0.2 秒内实际放行 200 个 ────────┘
优点:实现最简单,单次判断是 O(1),内存占用极低(每个窗口存一个整数)。
致命缺陷:临界突刺。突刺的严重程度和窗口长度正相关——窗口越长,临界点两侧能挤进来的流量越多。1 秒窗口最坏情况是 2 倍,1 分钟窗口最坏情况还是 2 倍,但绝对量可能从 200 变成 20000。
适用场景:对精度要求不高的非核心业务,或者内部系统里纯粹的「防脚本刷接口」。
2. 滑动窗口(Sliding Window)
原理:把一个大窗口细分成多个小格子(时间片),窗口随着时间推移向前滑动,统计当前覆盖范围内所有小格子的请求总量。
时间轴: [0.0-0.2) [0.2-0.4) [0.4-0.6) [0.6-0.8) [0.8-1.0) [1.0-1.2)
■ ■ ■ ■ ■
└────────── 当前滑动窗口(长度 1s)──────────┘
↓ 时间推进 0.2s
■ ■ ■ ■ ■
└────────── 窗口整体右移一格 ──────────┘
具体有两种实现路线:
| 实现 | 机制 | 精度 | 内存 |
|---|---|---|---|
| 滑动窗口日志 | 记录每个请求的精确时间戳,统计时剔除过期记录 | 最高(精确到单个请求) | 大,请求数越多越占内存 |
| 滑动窗口计数器 | 只维护相邻两个窗口的计数,加权插值估算 | 较高(有估算误差) | 小,固定为两个整数 |
滑动窗口日志的 Redis 实现很直观,用 ZSet 存时间戳:
-- KEYS[1] = 限流 key ARGV[1] = 当前时间戳(毫秒) ARGV[2] = 窗口大小 ARGV[3] = 阈值
-- 1. 移除窗口外的历史请求
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2])
-- 2. 统计窗口内请求数
local count = redis.call('ZCARD', KEYS[1])
if count < tonumber(ARGV[3]) then
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1] .. '-' .. math.random())
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1 -- 放行
end
return 0 -- 拒绝
滑动窗口计数器则是出于性能考虑做的近似。它不记录每个请求,只保留当前窗口和上一个窗口的计数,用一个权重估算:
估算值 = 上一窗口计数 × 权重 + 当前窗口计数
权重 = (1 - 当前窗口已过去的时间比例)
例:上一窗口 100 个,当前窗口过了 40%(权重 0.6),当前窗口计数 30
估算值 = 100 × 0.6 + 30 = 90 → 未超过阈值 100,放行
这个估算的前提是「请求在窗口内均匀分布」。请求越集中在窗口尾部,估算越保守;越集中在头部,估算越激进。但相比固定窗口的临界突刺,这个误差完全可以接受——Sentinel 的底层就是滑动窗口计数器。
优点:解决了临界突刺,限流更平滑。
缺点:实现比固定窗口复杂,高并发下内存或计算开销更大(日志版尤其明显;计数版则引入了估算误差)。
适用场景:API 网关、金融交易这类需要精确控制流量的场景。
3. 漏桶算法(Leaky Bucket)
原理:请求以任意速率流入一个固定容量的桶,桶底以恒定速率漏水(处理请求),桶满时新来的请求被丢弃。
请求以任意速率涌入 ↓ ↓↓ ↓↓↓↓ ↓
┌─────────────┐
│ 桶容量 N │ ← 满了就溢出丢弃
└──────┬──────┘
↓ 恒定速率流出(如 100 req/s)
后端服务
核心特点:强制输出速率恒定,无论外界流量多大、多突发,后端看到的请求速率永远不变。这就是流量整形。
优点:输出绝对平稳,下游永远不会被突发流量冲击。
缺点:不允许突发。即使后端此刻很空闲、处理能力有富余,请求也只能按固定速率排队等待,资源利用率低。而且排队会带来延迟——桶里积压的请求要等前面处理完才能出去。
适用场景:保护处理能力固定的稀缺资源。数据库连接池、GPU 推理服务、第三方计费 API 这类下游,处理能力基本是恒定的,给它们平稳的输入比给它们峰值输入更重要。
4. 令牌桶算法(Token Bucket)
原理:系统以恒定速率往一个容量固定的桶里放令牌。请求到达时先尝试取一个令牌,取到就放行,取不到就拒绝或等待。桶满了之后,新生成的令牌被丢弃。
恒定速率生成令牌 → ● ● ● ●
┌─────────────┐
│ 桶容量 N │ ← 满了丢弃新令牌
└──────┬──────┘
请求到达 → 有令牌? → 取走并放行
无令牌? → 拒绝 / 等待
和漏桶的关键区别在于「谁在等」:漏桶是请求在桶里排队等被处理,令牌桶是令牌在桶里等着被请求取走。
核心特点:允许突发流量。桶里积攒的令牌可以被一波请求一次性取走,所以只要桶够大,突发的 200 个请求可以在瞬间全部放行。
两个关键参数:
| 参数 | 作用 | 调大的后果 |
|---|---|---|
rate(填充速率) |
决定长期平均 QPS | 长期放行量变大,失去限流意义 |
capacity(桶容量) |
决定能承接的最大突发量 | 瞬时流量可能冲垮下游 |
优点:在限制长期平均速率的同时允许短期突发,这是最贴近真实业务流量的模型——真实流量从来不是均匀的。
缺点:参数需要调优。capacity 设得太大,比如 rate 100 而 capacity 10000,意味着空转十秒后可以一次性放行 10000 个请求,和没限流差不多。
适用场景:秒杀、微服务网关、普通业务接口——几乎所有需要兼顾突发和平均速率的场景。这也是它成为事实标准的原因。
四种算法横向对比
| 算法 | 是否允许突发 | 输出是否平滑 | 实现复杂度 | 内存开销 | 典型场景 |
|---|---|---|---|---|---|
| 固定窗口 | ❌(但临界点会意外突刺) | 窗口边界处不平滑 | 极低 | 极低 | 内部系统防刷 |
| 滑动窗口 | ❌(精确控制) | 平滑 | 中 | 中~高 | 网关、金融 |
| 漏桶 | ❌(强制拉平) | 最平滑 | 中 | 低 | 数据库、GPU、计费 API |
| 令牌桶 | ✅(可控突发) | 平滑 | 中 | 低 | 秒杀、网关、通用接口 |
一句话区分漏桶和令牌桶:
漏桶:出去的速度固定,不让突发进来。令牌桶:可以攒令牌,允许一波突发。
三、Sentinel 是怎么用的
Sentinel 把限流抽象成「资源 + 规则」。资源是你要保护的逻辑,规则描述怎么保护。
// 1. 定义资源:把要保护的业务逻辑包起来
try (Entry entry = SphU.entry("createOrder")) {
// 被保护的业务逻辑
return orderService.create(req);
} catch (BlockException e) {
// 被限流/降级的处理:返回兜底数据或友好提示
throw new BizException("当前排队人数过多,请稍后再试");
}
规则可以通过代码加载,也可以接 Nacos 配置中心实现动态下发——改配置不用重启,这是生产环境必须的:
FlowRule rule = new FlowRule();
rule.setResource("createOrder");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS); // QPS 模式
rule.setCount(100); // 阈值 100
// 流控效果:快速失败 / 预热 Warm Up / 匀速排队 Rate Limiter
rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT);
rule.setLimitApp("default"); // 默认对所有来源生效
FlowRuleManager.loadRules(Collections.singletonList(rule));
Sentinel 的流控效果有三种,对应不同的业务语义:
| 效果 | 行为 | 对应哪种算法思路 |
|---|---|---|
| 快速失败(Fail Fast) | 超阈值直接抛 BlockException,不排队 |
滑动窗口计数 |
| 预热(Warm Up) | 冷启动时阈值从 count/3 逐步升到 count |
令牌桶的变体 |
| 匀速排队(Rate Limiter) | 请求排队,按固定间隔放行,超时则拒绝 | 漏桶 |
匀速排队就是漏桶思想,适合处理间隔性突发流量;预热适合冷启动场景,比如缓存刚清空、数据库连接池刚建立时,系统承受不了满负荷,让阈值缓慢爬升能避免刚启动就被打崩。
Sentinel 还有一个容易被忽略的能力:它可以同时按 QPS 和线程数限流。
rule.setGrade(RuleConstant.FLOW_GRADE_THREAD); // 按并发线程数限流
QPS 限流防的是「请求太多」,线程数限流防的是「请求太慢」。一个接口如果因为下游变慢导致每个请求要跑 5 秒,即使 QPS 只有 20,也会占满 100 个线程。这种场景下 QPS 限流完全不起作用,必须按线程数限。
四、分布式限流:为什么必须用 Lua
单机限流用 Guava 的 RateLimiter 就够了。但服务部署了 10 个实例、每个实例限流 100 QPS,总量就是 1000 QPS——单机阈值 × 实例数才等于真实阈值,而且实例数一变就要改配置,扩容之后限流自动失效。
分布式限流必须把计数器收敛到 Redis。但直接读写 Redis 会踩一个坑:
// ❌ 两条命令之间有时间窗口,并发下会超额放行
Long count = redis.opsForValue().increment("limit:api:order");
if (count == 1) redis.expire("limit:api:order", 1);
if (count > 100) return "限流";
INCR 和 EXPIRE 是两条命令,中间可能宕机导致 key 永不过期(计数器只增不减,接口永久被限流);判断和自增也不是原子的,10 个实例并发时可能同时读到 99 然后各自 +1,实际放行超阈值。
正解是把整个「取值 + 判断 + 更新」打包成一段 Lua 脚本,交给 Redis 单线程一次性执行:
-- 固定窗口分布式限流
-- KEYS[1] = 限流 key(如 limit:order:userId)
-- ARGV[1] = 窗口大小(秒) ARGV[2] = 阈值
local current = redis.call('INCR', KEYS[1])
if tonumber(current) == 1 then
-- 第一次设置,绑定过期时间,保证 key 一定会被清理
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if tonumber(current) > tonumber(ARGV[2]) then
return 0 -- 拒绝
end
return 1 -- 放行
Java 侧调用:
private static final DefaultRedisScript<Long> LIMIT_SCRIPT =
new DefaultRedisScript<>(SCRIPT_TEXT, Long.class);
public boolean tryAcquire(String key, int threshold, int windowSeconds) {
Long result = redisTemplate.execute(
LIMIT_SCRIPT,
Collections.singletonList(key),
String.valueOf(windowSeconds),
String.valueOf(threshold));
return result != null && result == 1L;
}
有几点必须注意:
- 过期时间只能设一次。放在
current == 1的分支里,避免每次都刷新 TTL——否则热点 key 的窗口会被无限延长,计数器永远不清零,变成一把永久的锁。 - Lua 脚本里不要用
redis.call('TIME')之外的非确定性命令。Redis 5 之前,带随机写入的脚本没法安全复制到从库,会报错。 - key 的设计决定了限流维度。
limit:api:order:userId是按用户限流,limit:api:order是接口总限流,limit:api:order:ip是按来源 IP 限流。多个维度可以叠加,但 key 数量会线性增长,要评估 Redis 的内存和热 key 风险。
固定窗口的 Lua 版本已经能解决原子性问题,但仍然带着临界突刺。要彻底平滑,把上面滑动窗口日志那段 ZSet 脚本换上即可——代价是每个请求都要在 ZSet 里存一条记录,大流量下内存占用会明显上升。
限流之后怎么处理
被限流之后直接抛异常返回给用户,体验很差。常见的处理顺序:
- 重试其他实例:如果是客户端侧的负载均衡,被限流后换个实例重试,可能就成功了
- 排队等待:漏桶/匀速排队模式下,让请求等一会儿再处理(设置最大等待超时)
- 返回兜底:返回缓存数据、默认值,或者「当前排队人数过多」的友好提示
- 降级到备用链路:核心接口降级到简化逻辑,保证主流程能走通
第 3、4 条就是熔断降级那一层的事了。
五、小结
- 固定窗口最简单,但有临界突刺:窗口边界两侧各放行一批,短时间内的实际流量可以是阈值的 2 倍
- 滑动窗口解决了突刺;
滑动窗口日志精度最高但内存大,滑动窗口计数器用加权估算换取低开销,Sentinel 用的后者 - 漏桶强制输出恒定,做流量整形,代价是不允许突发、资源利用率低
- 令牌桶允许突发:桶里攒下的令牌能被一波取走;
rate决定长期均值,capacity决定突发上限 - 选型口诀:要平滑用漏桶,要突发用令牌桶,要精确用滑动窗口,图省事用固定窗口
- 分布式限流必须把判断逻辑打包成 Lua 脚本,单条命令原子不等于整段逻辑原子
- 限流的 key 维度(userId / IP / 接口)决定了限流的实际效果,阈值要按实例数折算
限流是保护系统的第一道闸门,但它只做「拒绝」。真正让系统在依赖故障时还能提供部分服务,靠的是下一层的熔断与降级。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。