令牌桶限流怎么实现?从原理到实战,一篇讲透高并发保护机制
目录导读
- 为什么需要令牌桶限流?
- 令牌桶算法的核心工作原理
- 手写一个令牌桶实现(Java版)
- 经典框架中的令牌桶实现:Guava RateLimiter 解析
- 分布式场景下的令牌桶挑战与解决方案
- 常见QA:你的疑惑我来答
为什么需要令牌桶限流?
在互联网高并发场景中,系统往往无法承受瞬间的流量洪峰,例如一个电商秒杀活动,如果每秒请求量从1000突增至10万,后端服务很可能直接崩溃。令牌桶算法就是在这种背景下诞生的“流量调节器”。

与简单的计数器法(固定窗口)相比,令牌桶能允许一定程度的突发流量,同时保证长期的平均速率不超过阈值,这使其成为业界最主流的限流算法之一,广泛应用于 API 网关、微服务、数据库连接池等场景。
核心优势:既能平滑流量,又不会完全拒绝突发请求(只要桶里还有令牌)。
令牌桶算法的核心工作原理
1 三个核心要素
| 要素 | 说明 |
|---|---|
| 令牌桶容量(capacity) | 桶最多能存放的令牌数,控制最大突发量 |
| 令牌生成速率(rate) | 每秒向桶中放入的令牌数 |
| 当前令牌数(tokens) | 桶中实时剩余的令牌数 |
2 执行流程
- 初始化:桶中填满令牌(通常等于容量)
- 周期性放入:每隔
1/rate秒放入一个令牌,但不超过容量上限 - 请求处理:每个请求到达时,从桶中取出一个令牌:
- 若桶中有令牌 → 取走并放行请求
- 若桶中无令牌 → 拒绝请求或等待
3 为什么允许突发流量?
假设桶容量为100,速率为10/s,当系统闲置10秒后,桶会积累100个令牌,此时若突然涌入100个请求,它们可以全部被放行(桶中令牌耗尽),相当于在1秒内处理了100个请求——这就是突发能力,之后请求只能以10/s的速率被处理,直到桶中令牌重新积累。
对比漏桶算法:漏桶以恒定速率出水,不允许突增,而令牌桶则恰好相反。
手写一个令牌桶实现(Java版)
public class TokenBucket {
private final long capacity; // 桶容量
private final double refillRate; // 每秒填充令牌数
private double tokens; // 当前令牌数
private long lastRefillTime; // 上次填充时间戳
public TokenBucket(long capacity, double refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.tokens = capacity;
this.lastRefillTime = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
refill(); // 先补充令牌
if (tokens >= 1) {
tokens -= 1;
return true;
}
return false;
}
private void refill() {
long now = System.currentTimeMillis();
double elapsed = (now - lastRefillTime) / 1000.0; // 转为秒
double newTokens = elapsed * refillRate;
if (newTokens > 0) {
tokens = Math.min(capacity, tokens + newTokens);
lastRefillTime = now;
}
}
}
使用示例
TokenBucket bucket = new TokenBucket(10, 1); // 桶容量10,每秒补充1个
for (int i = 0; i < 20; i++) {
boolean allowed = bucket.tryAcquire();
System.out.println("请求 " + i + (allowed ? " 放行" : " 限流"));
Thread.sleep(50);
}
前10个请求会被立即放行(吃完了初始的10个令牌),后续请求每1秒只能放行1个。
经典框架中的令牌桶实现:Guava RateLimiter 解析
Google Guava 库中的 RateLimiter 是令牌桶的标志性实现,但它不是严格的时间戳预计算,而是采用平滑突发限流(SmoothBursty) 模式,其核心设计更巧妙:
1 核心设计思想
- 不维护“当前令牌数”,而是维护 下次可用令牌的时间点(
storedPermits与storedPermits的最大存储时间) - 采用 预支付(credit)机制:即使令牌不足,也可以允许请求通过,但下一次请求必须等待“还债”
2 关键方法对比
| 方法 | 行为 | 适用场景 |
|---|---|---|
tryAcquire() |
立即返回是否能通过 | 非阻塞判断 |
acquire() |
阻塞直到获取到令牌 | 必须执行的请求 |
tryAcquire(timeout, unit) |
在指定时间内等待令牌 | 带超时的重试 |
3 平滑预热限流(SmoothWarmingUp)
Guava 还提供了 WarmUp 模式:
- 刚启动时,限流速率较慢(如 2/s),然后逐渐升到目标速率(10/s)
- 避免了冷启动时令牌瞬间被消耗完,导致后端雪崩
RateLimiter limiter = RateLimiter.create(10, 1, TimeUnit.SECONDS);
for (int i = 0; i < 10; i++) {
limiter.acquire(); // 阻塞等待直到获取令牌
System.out.println("执行第 " + i + " 个请求");
}
分布式场景下的令牌桶挑战与解决方案
单机令牌桶很容易实现,但在微服务、分布式集群中,需要全局共享令牌状态,如何确保多个节点之间令牌的一致性?
常见的方案有以下三种:
Redis + Lua 脚本
利用 Redis 的单线程特性,通过 Lua 脚本原子操作一个 key 中的令牌数:
-- 令牌桶 Lua 脚本
local key = KEYS[1]
local capacity = ARGV[1] -- 桶容量
local rate = ARGV[2] -- 每秒补充令牌数
local now = tonumber(ARGV[3]) -- 当前时间戳(毫秒)
local bucket = redis.call('hgetall', key)
local tokens, lastTime
if #bucket == 0 then
tokens = capacity
lastTime = now
else
tokens = tonumber(bucket[2])
lastTime = tonumber(bucket[4])
end
-- 补充令牌
local elapsed = (now - lastTime) / 1000
local newTokens = elapsed * rate
tokens = math.min(capacity, tokens + newTokens)
lastTime = now
-- 判断是否允许请求
if tokens >= 1 then
tokens = tokens - 1
redis.call('hmset', key, 'tokens', tokens, 'lastTime', lastTime)
return 1 -- 放行
else
redis.call('hmset', key, 'tokens', tokens, 'lastTime', lastTime)
return 0 -- 限流
end
- 优点:原子性、跨进程共享
- 缺点:一次限流多一次 Redis 调用,性能有一定损耗
集中式限流中间件
- Sentinel(阿里):支持令牌桶热点限流,提供控制台管理规则
- Nginx + lua-resty-limit-traffic:在高性能代理层做限流
- Kong / APISIX:API 网关内置令牌桶插件
客户端令牌预分配
每个节点预先从中心拉取一批令牌到本地内存,使用完后再申请,减少了中心依赖,但存在令牌浪费(节点宕机时预分配令牌丢失)。
常见QA:你的疑惑我来答
Q1:令牌桶和漏桶的区别是什么?
| 算法 | 流量类型 | 核心特点 |
|---|---|---|
| 令牌桶 | 允许突发 | 短期可超过限制,长期平均 |
| 漏桶 | 强制平滑 | 泄漏速率恒定,不允许抖动 |
一句话总结:令牌桶“给令牌,能排队”;漏桶“匀速泄洪,不允许积压”。
Q2:桶容量应该设置多大?
取决于系统能容忍的瞬时最高 QPS,假设系统能安全处理 200 QPS,但接口希望平滑到 50 QPS,
- 如果桶容量 = 40:可以承受连续 40 个突发请求
- 一般推荐容量 =
容忍最大突发时间 × 速率,例如容忍 2 秒突发,速率 50/s → 容量 100
Q3:为什么不能直接用 Java 的 synchronized 做分布式限流?
synchronized 只能保证单 JVM 内的线程安全,如果服务部署了 5 个节点,每个节点各自维护令牌桶,那么总 QPS 最高可达 5 × 单机阈值,失去了限流意义,必须使用分布式协调组件。
Q4:令牌桶能用于“按用户限流”吗?
可以,只需在键中加上用户 ID,bucket:user:12345,每个用户独立一个桶,但注意 Redis 内存占用,建议设置 TTL 自动清理长期未使用的用户桶。
Q5:如果请求需要等待令牌,用什么实现?
- 本地场景:使用
Semaphore+ 定时补充线程,或 Guava 的acquire()方法(会阻塞当前线程) - 分布式场景:用 Redis 实现“令牌等待队列”比较复杂,建议直接返回 429,让客户端重试,如果想等待,可以结合 Redis 的
BLPOP和发布订阅机制来实现通知。
写在最后:令牌桶算法不仅仅是一个公式,它是高并发系统设计的艺术——既不让系统被突增流量冲垮,也不要错过真正的商业机会,从单机手写到 Redis Lua 脚本,从 Guava 到 Sentinel,理解其核心思想后,你就能在各种框架中游刃有余地选择最适合的方案。
如果你在实战中遇到其他限流问题,欢迎留言交流。