Java案例如何实现令牌桶限流?核心原理与高并发实战
目录导读
- 什么是令牌桶算法?它与漏桶有何本质区别?
- 令牌桶限流的Java核心实现步骤(附完整代码)
- 单机版 vs 分布式版:不同场景下的选型策略
- 如何通过参数调优实现精准限流(QPS、突发流量处理)
- 常见问题与问答:为什么你写的限流代码在高并发下会失效?
令牌桶限流的本质:你该知道的“放行规则”
核心概念:令牌桶限制的是“单位时间内的请求处理速率”,同时允许一定程度的突发流量,算法逻辑如下:

- 系统以恒定速率向桶中添加令牌(比如1秒加100个)
- 桶有最大容量(比如100个令牌),超过则丢弃
- 每个请求到达时,必须从桶中获取一个令牌,否则拒绝或排队
与漏桶的区别:漏桶强行控制“出口速率”,令牌桶则允许短时间内的流量爆发(只要桶内还有令牌),令牌桶更适合处理“秒杀、热点活动”等突发流量场景。
问答1:令牌桶限流能完全防止高并发下的系统崩溃吗?
不能,令牌桶只是“平滑流量”,但无法应对远超设计峰值N倍的攻击(例如DDoS),此时需要结合IP黑名单、WAF或熔断机制。
Java核心实现:从零手写一个令牌桶
以下案例实现一个单机版令牌桶,包含四大核心方法:init、acquire、tryAcquire、refill。
1 基础代码结构
public class TokenBucket {
private final long capacity; // 桶容量
private final long refillTokens; // 每次添加的令牌数
private final long refillIntervalMs; // 添加间隔(毫秒)
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private volatile long availableTokens; // 当前可用令牌
private volatile long lastRefillTime; // 上次添加时间
}
2 核心逻辑:定时自动添加令牌
通过ScheduledExecutorService每固定间隔向桶中填充令牌,并检查容量上限:
public void start() {
scheduler.scheduleAtFixedRate(() -> {
synchronized (this) {
long now = System.currentTimeMillis();
long elapsed = now - lastRefillTime;
if (elapsed >= refillIntervalMs) {
availableTokens = Math.min(capacity, availableTokens + refillTokens);
lastRefillTime = now;
}
}
}, 0, refillIntervalMs, TimeUnit.MILLISECONDS);
}
3 请求获取令牌:两种消费模式
阻塞消费:当令牌不足时,请求线程等待直到有可用令牌。
public synchronized boolean acquire(long waitMs) throws InterruptedException {
while (availableTokens <= 0) {
if (waitMs <= 0) {
return false;
}
wait(waitMs); // 释放锁,直到被唤醒或超时
}
availableTokens--;
return true;
}
非阻塞消费:返回false表示当前限流,请求需要被降级(例如返回错误码或排队)。
public boolean tryAcquire() {
synchronized (this) {
if (availableTokens > 0) {
availableTokens--;
return true;
}
return false;
}
}
重点优化:高并发场景下,synchronized会导致严重锁竞争,实际生产环境建议改用ReentrantLock + Condition,并使用striped标记分散锁粒度。
实战场景:如何通过参数调优实现精准限流
1 参数配置公式
假设你的系统能承受最大QPS=500,且希望允许200个突发请求(接口偶尔会有300%的短时爆发):
capacity = 200 // 突发容量:允许短时间内累积最多200个请求
refillTokens = 100 // 每100ms添加100个令牌
refillIntervalMs = 100
// 0.1s增加100个 => 每秒1000个令牌,但桶只放200个,所以实际QPS限制为500
2 高并发下的“踩坑”与修正
错误实现:在acquire方法中用Thread.sleep()等待令牌生成,这会导致所有请求线程同时阻塞,CPU空转且响应延迟剧增。
正确答案:使用等待/通知机制。
// 改进版:使用Lock + Condition实现更精准的等待唤醒
private final Lock lock = new ReentrantLock();
private final Condition tokensAvailable = lock.newCondition();
public boolean acquire(long timeoutMs) throws InterruptedException {
lock.lock();
try {
long remainingWait = timeoutMs;
while (availableTokens <= 0) {
if (remainingWait <= 0) {
return false;
}
long start = System.nanoTime();
tokensAvailable.await(remainingWait, TimeUnit.MILLISECONDS);
remainingWait -= (System.nanoTime() - start) / 1_000_000;
}
availableTokens--;
return true;
} finally {
lock.unlock();
}
}
生产级选型:自研 vs 第三方中间件
| 方案 | 适用场景 | 劣势 |
|---|---|---|
| 自研单机令牌桶 | 单体应用、小流量(<1000 QPS) | 分布式环境无法共享状态 |
| Guava RateLimiter | 简单可靠,允许突发 | 默认基于“漏桶+平滑模式”,不直接是令牌桶 |
| Redis + Lua 脚本 | 分布式限流,支持动态调整 | 网络延迟影响精确度,需防止热点Key |
| Sentinel | 阿里开源,支持流控+熔断+降级 | 学习成本高,需引入SDK |
问答2:如果使用Redis实现令牌桶,如何保证Lua脚本的原子性?
将令牌桶的查询、扣减、放入逻辑写在一个Lua脚本中,并通过
EVALSHA调用,Redis单线程执行脚本,保证原子操作,举例:-- KEYS[1] = 桶的key, ARGV[1] = 当前时间戳, ARGV[2]=容量 local tokens = redis.call('get', KEYS[1]) if not tokens or tonumber(tokens) == nil then tokens = 0 end local capacity = tonumber(ARGV[2]) -- 扣减逻辑... redis.call('set', KEYS[1], tokens) return tokens
问答与避坑指南
Q1:我的令牌桶为什么在并发1000时,QPS只能到300?
检查
refillTokens和refillIntervalMs是否匹配,常见错误是:每秒添加1000个令牌,但桶容量只有500,导致大量令牌被丢弃,实际可用只有500,解决方案:适当增大capacity。
Q2:是否需要为每个API单独配置令牌桶?
是的,不同接口的限流策略不同(如登录接口5次/分钟,下单接口500次/秒),建议利用Spring的
@Aspect切面,根据注解中的key(如方法名+用户ID)创建独立的令牌桶实例。
Q3:令牌桶算法在微服务中如何实现全局限流?
使用Redis + Lua实现分布式令牌桶,或以Sentinel集群模式配合Nacos动态配置规则。
Q4:高并发时“令牌桶 vs 滑动窗口”哪个更优?
令牌桶适合突发流量,滑动窗口适合严格均匀限流(如每秒钟固定100个请求),若需要同时支持两种场景,可采用“令牌桶+漏桶”混合算法,但会增加复杂度。
令牌桶限流是后端高并发系统中的“保命锦囊”,但需要根据业务特征(突发流量占比、QPS上限、可容忍延迟)精心调配参数,从单机版的自研代码到分布式场景下的Redis脚本,每一次实现都要思考“我的系统最怕什么?是瞬间打穿,还是均匀限流?”。
如果你正在构建一个千万级DAU的应用,建议直接采用Sentinel或自研基于Redis的算法,并且一定要配合压力测试验证——代码写得好,不如测到位。