我印象特别深的一次线上事故,是某个大促活动刚开始那几分钟,一个稳定跑了快两年的订单接口,突然被外部脚本几波狂扫,QPS从平常的几百直接冲到两万多。数据库连接池先被打满,紧接着下游库存服务开始连环超时,不到五分钟整条下单链路全挂了。事后复盘,代码里的超时、重试、缓存其实都做了,缺的恰恰是入口层一道限流。那时候我才真正意识到,限流不是一个“限制用户”的工具,而是系统在流量失控时保命的东西。
这篇我会把限流最经典的4种算法——固定窗口、滑动窗口、漏桶、令牌桶——从原理到Java实现完整过一遍,每种算法附上可运行的代码,并且重点说清楚生产环境里容易踩的坑。无论你是在准备Java面试,还是正在给核心服务做高可用改造,这篇都应该对你有实际帮助。
1. 服务雪崩的传导路径:限流到底在护哪个环节
1.1 雪崩不是瞬间发生的,是一层一层被击穿的
很多人以为雪崩是“服务器突然崩溃”,实际上它更像一个多米诺骨牌过程。你的服务A平时扛1000 QPS没问题,突然来了5000 QPS,单机线程池开始排队。线程池排满之后,新的请求进不来,只能等超时。这时候如果服务A要调用服务B,B的响应时间也在变慢,A的这些线程就会一直占着不释放。线程越积越多,CPU和内存被吃掉,A开始大量报错,而调用A的上游又因为依赖A超时而出现同样的线程堆积。整个链路就是这样被一层层拖垮的。
限流解决的问题,就是“不让超过系统承载能力的流量进入处理环节”。它不是等系统扛不住了再拒绝,而是在入口处提前判断这个请求该不该放行。所以限流经常被部署在外层网关、API入口或下游服务调用的前置逻辑里。
1.2 限流、熔断、降级,三者分工不太一样
面试里经常把限流、熔断、降级混在一起问,但它们的职责是不同的:
- 限流:控制单位时间内的请求数量。系统只能处理1000 QPS,我就只放1000进来,多余的直接拒掉。核心是“不让入口被打爆”。
- 熔断:当依赖的下游服务连续出错时,快速断开调用链路,不再请求已经故障的服务。核心是“不让故障范围继续扩散”。
- 降级:在系统压力过大时,主动牺牲一些非核心功能,比如关闭推荐流、返回兜底数据。核心是“用次要功能保主要功能”。
限流是第一道闸门,通常负责挡住外部流量;熔断和降级更多是内部自我保护。一个完整的高可用方案,通常三者都要有,但限流是最基础的一道防线。
1.3 评判限流算法好用不好用,主要看三件事
看完四种算法之后你会发现,它们的核心差异其实就是三件事:
第一,精度。统计的流量窗口是不是够准确,能不能精确反映一个时间段内的真实请求量。
第二,内存和计算开销。算法的实现是否简单、高效,在高并发下会不会成为新的性能瓶颈。
第三,对突发流量的处理能力。有的场景希望流量完全匀速,有的场景则希望允许一定程度的突发。没有一种算法能同时做到既严格匀速、又允许突发,所以你才能看到这么多种方案存在。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 固定窗口计数器:5分钟能写出来,但临界流量会击穿它
2.1 原理其实就是一个计数器加一个时间窗
固定窗口是所有限流算法里最直观的。我把时间切成一个个固定长度的窗口,比如1秒一个窗口,每个窗口内维护一个计数器。请求进来,计数器加一;计数器超过阈值,请求一律拒绝;进入下一个窗口,计数器清零重新计数。
这种设计和“按秒统计请求数限制为100”是一个意思。实现也非常简单,一个类就搞定。
java复制public class FixedWindowRateLimiter {
private final long maxCount; // 窗口内允许的最大请求数
private final long windowMillis; // 窗口大小,单位毫秒
private long currentWindowStart; // 当前窗口的开始时间
private long currentCount; // 当前窗口内已通过的请求数
public FixedWindowRateLimiter(long maxCount, long windowMillis) {
this.maxCount = maxCount;
this.windowMillis = windowMillis;
this.currentWindowStart = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - currentWindowStart >= windowMillis) {
currentWindowStart = now;
currentCount = 0;
}
if (currentCount >= maxCount) {
return false;
}
currentCount++;
return true;
}
}
这个版本我加了synchronized来保证并发安全。实际生产里如果并发量很高,synchronized会形成锁竞争,你可以换成AtomicLong加时间判断,或者用LongAdder做计数,命中的时候再判断是否超限。不过逻辑都一样,不复杂。
2.2 临界问题:两个窗口交替时,流量直接翻倍
固定窗口最大的问题,是窗口边界前后的流量可以叠加,形成双倍甚至更高的请求尖刺。
假设限流规则是“每秒最多100个请求”,窗口是1秒。某个请求在第1秒的999毫秒时进来,它所在窗口已经积累了100个请求,所以它被拒绝,这没问题。但如果第1秒的999毫秒时,第1个窗口只积累了50个请求;紧接着到第2秒的1毫秒,新窗口开启计数器归零,这时又来了50个请求。表面上每一秒的请求数都是50,没有超过100,但实际上在第1秒的尾巴和第2秒的开头之间,这个系统在极短的时间内连续收到了100个请求,整体负载并没有被控制住。
更极端一点:如果第1秒最后1毫秒来了100个请求,第2秒最开始1毫秒又来了100个请求,那系统相当于在2毫秒内承载了200个请求。对于数据库连接池、下游接口这类抗瞬时峰值能力很弱的东西,这2毫秒就足够打出问题了。
2.3 固定窗口不是一无是处,它适合什么样的场景
虽然临界问题听上去很严重,但在实际业务里,固定窗口依然有它的位置。
如果你的限流阈值比较低,比如一个简单的短信发送接口,限制每分钟最多10次,就算有临界问题,也不会造成多大冲击。又或者你的系统对这个接口的瞬时峰值并不敏感,比如是纯内存计算、响应时间在毫秒级的服务,固定窗口的不足基本可以被忽略。
另外,很多分布式限流也喜欢用固定窗口,因为它的Redis实现非常简洁,直接对一个key做INCR和EXPIRE就行。在分布式场景里,算法本身的精度常常不是第一位的,能够在多节点之间共享计数才是重点。
3. 滑动窗口:把窗口切碎,让流量统计更贴近真实
3.1 滑动窗口和固定窗口的本质区别
滑动窗口解决的就是固定窗口的临界问题。它不再是一个完整的窗口到点之后整体清零,而是让窗口的终点始终跟随当前请求的时间移动,窗口内永远只保留最近一段时间的数据。
举个例子,限流规则是“最近60秒内最多允许600个请求”。固定窗口的做法是每分钟清零一次,到了边界流量就可以钻空子。滑动窗口的做法是:窗口起始时间随着最新请求的到达持续后移,窗口内统计的是从“当前时间往前倒60秒”这个区间里的请求总数。这样不管你请求是59秒的时候来的,还是下一秒刚过的时候来的,统计的始终是“最近60秒”的真实总量,不会有窗口切换导致的断档。
3.2 两种常见实现:时间戳队列与固定格子
先看最容易理解的时间戳队列版本。我把每个放行的请求时间戳放进一个队列,每次请求进来时,先把队头已经超出窗口的时间戳移除,再判断队列长度是否达到上限。这个版本代码很短,面试时口述也方便。
java复制import java.util.ArrayDeque;
import java.util.Deque;
public class SlidingWindowRateLimiter {
private final long maxCount;
private final long windowMillis;
private final Deque<Long> timestamps = new ArrayDeque<>();
public SlidingWindowRateLimiter(long maxCount, long windowMillis) {
this.maxCount = maxCount;
this.windowMillis = windowMillis;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
while (!timestamps.isEmpty() && now - timestamps.peekFirst() >= windowMillis) {
timestamps.pollFirst();
}
if (timestamps.size() >= maxCount) {
return false;
}
timestamps.addLast(now);
return true;
}
}
这个实现的优点是精度高,每个请求的精确时间都被记录下来;缺点是内存和请求数成正比,高QPS下队列会很长,频繁的队头移除操作也有一定开销。
生产环境更常用的是固定格子版本。我把60秒的窗口切成6格,每格10秒,用一个固定长度的数组记录每一格内的请求数。窗口滑动时,只重置那些已经滑出窗口的格子。这样内存是固定的,不受请求量影响。
java复制public class SlidingWindowBySlotRateLimiter {
private final int slotCount; // 格子数
private final long slotMillis; // 每格的时间长度
private final long maxCount; // 窗口内最大请求数
private final long[] slotCounts; // 每个格子的计数
private final long[] slotStartIndex; // 每个格子当前对应的时间分片编号
private int cursor; // 当前写入的格子下标
public SlidingWindowBySlotRateLimiter(int slotCount, long windowMillis, long maxCount) {
this.slotCount = slotCount;
this.slotMillis = windowMillis / slotCount;
this.maxCount = maxCount;
this.slotCounts = new long[slotCount];
this.slotStartIndex = new long[slotCount];
long now = System.currentTimeMillis();
long currentSlot = now / slotMillis;
for (int i = 0; i < slotCount; i++) {
slotStartIndex[i] = currentSlot - slotCount + 1 + i;
}
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
long currentSlot = now / slotMillis;
long total = 0;
for (int i = 0; i < slotCount; i++) {
if (slotStartIndex[i] < currentSlot - slotCount + 1) {
slotStartIndex[i] = currentSlot - slotCount + 1;
slotCounts[i] = 0;
}
total += slotCounts[i];
}
if (total >= maxCount) {
return false;
}
cursor = (int) (currentSlot % slotCount);
if (slotStartIndex[cursor] != currentSlot) {
slotStartIndex[cursor] = currentSlot;
slotCounts[cursor] = 0;
}
slotCounts[cursor]++;
return true;
}
}
这段代码里,slotStartIndex保存的是“这个格子当前统计的是哪一个时间分片”。每个请求进来时,遍历所有格子,把超出窗口时间范围的分片清零。因为格子数通常都很小(几十个),O(n)的遍历开销很小,可以忽略。
3.3 格子数怎么选,精度和内存的权衡
滑动窗口的精度取决于格子数量。格子越多,统计越接近真实流量,但每个窗口需要存储和遍历的数据也越多。格子太少,比如60秒只切2格,和固定窗口的区别就不大了,依然可能出现两倍突发。
我的经验是,如果限流阈值是秒级别的,窗口切20到50个格子比较合适。比如限制QPS为100,窗口是1秒,切成40格,每格25毫秒,已经能比较平滑地控制流量。切太多没意义,因为请求到达的粒度本身也是离散的,切到上百格之后精度的提升基本感知不到,反而是白白增加存储和计算成本。
4. 漏桶算法:强制匀速输出,保护下游不被打爆
4.1 漏桶的模型:桶里装水,底部匀速漏水
漏桶算法的思想很有意思。你可以想象一个底部有洞的桶,水流进来,水从洞里流出去,洞的大小固定,所以不管进水流多大,出水的速度始终是恒定的。如果进水的速度大于出水的速度,桶里的水就会越来越多,直到桶满了,再进来的水直接溢出,也就是被丢弃。
对应到限流:请求就是“水”,桶就是请求缓冲区,桶的容量决定了最多能同时积压多少请求。漏口的速率就是系统处理请求的速率。漏桶保证了“系统处理请求的速率恒定”,不管上游怎么突发,下游看到的都是一股稳定的流量。
4.2 时间驱动版Java实现,不用线程池也能跑
漏桶最直接的做法是用一个队列缓存请求,再加一个定时线程池按固定频率取出请求处理。但定时任务会引入线程和调度的复杂度。如果只是做限流判断,可以做成纯时间驱动的计算版本。
思路是:记录当前桶里的水量和上次请求时间。每次请求进来,先算出从上次到现在漏掉了多少水,更新水量。如果桶还能放下一个请求,就放入,并让桶里的水量加一。
java复制public class LeakyBucketRateLimiter {
private final long capacity; // 桶容量,最多能积压多少请求
private final double leakPerMs; // 每秒漏出的请求数 / 1000
private double water; // 当前桶里的水量
private long lastTime; // 上次请求时间
public LeakyBucketRateLimiter(long capacity, double leakPerSecond) {
this.capacity = capacity;
this.leakPerMs = leakPerSecond / 1000.0;
this.water = 0;
this.lastTime = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
double leaked = (now - lastTime) * leakPerMs;
water = Math.max(0, water - leaked);
if (water + 1 > capacity) {
return false;
}
water += 1;
lastTime = now;
return true;
}
}
注意,漏桶的tryAcquire和令牌桶的最大区别在于:漏桶判断的是“桶里还有没有空位”,而令牌桶判断的是“还有没有令牌可取”。漏桶里的水代表的是“待处理的积压请求”,水越多说明系统负荷越大;令牌桶里的令牌代表的是“允许放行的配额”,令牌越多说明系统越空闲。
4.3 漏桶的短板:它不适合需要快速响应的业务
漏桶最大的特点是输出平滑,但这既是优点也是缺点。它强行把流量变成匀速,意味着即使系统当前很空闲,也没有办法一次性多处理一些请求。如果业务对突发流量有一定容忍度,漏桶反而会造成资源浪费。
比如一个秒杀场景,瞬间涌入10万个请求,但漏桶速率是每秒1000个。这10万个请求只能在桶里排队,前几秒绝大部分请求都拿不到处理资格,用户会感觉“卡死”。如果此时系统其实有能力在前几秒处理更多请求,漏桶就把这部分能力浪费掉了。
所以漏桶比较适合的场景是:下游服务对流量速率非常敏感,宁可把突发请求拿在手里排队,也不允许一口气打过去。典型的就是数据库写入、第三方支付接口、消息推送这类能力有限的依赖。
5. 令牌桶算法:允许突发的同时,还能守住平均速率
5.1 令牌桶的核心语义:想放行,先拿令牌
令牌桶的思路和漏桶正好反着来。桶里装的不是请求,而是令牌。令牌按固定速率生成,比如每秒生成100个,最多攒到桶容量500个。请求进来时,先去桶里拿一个令牌,拿到了就放行,拿不到就拒绝。
这样带来的直观效果是:只要桶里有令牌,请求就能通过。令牌是攒出来的,如果之前一段时间请求比较少,桶里会攒下不少令牌;等到流量突然变大,请求可以一次性把这些令牌全部取走,从而形成一波突发流量。而令牌的生成速率又是固定的,所以长时间看,平均速率被限制在了生成速率附近。
用一句话总结:漏桶限制的是“出去的速度”,令牌桶限制的是“进来的平均速度”。
5.2 手写一个令牌桶的Java实现
java复制public class TokenBucketRateLimiter {
private final long capacity; // 桶容量,最多能存多少令牌
private final double tokenPerMs; // 每秒生成令牌数 / 1000
private double tokens; // 当前桶里的令牌数
private long lastTime; // 上次生成令牌的时间
public TokenBucketRateLimiter(long capacity, double tokenPerSecond) {
this.capacity = capacity;
this.tokenPerMs = tokenPerSecond / 1000.0;
this.tokens = capacity; // 初始为满桶,避免启动后被立即打爆
this.lastTime = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
tokens = Math.min(capacity, tokens + (now - lastTime) * tokenPerMs);
if (tokens >= 1) {
tokens -= 1;
lastTime = now;
return true;
}
return false;
}
}
这里有一个很重要的细节:初始令牌数设为capacity,也就是“满桶”启动。原因是你无法预知服务启动后第一秒会不会遇到流量高峰,如果不给初始令牌,启动瞬间所有请求都会被拒绝,这在生产里很容易引发“重启后立即限流误伤”。满桶启动能让服务在刚启动时具备一次应对突发的能力。
而失败请求不需要更新lastTime,因为令牌本身是持续生成的,下次请求时会从上次结算时间继续计算增加的数量。
5.3 Guava RateLimiter:生产环境可以直接用的版本
手写令牌桶用于理解原理没问题,但生产环境我更推荐直接用Guava的RateLimiter,它经过了充分的性能优化和线上验证,还提供了两个不同行为的变体。
SmoothBursty是平滑突发模式,也就是标准令牌桶:允许一定的突发流量。SmoothWarmingUp是平滑预热模式,它会在一段时间内逐步提升放行速率,避免冷启动瞬间把后端打满。这个模式很适合那些启动后需要时间预热缓存、初始化连接池的服务。
java复制import com.google.common.util.concurrent.RateLimiter;
// 每秒生成10个令牌,最多积累20个令牌
RateLimiter limiter = RateLimiter.create(10.0);
// 尝试获取1个令牌,获取到则返回true
if (limiter.tryAcquire()) {
// 放行
} else {
// 限流
}
如果你用的是Spring Boot 2.x及以上,还可以考虑引入resilience4j-ratelimiter,它内置了基于令牌桶的限流器,并且与Spring的适配做得比较好。不过无论如何,手写一版仍然很有价值,它能帮你彻底理解配置项背后到底发生了什么。
6. 四种算法怎么选:一张表看清差异,面试这么答更稳
6.1 横向对比:从核心指标看差异
| 算法 | 核心思想 | 输出流量特征 | 是否允许突发 | 精度 | 内存开销 | 实现复杂度 |
|---|---|---|---|---|---|---|
| 固定窗口 | 固定时间窗内计数 | 窗口内不限速,边界易突发 | 允许,但会出现双倍窗口风险 | 低 | 极低 | 极低 |
| 滑动窗口 | 窗口随时间滚动 | 接近匀速,精度取决于格子数 | 允许,但受窗口内总量约束 | 高 | 中等 | 中等 |
| 漏桶 | 请求入桶,底部匀速漏出 | 严格匀速 | 不允许 | 高 | 中等 | 中等 |
| 令牌桶 | 令牌按速率生成,请求取令牌 | 平均限速,短期可出现突发 | 允许,突发上限由桶容量决定 | 高 | 低 | 低 |
6.2 不同业务场景下的选型建议
选型不能笼统地说“哪个算法最好”,而是要看你限流后要保护的是什么。
如果保护的是外部API网关,面对的是不可控的用户流量,希望既能应对秒杀瞬间的流量尖刺,又不让后端长时间处于过载状态,令牌桶是首选。
如果保护的是数据库连接池、第三方接口、消息队列生产者这类对流量敏感性极高的依赖,漏桶更合适。因为无论是1万QPS还是100万QPS,到达数据库的流量都必须是稳定的,任何突发都可能打爆连接池。
如果业务要求限流足够精确,不允许边界翻倍之类的问题,同时不希望算法太复杂,滑动窗口是不错的选择。
如果只是普通接口防刷、配置简单优先,固定窗口也能用,但要知道它的边界风险,并做好流量峰值的余量设计。
6.3 面试官真正想听的不是“标准答案”
很多Java面试题喜欢问“四种限流算法有什么区别”,网上八股文答案一堆。但面试官真正想考察的是你有没有理解每种算法背后的取舍。
我建议面试时先点出最关键的一句话:令牌桶和漏桶的核心区别在于“突发处理方式”。然后马上补一个场景:如果你的系统发现某段时间内请求突然增加,但数据库连接池完全扛得住,你会选哪种?答案就顺着“允许突发、保留令牌”的思路走,令牌桶。反过来,如果场景换成“数据库只能接受每秒500次写入,多了就挂”,你会选漏桶。这种结合场景的回答,比单纯背概念有用得多。
另外一个经常被追问的点是:固定窗口临界问题怎么解决?你可以直接说,“固定窗口的临界问题来自窗口重置,滑动窗口通过分片滚动避免整窗口清零,所以从根本上消除了这个问题”。这个回答既包含了原因也包含了结果,面试官一般会满意。
7. 从算法到生产:单机限流之外的分布式实践与调参经验
7.1 单机限流的局限在哪里
上面所有算法都是单机版本的,也就是说每个服务实例各自维护自己的计数器或令牌桶。假设你有10个实例,每台机器限流100 QPS,理论上集群总容量是1000 QPS。但如果流量在机器之间分配不均匀,某台机器可能突然分到400 QPS,直接被限流误杀,而其他机器还很空闲。
更关键的是,如果前面还有一层负载均衡或者网关,入口流量往往是集中到达的。单机限流只护住了下游实例,却没法保证整个集群的请求总量被控制在合理范围。所以网关层或接入层通常要做一层分布式限流,让所有节点共享同一个计数状态。
7.2 基于Redis和Lua的分布式限流实现
分布式限流最常用的技术栈是Redis+Lua,原因是Lua脚本可以在Redis服务端原子执行,既保证了并发安全,又减少了多次网络往返。
以固定窗口为例,Lua脚本可以写成这样:
lua复制local key = KEYS[1]
local limit = tonumber(ARGV[1])
local windowMillis = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('PEXPIRE', key, windowMillis)
end
if current > limit then
return 0
end
return 1
Java侧调用时,把当前时间戳和限流阈值作为参数传入,Redis端会原子完成计数、过期设置和阈值判断。这个方案的好处是极度简单、性能高,单条Lua脚本在普通Redis实例上执行时间通常是微秒级。
如果是分布式令牌桶,可以用一个Hash结构保存令牌数和上次刷新时间,在Lua脚本里完成令牌生成、取令牌、回写三个步骤。滑动窗口和漏桶也有对应的Redis实现,但复杂度会高一些。在实际项目中,很多团队会直接使用开源的分布式限流组件,比如Redisson的RRateLimiter,底层就是Redis+Lua。生产环境没有必要重复造轮子。
7.3 限流阈值怎么定,不能拍脑袋
阈值设多少,是这个领域最常见的实操难题。设太小,稍微一点正常流量就会被误杀;设太大,限流形同虚设。
我的建议分三步走。第一步,做压测,找出核心接口的“极限QPS”。这个值不是看单机CPU烧满就算,而是要看链路中第一个扛不住的组件,可能是数据库连接池、下游服务或外部依赖。第二步,把极限QPS乘以一个安全系数,通常是0.7到0.8,作为限流阈值,留下缓冲。第三步,在线上试运行期间持续观察,如果频繁触发限流而系统负载并不高,说明阈值偏低,需要上调;如果系统负载已经很高但限流还没触发,说明阈值偏高,需要下调。
这个过程没有一劳永逸的公式,需要结合实时监控不断校准。尤其是大促、活动、新品发布这类流量模型明显变化的场景,最好提前再压一次测,别拿上一次的配置硬套。
7.4 限流之后的处理策略,也决定限流的效果
最后再说一个很容易被忽略的点:限流拦截下来的请求,不是简单返回个错误就完事了。不同的处理策略对用户体验和系统稳定性的影响差别很大。
最常见的策略是快速失败,直接返回错误码或者提示“请求太频繁”。这种方案适合大多数API场景,简单直接,缺点是用户体验差。
其次是排队等待。将超出阈值的请求放进一个固定容量的队列,按先进先出或者按优先级处理,让请求“缓慢通过”。漏桶算法天然适合做这件事。不过要注意,排队不能无限制,队列也要设置容量和超时时间,否则积压的请求会拖垮系统。
第三是降级。被限流的请求返回一个兜底结果,比如缓存里的旧数据、默认的商品列表、静态页等。这是电商大促场景里很常用的方案,牺牲部分数据实时性,换取整体系统的可用性。
在实际生产里,限流、排队、降级经常是组合使用。比如入口网关用令牌桶限流,快速拒绝超出的请求;核心服务调用下游时再用漏桶做流量整形,防止打爆数据库;最后配一层降级策略兜底,保证用户无论如何都能拿到一个结果而不是看到超时错误。
我自己的经验是,限流的落地比想象中要复杂。算法本身不难,难的是把限流阈值定准,把被限流的业务逻辑做对,再结合监控不断调整。如果你正在给系统加限流,建议先从最简单的固定窗口或者Guava令牌桶跑起来,然后逐步升级到滑动窗口和分布式限流,每一步都观察线上数据,再决定要不要继续优化。
