1. 突发流量为什么能瞬间打崩服务:限流目标的本质
前阵子一个后端群里有人问:接口平时只有200 QPS,大促一开瞬间冲到5000,系统一下被打懵了,加机器来不及,限流也不知道怎么限,有没有一套方案能让缓存、数据库、下游服务都不被冲垮?当时大家七嘴八舌,有人推荐单机计数器,有人推荐漏桶,我回了一句:你要是既要扛得住瞬时洪峰,又不想牺牲正常的处理能力,令牌桶基本是唯一解。
这年头做后端,不管是做Java还是做Go,凡是跟高并发沾边的系统,令牌桶和限流这两个词几乎绕不开。面试也爱问,实战也躲不掉。今天不聊晦涩的理论,就从“为什么突发流量会打崩服务”这个问题出发,把令牌桶从原理到Java实现再到真实场景,完整拆一遍。
1.1 突发流量和匀速流量的杀伤力差异
先搞清楚一个问题:同样是一万次请求,匀速过来和瞬间砸过来,对系统的伤害完全不一样。
匀速一万次,如果系统每秒能处理2000,五秒内全部消化掉,CPU、内存、数据库连接数都在可控范围内,顶多队列排队,整体是稳定的。但突发流量是什么样?前一秒还是静悄悄的,下一秒五千个请求同时到达。Tomcat线程池一下子被占满,每个线程都在等数据库连接,连接池耗尽,后续请求全部超时,超时进一步加剧线程堆积,最后整个服务假死。这不是玄学,是每个线上系统都会遇到的实际场景。
而且突发流量还有一种“雪崩放大效应”:一个接口被打挂了,它内部调用方开始疯狂重试,重试流量翻倍叠加到下游服务,下游也挂,再传导到更下游。等链路全乱了,恢复起来就是几个小时的事。
1.2 为什么固定窗口限流解决不了突发
很多人第一反应是用固定窗口计数器:比如每秒允许100个请求,统计当前秒内数量,超过就拒绝。
这个方案写起来简单,但有两个致命伤。
第一个是一瞬间打满配额。假设每秒限100个,前0.1秒来了100个请求,后0.9秒所有请求全部被拒。服务确实保住了,但用户体验极差——明明系统有能力处理更多,却因为计数方式太粗暴直接掐断。
第二个更隐蔽:固定窗口存在临界突刺。窗口是每秒一切换的,如果上一秒最后10毫秒来了100个请求,下一秒前10毫秒又来了100个,在这个“窗口切换瞬间”,系统实际承受了每秒200的瞬时流量,而完全绕过了限流判断。这就是经典的窗口边界漏洞。
跳变、浪费、边界漏洞,这三个问题决定了固定窗口只适合低要求的场景,真正要应对突发流量,必须有更好的机制。
1.3 限流的真正目标:既要挡得住洪峰,又不能掐断正常流量
这里要把限流的目标想清楚。很多人认为限流就是“限制并发”,其实不完全对。限流的目标是在有限的系统容量下,保障服务的可用性,同时尽量多地处理有效请求。
注意“尽量多”这三个字。如果一个请求在我能力范围内,我就应该放它进来;只有当请求量超过我的真实承载上限,才需要削峰或者拒绝。所以限流其实是在做一种“按能力匹配”的博弈:系统的容量是一条线,请求曲线是在线以上跳跃的,我要做的事情是让通过的流量尽量贴近那条容量线,既不长期超载,也不浪费空闲吞吐。
令牌桶最牛的地方就在这里:它允许流量在桶内有令牌时瞬间爆发,最多可以吃掉整个桶的存量令牌,同时又能保证长期来看平均速率不超过设定值。翻译成人话就是:短时间可以超常发挥,长时间不被拖垮。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 令牌桶的核心机制:一个桶怎么做到“允许爆发还限速”
令牌桶的逻辑听起来很简单:一个桶,容量固定,里面不断生成令牌;每个请求来的时候必须取走一个令牌,有令牌放行,没令牌就等或者拒绝。但就是这套简单逻辑,背后藏着几个关键参数和数学约束。
2.1 三个核心参数决定行为特征
第一个参数是令牌生成速率r,单位是“个/秒”。它决定了长期平均QPS。举个形象点的例子,把系统每秒的处理能力理解成一条水管的最大流速,r就是水管的正常通水速度。
第二个参数是桶容量b。它决定了瞬时突发能力能放大到什么程度。如果我们把r设置成每秒100,把b设置成500,那么当桶里存满了500个令牌时,瞬间可以一次性放行500个请求——这500个请求处理完,桶就空了,之后只能按照每秒100的速率继续补充令牌和放行请求。
第三个参数是当前令牌数current,以及上次填充时间lastRefillTime。这两个是运行时状态,用来计算“我该补充多少令牌了”。
补充令牌的核心逻辑有个细节容易被忽略:不是每秒定时加一个,而是用“惰性计算”。也就是说,不在后台起一个定时器傻乎乎地刷令牌,而是在每个请求来的时候,用当前时间和上次刷新时间之差,计算出这段时间应该生成多少个令牌,一次性补进去,再跟桶容量取最小值。
2.2 令牌桶和漏桶的对比:为什么非用它不可
网上有个经典类比:漏桶是一个底部有洞的水桶,谁往里倒水,它就匀速漏出去,进来的再猛,出去的速率恒定不变。令牌桶则是“加了令牌的水龙头”,有令牌就放水,平均速率限定的前提下允许短时洪峰。
拿一张对比表来梳理一下:
| 方案 | 允许突发 | 平均速率限制 | 主要问题 |
|---|---|---|---|
| 固定窗口计数器 | 否(窗口内严格计数) | 是,但窗口边界有漏洞 | 边界突刺、瞬间打满配额浪费能力 |
| 滑动窗口计数器 | 基本否 | 是 | 实现复杂,突发能力差 |
| 漏桶 | 否 | 是,速率恒定 | 无法利用系统富余能力 |
| 令牌桶 | 是,最多burst个 | 是,长期平均 | 需要合理设置桶容量 |
这里想多说一句:漏桶不是没有应用场景。当你要保证下游某一个依赖接口绝对不能被打爆,比如银行扣款接口、短信供应商接口,这种场景下“绝对匀速”反而是优点。但如果你做的是自己的核心读接口,希望服务稳定不死的同时尽可能多接请求,令牌桶明显更合适。
2.3 突发量的数学边界:桶容量不是随便拍的
最后讲一下桶容量怎么定。假设系统稳定处理能力是1000 QPS,突发时最多坚持2秒的流量高峰不下线,那么单个实例上,桶容量最少可以定为2000。而令牌生成速率通常设置为单机安全QPS的1.5倍左右,为什么是1.5倍而不是刚好等于?因为系统在自己处于轻载时,留一些余量可以更高效地利用资源,但如果设置太高,突发总量会被放大,反而打崩下游。
实际操作中,算法只负责限流,容量规划才是真正决定系统生死的那一步。桶给得太大,等于没限;太小,平时正常请求也会被误伤。后面讲实际案例时我会给一组真实压测的调参数据。
3. Java代码实现:从零手写到成熟工具
理念说完了,动手写代码。先来一个能跑的手写版,再介绍企业项目里真正在用的成熟实现。
3.1 用两个锁和一个时间戳手写令牌桶
最朴素的实现思路:用synchronized保证并发安全,每次请求进来刷新令牌数,再决定是否放行。完整代码如下:
java复制public class TokenBucket {
private final long capacity; // 桶容量,最多存多少令牌
private final long refillRate; // 每秒生成的令牌数
private double currentTokens; // 当前令牌数
private long lastRefillTime; // 上次补充令牌的时间
public TokenBucket(long capacity, long refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.currentTokens = capacity; // 初始时桶是满的
this.lastRefillTime = System.nanoTime();
}
public synchronized boolean tryAcquire(int permits) {
refill();
if (currentTokens >= permits) {
currentTokens -= permits;
return true;
}
return false;
}
private synchronized void refill() {
long now = System.nanoTime();
long elapsed = now - lastRefillTime;
double tokensToAdd = elapsed * refillRate / 1_000_000_000.0;
currentTokens = Math.min(capacity, currentTokens + tokensToAdd);
lastRefillTime = now;
}
}
有几个细节说一下。
第一,为什么用System.nanoTime而不是System.currentTimeMillis?因为currentTimeMillis是墙上时钟,可能被系统时间调整影响,而nanoTime基于JVM内部单调时钟,适合用来计算时间差。
第二,为什么初始令牌数设为capacity?这是“先到先得”策略:系统刚上线时一般没有积累流量,允许桶空不空存在性能浪费——所以把桶填满,服务一开就能承受一波突发请求。
第三,synchronized锁住了tryAcquire和refill,意味着同一时刻只有一个请求能更新令牌,这是最简单的线程安全写法。但高并发场景下synchronized就是瓶颈,后面讲优化时细说。
接一个简单的测试:
java复制public class Demo {
public static void main(String[] args) throws Exception {
TokenBucket bucket = new TokenBucket(5, 2); // 容量5,每秒补2个令牌
for (int i = 0; i < 10; i++) {
Thread.sleep(200);
System.out.println("第" + i + "次请求结果: " + bucket.tryAcquire(1));
}
}
}
因为我初始满桶,前几个请求会全部通过,直到桶里的存量令牌耗尽,才会开始按每秒2个的速率严格限制。跑一下你就明白什么是“先放爆发,再回稳态”的过程。
3.2 用Guava RateLimiter:企业项目里的成熟方案
实际开发中,单机限流我最常用的还是Guava的RateLimiter,代码已经封装得很完善,而且解决了手写版synchronized的性能问题——它内部用的是循环队列和permits-per-second刷新机制,在高并发下更高效。
java复制import com.google.common.util.concurrent.RateLimiter;
public class RateLimiterDemo {
public static void main(String[] args) {
// 每秒放行100个请求
RateLimiter limiter = RateLimiter.create(100);
for (int i = 0; i < 500; i++) {
boolean acquired = limiter.tryAcquire();
if (acquired) {
System.out.println("放行请求 " + i);
} else {
System.out.println("拒绝请求 " + i);
}
}
}
}
RateLimiter.create(100)其实还有第二个重载版本,支持指定预热时间,例如RateLimiter.create(100, 3, TimeUnit.SECONDS),这个是SmoothWarmingUp模式,它不会一上来给满桶令牌,而是像汽车的冷启动一样从低速慢慢爬到目标速率。这个特性在系统刚启动、JIT还没热起来、缓存还没构建时特别实用,能有效防止“刚上线就被一波流量带走”。
Guava RateLimiter还支持acquire()阻塞式和tryAcquire(timeout)带超时式。阻塞式适合消息队列消费这种“宁可排队也不能丢”的场景,超时式适合Web接口——超过100毫秒拿不到令牌直接返回“系统繁忙”,避免请求堆积。
3.3 多实例场景:Redis + Lua实现分布式令牌桶
单机限流只能保护单个JVM进程。微服务架构下同一个接口部署了多个节点,请求被负载均衡分散到不同机器,如果每台机器各自限流,整体极限流量是“单机限流值 × 节点数”,远远超出设计容量。这种场景必须做分布式限流。
分布式令牌桶的常见做法是借助Redis的Lua脚本保证原子性。核心思路是把令牌数据存在Redis里,每次请求用Lua脚本完成“读取令牌数→计算补充→扣减→返回结果”的原子操作。
lua复制-- 初始化参数
local rate = tonumber(ARGV[1]) -- 每秒补充令牌数
local capacity = tonumber(ARGV[2]) -- 桶容量
local now = tonumber(ARGV[3]) -- 当前时间戳(毫秒)
local requestNum = tonumber(ARGV[4]) -- 本次请求需要的令牌数
-- 读取当前令牌状态
local tokensKey = KEYS[1] -- 当前令牌数
local lastRefillKey = KEYS[2] -- 上次刷新时间
local currentTokens = tonumber(redis.call('get', tokensKey) or capacity)
local lastRefill = tonumber(redis.call('get', lastRefillKey) or now)
-- 计算应该补充多少令牌
local interval = now - lastRefill
local newTokens = math.floor(interval * rate / 1000.0)
if newTokens > 0 then
currentTokens = math.min(capacity, currentTokens + newTokens)
redis.call('set', lastRefillKey, now)
end
-- 判断是否放行
if currentTokens >= requestNum then
currentTokens = currentTokens - requestNum
redis.call('set', tokensKey, currentTokens)
return 1
else
redis.call('set', tokensKey, currentTokens)
return 0
end
这种方案的优点是全集群共享一份令牌,限流阈值精确;缺点是每请求一次Redis,多了一次网络往返,极端高并发下Redis本身也可能成为瓶颈。所以业界的标准做法是“本地限流兜底 + 分布式限流兜大数”或者干脆用网关层做统一限流,避免每次业务请求都链到Redis。
4. 实际案例:订单接口的限流压测实录
讲一个我参与过的真实场景:一个电商平台的订单提交接口,平时单机也就跑200 QPS,但预热秒杀活动一开,用户疯狂点击提交,瞬时流量能冲到单机2000以上。如果不加任何防护,数据库连接池先满,紧接着整个订单服务就卡死了。
4.1 业务场景与参数设计
当时我们用了Nginx层接入令牌桶限流,之所以在Nginx层做,是因为订单这类核心接口最好在流量进入业务进程之前就先干掉过载部分,省得业务JVM白耗线程和内存去处理一定会被拒掉的请求。
配置大致是这样:
| 配置项 | 值 | 设计理由 |
|---|---|---|
| 令牌生成速率 | 500/s | 单机经压测验证的最大安全QPS |
| 桶容量 | 1500 | 允许瞬发3倍速率的流量,撑住秒杀开始的前3秒 |
| 拒绝方式 | 返回“系统繁忙” | 比排队更符合秒杀场景,避免客户端无限重试 |
| 分布式方案 | Redis + Lua | 多个接入层节点共享一份令牌状态 |
4.2 压测结果对比:固定窗口、漏桶、令牌桶的实战差距
我们当时用压测工具分别模拟三种方案下同一批流量曲线——从0突然跳到800 QPS,持续5秒,再回到正常水平。数据大概可以归纳成这样:
| 方案 | 限流前QPS | 限流后QPS | 错误率 | 系统稳定性 |
|---|---|---|---|---|
| 不限流 | 2000 | 2000 | 37%(超时+异常) | 服务卡死,CPU飙高 |
| 固定窗口(每秒500) | 2000 | 500 | 5%(边界瞬间穿透) | 稳定 |
| 漏桶(速率500) | 2000 | 500 | 3%(流量被匀速削平) | 稳定 |
| 令牌桶(速率500,容量1500) | 2000 | 1500+500 | 0%(前3秒瞬间放行1500,之后按500) | 稳定 |
这组数据最直观的一点:同样的下游容量,令牌桶放行的有效请求数量是漏桶的三倍多——因为漏桶把前几秒本来可以处理掉的流量全部削掉了,白白浪费了系统富余的处理能力。而令牌桶把这些富余容量以“突发令牌”的形式保留了下来。
当然没写进表格的还有客户端响应体验。漏桶模式下秒杀刚开始那几秒,用户点提交被匀速挡回去,报“系统繁忙”;令牌桶模式下,前3秒点击的用户几乎全部成功提交,体验完全不一样。
5. 实战避坑:配置、预热与降级的那些暗坑
限流不是写完代码就完事了,真正上线前有几个坑几乎是人人都会踩一遍的。
5.1 冷启动陷阱:一上来就被打死的系统重建期
这是最容易被忽略的点。系统刚刚重启,数据库连接池还没建立,JIT还没编译热点代码,本地缓存还是空的,这个时候如果就直接用满桶令牌,很可能把本来就不稳定的系统再压上一脚。这是我在生产上吃过亏的地方。
解决方案之一是用Guava RateLimiter的预热模式,前几秒从低速率爬升,给系统留一个“缓一口气”的过程;方案之二是从注册中心做优雅上线,拉入流量前先等健康检查通过,并且在一段窗口期内容忍更长的RT。总之,令牌桶只是控制“入口流量”,系统内部状态是另一层需要配合的因素。
5.2 单机限流和集群限流的错配
再说一个常见错误配置:明明部署了10个节点,每台机器限流500,觉得这样总量是5000,结果某次某个节点因为负载均衡不均,实际分到了1000以上的流量,直接把自己打死。所以多节点限流逃不开两个选择:要么做分布式限流,精确控制总量;要么把单机限流值设得足够保守,比如只按“服务总量/N”的70%来配置,留出应对不均衡的缓冲。没有第三种既精确又完全免运维的方案。
5.3 令牌桶 + 熔断降级:限流不是唯一的救世主
最后要泼一盆冷水:限流能挡住上游的“量”,但是挡不住下游的“病”。如果下游数据库真的挂了,限流再准也没用,因为每个被放行的请求依然会打到一个不可用的服务上。
所以完整的高可用链路通常是这么组合的:进程入口用令牌桶做限流,控制流量总量;内部用熔断器(比如Resilience4j、Sentinel)保护对下游的调用,一旦下游错误率达到阈值立即断路,不再发起无意义的请求;再配合线程隔离,让慢调用只占有限的线程资源,不拖垮整个服务。
限流管“炸进来多少”,熔断管“转出去多少”,隔离管“占用多少”,三者缺一不可。
我在实际项目中还养成了一个习惯:每次调整限流阈值,都会保留改动记录,并且把「被限流次数」这个指标单独拉出来看。限流次数突然升高,不一定是坏事,反而可能是活动流量来了的信号;但是如果限流次数持续长时间高位,那就说明容量已经跟不上业务增长,该扩容而不是一味调大桶容量。这个从指标中倒推容量规划的角度,是很多刚接触限流的人容易忽略的关键一步。
