用 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)线上常见坑与修正建议

  1. 冷启动策略 初始化令牌数是满桶还是 0?

  2. 满桶:允许上线后短时间突发(更友好)

  3. 0:更保守,但可能影响刚启动时的可用性 多数业务用“满桶”更合理。

  4. 纳秒时间源选择 System.currentTimeMillis() 可能被 NTP 校时影响回拨,导致补给异常。 System.nanoTime() 是单调递增,更适合做时间差计算。

  5. 锁粒度 synchronized 足够简单可靠;如果你在超高并发下需要极致性能,可以考虑 CAS + 原子变量,但复杂度会大幅提高,且更容易出错。

  6. 多实例一致性 本文是单机限流。多实例部署时,每台机器各限各的,总量会放大。 解决方式通常是:在网关层统一限流,或使用 Redis/本地令牌分片等方案。


总结

令牌桶的价值不在“公式多漂亮”,而在 可预测的系统保护能力。用“按时间差补给”的方式实现令牌桶,可以避免后台线程漂移,也让限流逻辑更贴近真实工程需求。你可以先把这个版本落地到单点服务或网关,再根据业务规模演进到分布式限流。

评论 0