Java案例:从零实现高并发令牌桶算法——源码与场景解析
目录导读
- 令牌桶算法核心原理
- 为什么Java项目需要令牌桶?
- 经典实现:基于定时任务的令牌桶
- 高性能实现:基于时间戳计算的令牌桶
- 完整Java代码案例与单元测试
- 常见问题问答(Q&A)
- 总结与最佳实践
令牌桶算法核心原理
令牌桶是一种流量控制算法,它通过维护一个恒定速率生成令牌的“桶”来限制请求速率。
关键参数包括:

- 容量(capacity):桶的最大令牌数,即突发流量上限。
- 速率(rate):每秒生成的令牌数。
- 当前令牌数(currentTokens):实时更新的可用令牌。
当请求到达时,算法检查桶中是否有令牌:
- 若有,则获取一个令牌并放行。
- 若无,则拒绝请求(或等待)。
这种机制既能平滑突发流量,又能保证长期平均速率,非常适合API限流、数据库连接池保护等场景。
为什么Java项目需要令牌桶?
在微服务架构中,流量控制是避免系统雪崩的关键,令牌桶相比其他算法(如漏桶、计数器)的独特优势:
- 允许突发:桶中可积攒令牌,应对瞬时高峰。
- 精确控制:令牌生成速率固定,易于配置。
- 资源友好:Java中只需一个AtomicLong和一个定时任务,CPU消耗极低。
常见业务场景:
- 对外API接口限流(如每分钟1000次调用)
- 秒杀系统的商品库存令牌分发
- 消息队列的消费速率限制
经典实现:基于定时任务的令牌桶(ScheduledExecutorService)
最直观的方式是使用ScheduledExecutorService定期向桶中添加令牌:
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
public class TokenBucketBasic {
private final AtomicLong tokens = new AtomicLong(0);
private final long capacity;
private final long rate;
public TokenBucketBasic(long capacity, long rate) {
this.capacity = capacity;
this.rate = rate;
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
long current = tokens.get();
if (current < capacity) {
tokens.incrementAndGet();
}
}, 0, 1000 / rate, TimeUnit.MILLISECONDS);
}
public boolean tryAcquire() {
while (true) {
long current = tokens.get();
if (current <= 0) return false;
if (tokens.compareAndSet(current, current - 1)) return true;
}
}
}
问题:定时任务粒度不精确,且高并发下CAS循环可能造成CPU空转。
高性能实现:基于时间戳计算的令牌桶(无锁)
为了避免定时任务的开销,改用请求时计算的方式:根据上一次更新时间自动补充令牌。
public class TokenBucketEfficient {
private final long capacity;
private final long rate; // 令牌/秒
private double currentTokens;
private long lastRefillTime = System.currentTimeMillis();
public TokenBucketEfficient(long capacity, long rate) {
this.capacity = capacity;
this.rate = rate;
this.currentTokens = capacity; // 初始满桶
}
public synchronized boolean tryAcquire(long tokensToConsume) {
refill();
if (currentTokens >= tokensToConsume) {
currentTokens -= tokensToConsume;
return true;
}
return false;
}
private void refill() {
long now = System.currentTimeMillis();
long elapsed = now - lastRefillTime;
double newTokens = elapsed * rate / 1000.0;
currentTokens = Math.min(capacity, currentTokens + newTokens);
lastRefillTime = now;
}
}
优势:无需额外线程,性能优于定时方案;synchronized在低并发下足够,高并发可替换为ReentrantLock。
完整Java代码案例与单元测试
以下是一个完整可运行的限流器,包含默认单次请求消耗1个令牌的能力:
public class TokenBucketLimiter {
// ... 使用上面高效实现的构造函数和tryAcquire方法
public boolean tryAcquire() {
return tryAcquire(1);
}
public static void main(String[] args) throws InterruptedException {
TokenBucketLimiter limiter = new TokenBucketLimiter(10, 5); // 桶容量10,每秒5个令牌
// 模拟请求
for (int i = 0; i < 20; i++) {
boolean allowed = limiter.tryAcquire();
System.out.println("Request " + i + ": " + (allowed ? "✅ 通过" : "❌ 限流"));
Thread.sleep(100); // 每100ms发一次请求
}
}
}
JUnit单元测试建议:
- 测试容量上限:连续获取超过容量的令牌,应返回false。
- 测试速率:在1秒内尝试获取超过rate次的请求,部分应被拒绝。
常见问题问答(Q&A)
Q1:令牌桶和漏桶的核心区别是什么?
A:
- 令牌桶允许突发:桶内令牌可积攒,并发请求可一次性消耗。
- 漏桶强制平滑:以固定速率流出,拒绝突发。
- 应用场景:令牌桶适合有超时排队机制的API,漏桶适合数据库等必须匀速的写入操作。
Q2:分布式场景下如何实现令牌桶?
A:需要借助Redis的LUA脚本保证原子性,核心逻辑:
- 使用
GET获取当前令牌数和时间戳。 - 计算应补充的令牌数。
- 用
SET更新令牌数。
参考代码结构(伪代码):local key = KEYS[1] local current = redis.call('GET', key) -- 补充逻辑... redis.call('SET', key, newTokens)
Q3:如何防止令牌桶在微服务间“惊群”?
A:采用本地令牌桶 + 动态配额同步方案,每个服务实例持有独立桶,但限额由中心配置下发(如通过配置中心每秒调整速率),避免所有实例同时从Redis拉取令牌。
Q4:Java中如何优化CAS失败率?
A:
- 使用
LongAdder替代AtomicLong,减少CAS竞争。 - 或采用分段锁(Striped Lock)控制不同令牌区段。
- 实际测试表明:对于单机万级QPS,
AtomicLong已足够;百万QPS建议改用无锁时间戳方案(如第4节代码)。
总结与最佳实践
- 优先选择时间戳计算方式:无额外线程、避免定时误差。
- 容量设置建议:
capacity = rate * 2(允许2秒的突发量)。 - 监控关键指标:令牌桶空置率(
emptyRate)、请求拒绝率。 - Java并发工具选择:
- 低并发(<1K QPS):
synchronized。 - 中并发(1K~10K QPS):
AtomicLong+CAS。 - 高并发(>10K QPS):时间戳计算无锁方案或
LongAdder。
- 低并发(<1K QPS):
通过合理设计与测试,令牌桶能在Java应用中有效抵御突发流量,同时保证系统稳定性,如果需要在大型分布式系统中落地,可参考Guava的RateLimiter底层实现,或结合Redis做全局限流。