用 Java 手写一个轻量级限流器(Token Bucket)并发实战
用 Java 手写一个轻量级限流器(Token Bucket)并发实战
很多后端接口在上线后才发现:真正压垮系统的不是“峰值 QPS”,而是短时间的突发流量(burst)。如果没有限流,线程池、数据库连接池、下游服务都会被瞬间打满。本文用 Java 从零实现一个 令牌桶(Token Bucket)限流器,重点放在并发正确性、时间精度、以及如何在真实项目里落地使用。
1)令牌桶模型怎么理解
令牌桶的核心思想是:系统以固定速率往桶里放令牌(token),桶有容量上限。每次请求到来时尝试取走 1 个令牌:
- 桶里有令牌:请求通过
- 桶里没令牌:请求被拒绝(或等待)
这种模型非常适合“平均速率可控,但允许短时间突发”的场景,比如:登录、短信、下单、支付回调等。
2)并发实现要点:别用 sleep 硬补
很多示例会用一个线程每隔 N 毫秒 sleep 然后加 token。这样做的问题是:
- 线程调度不稳定:sleep 不是精确定时
- 多实例部署难以统一
- 实现复杂,还要处理停止与漂移
更好的方式是:请求到来时按时间差“计算应该补多少令牌”,这样不依赖后台线程,性能也更可控。
3)Java 实现:基于时间差的令牌补给
下面是一个可直接用的单机版本,使用 System.nanoTime() 提高计时精度,并用 synchronized 保证并发正确性(简单可靠,适合多数场景)。
import java.util.concurrent.TimeUnit;
public class TokenBucketLimiter {
private final long capacity; // 桶容量
private final double tokensPerNanos; // 每纳秒生成多少 token
private double tokens; // 当前令牌数(double 允许小数累积)
private long lastRefillTime; // 上次补给时间(nanoTime)
public TokenBucketLimiter(long capacity, long tokensPerSecond) {
if (capacity <= 0 || tokensPerSecond <= 0) {
throw new IllegalArgumentException("capacity/tokensPerSecond must be > 0");
}
this.capacity = capacity;
this.tokensPerNanos = tokensPerSecond / (double) TimeUnit.SECONDS.toNanos(1);
this.tokens = capacity; // 初始化满桶,避免冷启动过严
this.lastRefillTime = System.nanoTime();
}
// 尝试获取 1 个令牌,成功返回 true,否则 false
public synchronized boolean tryAcquire() {
refill();
if (tokens >= 1.0) {
tokens -= 1.0;
return true;
}
return false;
}
// 计算按时间差补给的 token
private void refill() {
long now = System.nanoTime();
long elapsed = now - lastRefillTime;
if (elapsed <= 0) return;
double add = elapsed * tokensPerNanos;
if (add > 0) {
tokens = Math.min(capacity, tokens + add);
lastRefillTime = now;
}
}
// 便于观察当前桶状态(调试用)
public synchronized double getTokens() {
refill();
return tokens;
}
}
为什么用 double? 因为补给是“按时间差连续计算”的,可能一次只补 0.2 个 token。用 double 可以累积这些小数,避免被整数截断导致限流偏严。
4)怎么在业务里用:过滤器 / 拦截器
以伪代码形式说明用法(你可以放到 Servlet Filter、Spring HandlerInterceptor、网关插件里):
public class ApiRateLimitDemo {
private static final TokenBucketLimiter limiter =
new TokenBucketLimiter(50, 20); // 容量50,速率20/s
public static void handleRequest(String path) {
if (!limiter.tryAcquire()) {
// 这里建议返回 429 Too Many Requests
System.out.println("429 Too Many Requests: " + path);
return;
}
// 正常执行业务
System.out.println("200 OK: " + path);
}
public static void main(String[] args) {
for (int i = 0; i < 200; i++) {
handleRequest("/api/order/create");
}
}
}
这里的逻辑非常直接:拿到令牌就放行,拿不到就拒绝。如果你希望“排队等待”,可以扩展一个 acquire(timeout),在超时时间内循环尝试获取(但要注意会增加线程占用)。
5)线上常见坑与修正建议
-
冷启动策略 初始化令牌数是满桶还是 0?
-
满桶:允许上线后短时间突发(更友好)
-
0:更保守,但可能影响刚启动时的可用性 多数业务用“满桶”更合理。
-
纳秒时间源选择
System.currentTimeMillis()可能被 NTP 校时影响回拨,导致补给异常。System.nanoTime()是单调递增,更适合做时间差计算。 -
锁粒度
synchronized足够简单可靠;如果你在超高并发下需要极致性能,可以考虑 CAS + 原子变量,但复杂度会大幅提高,且更容易出错。 -
多实例一致性 本文是单机限流。多实例部署时,每台机器各限各的,总量会放大。 解决方式通常是:在网关层统一限流,或使用 Redis/本地令牌分片等方案。
总结
令牌桶的价值不在“公式多漂亮”,而在 可预测的系统保护能力。用“按时间差补给”的方式实现令牌桶,可以避免后台线程漂移,也让限流逻辑更贴近真实工程需求。你可以先把这个版本落地到单点服务或网关,再根据业务规模演进到分布式限流。
评论 0