《Java 服务端通信工程》8.1 令牌桶与漏桶

令牌桶的关键不是公式,而是时钟、burst 和拒绝语义。本节用 System.nanoTime 补桶,给出 tryAcquire 与返回等待时长的实现,讨论冷启动满桶、多实例配额分摊和 retry-after 的边界,并用漏桶对照说明什么时候该排队、什么时候该立即拒绝。

本节目标:把「200 rps、burst 400」变成一个可解释、可测试的桶,并知道它和漏桶各自该用在哪一层。
适用版本:JDK 25,纯 JDK 实现,不引入外部限流库。

承接的问题

7.x 的三件套保护的是「调用方不被下游拖死」。这一章换方向:请求本身太多时,怎么在入口把流量挡住。限流器要回答三个问题:允不允许这次请求、不允许时让客户端等多久、多实例部署时这个数字还准不准。

linkd-v3 的默认值是 200 rps、burst 400。200 rps 是稳态速率,400 是桶容量,也就是允许的瞬时突发。把这两个数字直接塞进一个固定窗口计数器也能跑,但窗口边界会放行双倍流量:窗口最后 1 ms 放 200 个,窗口翻转后又放 200 个,下游在 2 ms 内看到 400 个。令牌桶没有这个边界问题,它按连续时间补充,任何时刻的突发上限都由桶容量决定。

先明确一个容易混淆的点:令牌桶限制的是「放行速率」,不是「并发数」。一个请求被放行后可能执行 1 秒,期间桶继续按 200 rps 补充;如果没有并发限制,1 秒后可能有 200 个请求同时在跑。8.2 会用服务端并发上限补上这一层。

核心机制一:用单调时钟补桶

令牌桶的状态只有两个:当前令牌数 tokens 和上次补充时刻 lastNanos。每次请求到来时,先按经过的时间补令牌,再尝试扣减:

elapsedSeconds = (nowNanos - lastNanos) / 1e9
tokens = min(burst, tokens + elapsedSeconds * ratePerSecond)
lastNanos = nowNanos

nowNanos 必须来自 System.nanoTime()。currentTimeMillis 是墙上时钟,NTP 校时可能让它回拨几百毫秒甚至几秒;回拨时 elapsed 变成负数,令牌可能被冻结,也可能因为实现里的 Math.max(0, elapsed) 而少补。更糟的是向前跳变,一次补进大量令牌,限流器瞬间失效。nanoTime 没有绝对时间语义,但差值单调,正好适合「过了多久」。

补桶时要先封顶再扣减。如果先扣减再封顶,tokens 可能短暂超过 burst,下一次请求会多拿到令牌。公式里的 min 必须在加法之后、扣减之前。elapsed 为 0 或负数时直接返回,不要更新 lastNanos,否则会把「没有时间流逝」误记成一次补充。

tokens 用 double 而不是 long。200 rps 意味着每 5 ms 补 1 个令牌;如果只存整数,5 ms 内的多次请求会看到同样的令牌数,补充被截断,长期速率偏低。double 有精度误差,判断「够不够」时加一个很小的容差,例如 tokens + 1e-9 >= permits,避免 0.9999999999 被当成不足 1 个。

量类型说明
ratePerSeconddouble稳态速率,200 表示每秒 200 个令牌
burstdouble桶容量,400 表示最多攒 400 个
tokensdouble当前令牌数,范围 [0, burst]
lastNanoslong上次补充的单调时钟读数
permitsint一次请求消耗的令牌数,普通请求为 1

核心机制二:tryAcquire 与等待时长

限流器有两个常用接口:非阻塞的 tryAcquire 返回布尔值,适合「不允许就拒绝」;nanosUntil 返回「还差多久才有足够令牌」,适合在拒绝响应里带 Retry-After。两者都必须先补桶,保证读到的是同一时刻的状态。

package com.plume.linkd.v3.core;

public final class TokenBucket {

    private final double ratePerSecond;
    private final double burst;
    private double tokens;
    private long lastNanos;

    public TokenBucket(double ratePerSecond, double burst) {
        this(ratePerSecond, burst, System.nanoTime());
    }

    public TokenBucket(double ratePerSecond, double burst, long nowNanos) {
        if (!(ratePerSecond > 0) || !(burst > 0)) {
            throw new IllegalArgumentException("速率和容量必须为正");
        }
        this.ratePerSecond = ratePerSecond;
        this.burst = burst;
        this.tokens = burst;
        this.lastNanos = nowNanos;
    }

    public synchronized boolean tryAcquire(long nowNanos) {
        return tryAcquire(nowNanos, 1);
    }

    public synchronized boolean tryAcquire(long nowNanos, int permits) {
        if (permits < 1 || permits > burst) {
            return false;
        }
        refill(nowNanos);
        if (tokens + 1e-9 < permits) {
            return false;
        }
        tokens -= permits;
        return true;
    }

    public synchronized long nanosUntil(long nowNanos, int permits) {
        refill(nowNanos);
        double missing = permits - tokens;
        if (missing <= 0) {
            return 0L;
        }
        return (long) Math.ceil(missing / ratePerSecond * 1_000_000_000d);
    }

    public synchronized double available(long nowNanos) {
        refill(nowNanos);
        return tokens;
    }

    public synchronized long idleNanos(long nowNanos) {
        return Math.max(0L, nowNanos - lastNanos);
    }

    private void refill(long nowNanos) {
        long elapsed = nowNanos - lastNanos;
        if (elapsed <= 0L) {
            return;
        }
        tokens = Math.min(burst, tokens + elapsed / 1_000_000_000d * ratePerSecond);
        lastNanos = nowNanos;
    }
}

初始 tokens = burst 表示冷启动时桶是满的。对入口限流来说这是合理的:服务刚启动、下游空闲,允许一次 400 个请求的突发。对发往脆弱下游的限流来说,冷启动满桶可能正好把下游打挂;这类场景应该用 tokens = 0 或一个较小的初始值,让流量从零开始爬。选择哪一种不是代码问题,是策略问题,必须在构造器参数里写清楚。

permits > burst 直接返回 false,而不是算出「永远等不到」的等待时间。一次请求要的令牌比桶容量还大,无论等多久都不可能满足;返回一个巨大的 nanosUntil 只会让客户端做无意义的重试。

nanosUntil 的计算也有边界。missing / ratePerSecond 得到秒数,再乘 1e9 得到纳秒;ratePerSecond 很小时结果可能超过 long 的纳秒范围(约 292 年),虽然实际不会等那么久,但转换前的 double 已经失去精度。生产实现可以先把结果封顶到一个业务上限,例如 60 秒,超过就按 60 秒返回;客户端只需要知道「现在别来」,不需要精确到纳秒。

另一个细节是 permits 的类型。普通请求一次一个令牌,用 int 足够;如果以后要做「按响应字节数计费」,permits 会变成 long,tokens 仍然是 double,比较时要注意 long 到 double 的精度损失。当前契约只按请求计数,保持 int 更简单。

synchronized 的粒度是单个桶。不同租户、不同路由各有自己的桶,互不竞争;同一个桶的并发请求会排队几纳秒到几微秒,相对于一次 RPC 可以忽略。如果压测发现锁竞争明显,再考虑用 AtomicLong 存「已消耗的令牌数」做无锁版本,但要注意补桶与扣减的原子性,复杂度会上升一个量级。

核心机制三:漏桶对照

令牌桶和漏桶经常被当成同义词,其实它们的输出形状不同:

维度令牌桶漏桶(队列版)
输出速率稳态等于 rate,允许 burst 大小的突发恒定 rate,不允许突发
队列无有,容量固定
拒绝时机取不到令牌队列满
延迟不引入额外延迟排队会引入延迟
内存一个桶的状态队列里每个请求的状态
适合入口限流、保护自己出站整形、保护脆弱下游

漏桶的「漏」是恒定速率:不管上面倒多快,下面每秒只漏 rate 个。队列版漏桶把请求排进有界队列,按固定间隔取出执行;队列满时拒绝。它把突发抹平,代价是延迟。令牌桶允许突发,代价是下游要能承受 burst 大小的瞬时压力。

还有一个常被忽略的变体:漏桶的「计量版」没有队列,只维护一个计数器,每个时间片减一,请求到来时加一,超过容量就拒绝。它和令牌桶在数学上对偶,但实现上仍然是固定时间片,边界效应没有完全消失。真正要平滑输出,还是要有队列或精确的调度器。

linkd-v3 的选择是:入口用令牌桶(200 rps / burst 400),因为它要保护自己、又要允许正常突发;发往 libraryd 的写路径如果发现下游对突发敏感,再在其前面加一个容量很小的漏桶队列,把写请求按固定速率放出去。两层的参数不能拍脑袋,要用 14 章的压测剖面校准。

在 linkd-v3 里落地:多租户桶与本地配额

入口限流通常按租户分桶:一个租户刷爆自己的配额,不应该影响其他租户。x-tenant-id 是项目契约里的 metadata 键,服务端拦截器从 Metadata 里取出它,查对应的桶。

package com.plume.linkd.v3.core;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

public final class TenantRateLimiter {

    private final ConcurrentHashMap<String, TokenBucket> buckets =
            new ConcurrentHashMap<>();
    private final int maxEntries;
    private final double ratePerSecond;
    private final double burst;
    private final AtomicLong rejected = new AtomicLong();

    public TenantRateLimiter(int maxEntries, double ratePerSecond, double burst) {
        if (maxEntries < 1) {
            throw new IllegalArgumentException("maxEntries 必须为正");
        }
        this.maxEntries = maxEntries;
        this.ratePerSecond = ratePerSecond;
        this.burst = burst;
    }

    public Decision acquire(String tenantId, long nowNanos) {
        TokenBucket bucket = buckets.get(tenantId);
        if (bucket == null) {
            if (buckets.size() >= maxEntries && !evictIdle(nowNanos)) {
                rejected.incrementAndGet();
                return new Decision(false, 1_000_000_000L);
            }
            bucket = buckets.computeIfAbsent(tenantId,
                    key -> new TokenBucket(ratePerSecond, burst, nowNanos));
        }
        if (bucket.tryAcquire(nowNanos)) {
            return new Decision(true, 0L);
        }
        rejected.incrementAndGet();
        return new Decision(false, Math.max(1L, bucket.nanosUntil(nowNanos, 1)));
    }

    public long rejectedCount() {
        return rejected.get();
    }

    private boolean evictIdle(long nowNanos) {
        int before = buckets.size();
        buckets.entrySet().removeIf(entry ->
                entry.getValue().idleNanos(nowNanos) > 300_000_000_000L);
        return buckets.size() < before || buckets.size() < maxEntries;
    }

    public record Decision(boolean granted, long retryAfterNanos) {
    }
}

maxEntries 必须有。每个新租户建一个桶,攻击者用大量伪造的 x-tenant-id 就能把堆撑爆;evictIdle 在表满时清掉 5 分钟没访问的桶,避免长期运行后 map 只增不减。表满且没有可回收项时,对新租户返回拒绝,这是失败关闭;如果业务更看重可用性,可以改为放行并打日志,但要明确这是策略选择,不是实现细节。

多实例是本地限流的固有缺陷。3 个 linkd-v3 实例各自配置 200 rps,合起来是 600 rps,下游看到的不是 200。两种处理方式:按实例数分摊,每个实例配 200 / 3 ≈ 67 rps、burst / 3;或者用共享存储(Redis 等)做全局计数,代价是每次请求一次远程调用。linkd-v3 先用分摊,因为配额本来就是近似值,实例数变化时通过配置中心滚动更新;等业务要求精确配额,再引入共享计数。

retryAfterNanos 要转成整数秒放进 Retry-After。向上取整,至少 1 秒;如果服务端支持 google.rpc.RetryInfo,把同样的时长放进 detail,客户端就能按服务端给的时间重试,而不是盲目退避。8.2 会给出完整的拦截器和错误响应。

可观测性上至少要有三个量:

  • 当前令牌数:长期贴着 0 说明配额偏小或流量超标。
  • 拒绝计数:按 tenant、route 两个维度打标签,事后才能定位是谁把桶打空的。
  • 等待时长分布:nanosUntil 的返回值直接进直方图,用来校准 Retry-After。

指标名和标签口径放到 11.1 统一,这里只要求拒绝路径不要只打一行文本日志。

常见错误与边界

错误一:用 currentTimeMillis 补桶。 NTP 回拨会让 elapsed 变负,令牌冻结;向前跳变会一次补进大量令牌,限流瞬间失效。只用 nanoTime 的差值。

错误二:tokens 用 long。 200 rps 下每 5 ms 才补 1 个令牌,整数截断会让长期速率明显低于配置值。用 double,判断时加容差。

错误三:先扣减再封顶。 tokens 可能短暂超过 burst,下一次请求多拿令牌。顺序必须是「补桶 → 封顶 → 判断 → 扣减」。

错误四:burst 设成等于 rate。 这样桶只能攒 1 秒的令牌,任何超过 1 秒的突发都会被拒。burst 应该按下游能承受的瞬时压力设置,通常是 rate 的 1–5 倍;400 / 200 = 2 是 linkd-v3 的选择。

错误五:限流 map 没有上限。 每个新租户一个桶,攻击者用伪造租户 ID 就能把堆撑爆。maxEntries、空闲回收、定时扫描三件套缺一不可;只做 removeIf 不做上限,仍然会在扫描间隔内被灌满。

错误六:429 不带 Retry-After。 客户端只能盲目重试,放大的流量会把刚恢复的桶再次打满。等待时长按最慢的那个桶算,向上取整到秒。

边界:令牌桶不是精确的 QPS 计量。 burst=400 时,一个空闲很久的桶可以瞬间放行 400 个请求。要严格平滑,用漏桶或排队;要防突发,调小 burst。这不是实现误差,是令牌桶的定义。

边界:本地桶不跨实例。 多实例部署时,每个实例独立计数,合计速率是单实例配置乘以实例数。要么分摊配额,要么用共享存储;不要把单机数字当成全局承诺。

边界:限流不替代容量规划。 限流只决定「拒绝谁」,不增加下游容量。把 200 rps 设得比压测出的安全值高,只是把雪崩推迟几秒。

小结

  • 令牌桶状态只有 tokens 和 lastNanos,用 System.nanoTime 的差值补桶,currentTimeMillis 会被 NTP 回拨破坏。
  • 补桶公式是 tokens = min(burst, tokens + elapsed * rate);先封顶再判断,tokens 用 double 并留浮点容差。
  • 初始满桶允许冷启动突发;对脆弱下游要改成空桶起步,这是策略而不是实现细节。
  • tryAcquire 非阻塞拒绝,nanosUntil 给出等待时长;permits > burst 直接失败,不要算出永远等不到的时间。
  • 漏桶用有界队列和恒定输出速率抹平突发,代价是延迟;入口用令牌桶,出站整形用漏桶。
  • 按租户分桶要有 maxEntries 和空闲回收;多实例时按实例数分摊配额,或引入共享计数。

令牌桶限制的是「每秒放行多少」,它不关心放行后的请求跑多久。200 rps 的流量如果每个请求都卡 1 秒,稳态仍有 200 个请求在跑。8.2 在服务端加并发上限,把「速率」和「并发」两层分开。

阅读导航:上一节 7.3 Bulkhead 与线程隔离 | 下一节 8.2 服务端并发限制

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收