《Java 并发编程·性能诊断与事故》5.1 Little's law 在池化中的应用

线程池该开多大,不该靠手感。本节从 L = λW 的计数推导讲起,用一次开环定速到达的实验校核「系统内请求数」与「吞吐乘延迟」是否相等,给出算所需并发、反推吞吐上限、判断余量三种用法,以及一张从假设到结论的容量估算表,并说明稳态、有界、不区分分位这三个前提。

承接的问题

构造场景:libraryd-conc 的借阅查询接口上线后,监控面板上是这组数:QPS 800 上下,P99 320 ms,线程池固定 32 个线程,活跃线程常驻 30 以上,ArrayBlockingQueue(256) 偶尔堆到 80 个。值班的判断是「线程不够,加到 64」;另一位说「延迟高是下游慢,加线程只会更慢」。两种说法都没给依据,谁也说服不了谁。

要判断谁对,得先回答一个更基础的问题:在 QPS 800、单请求 45 ms 的情况下,系统里本来应该有多少个请求在跑? 这个问题有一个不需要假设服务时间分布、也不需要假设到达是泊松的答案,它只做一件事:数人头。

三个量先对齐口径

符号含义在池化系统里对应什么
λ完成速率(请求/秒)吞吐。注意是完成速率,不是到达速率;过载时两者不相等
W单个请求在系统内停留的时间端到端延迟,含排队;不是纯服务时间
L系统内同时在途的请求数活跃线程 + 队列中的任务 + 已发出未返回的下游调用

三个口径最容易错的地方是「系统」的边界。如果请求已经交给下游 HTTP 客户端,但下游还没返回,这个请求仍然占着一个线程、仍然算在 L 里——它没有离开系统。反过来,如果代码把任务丢给线程池之后立刻返回给调用方(fire-and-forget),那调用方的 W 里不包含它,但池子里的 L 里包含它。同一次观测里,L 和 W 必须取同一个边界,否则算出来的数一定对不上,而且错得很隐蔽。

L = λW 的推导

设观测窗口是 [0, T],窗口内完成的请求有 N 个,第 i 个请求在系统里待了 W_i。把所有请求的停留时间加起来,得到总的「请求·秒」:

Σ W_i

换一个角度看同一段时间:每个时刻 t 系统里有 L(t) 个请求,把它们对时间积分,得到的也是总的「请求·秒」:

∫₀ᵀ L(t) dt = Σ W_i

两边同除以 T:

(1/T) ∫₀ᵀ L(t) dt = (N/T) × (Σ W_i / N)
       平均 L      =    λ    ×    平均 W

这就是 Little’s law。推导里没有任何分布假设:到达不必是泊松,服务时间不必是指数,只要三个平均值取自同一段稳态窗口,等式就成立。这一点很关键,因为它意味着这个式子可以用来校验你的监控数据:如果监控上 L 和 λW 差了三倍,不是公式失效,是三个数里至少有一个口径错了。

它同时有三个前提,缺一个就不成立:

  1. 稳态。窗口内 λ 和 W 不漂移。压测的前两秒、故障恢复的头十秒都不是稳态,那段时间的 L 平均值没有意义。
  2. 有界。L(t) 必须有限。队列无界且消费慢于生产时,积分发散,等式左右都是无穷大——这不是公式的问题,是系统已经不在「能用公式描述」的区域了。
  3. 守恒。窗口内不许有「黑户」:超时返回给调用方但后台仍在跑的任务、被线程池丢弃但没有记账的任务,都还在占 L,却不计入 λ。这类泄漏会让等式右小左大。

还有一条最常被忽略的限制:L = λW 只约束均值,不约束分位数。用 P99 延迟代入公式算并发,是这一类错误里最常见的一种。

一次可运行的校核

把公式变成可以打脸的实验:固定 8 个工作线程,服务时间名义 20 ms,用开环定速发生器按 100/200/300 请求每秒投递,跑 8 秒。同时用采样线程每 5 ms 读一次「队列长度 + 活跃线程数」,对时间取平均,得到 L_sample;再由完成的请求数算 λ、由每个请求的提交到完成时间算 W,得到 L_calc = λW。

import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.concurrent.locks.LockSupport;

public class LittleLawProbe {
    static final int POOL = 8;
    static final long SERVICE_NANOS = 20_000_000L;   // 名义 20 ms 服务
    static final long RUN_NANOS = 8_000_000_000L;

    public static void main(String[] args) throws Exception {
        for (int rate : new int[]{100, 200, 300}) {
            run(rate);
        }
    }

    static void run(int arrivalPerSec) throws Exception {
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                POOL, POOL, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
        pool.prestartAllCoreThreads();

        LongAdder completed = new LongAdder();
        LongAdder sumNanos = new LongAdder();
        AtomicBoolean running = new AtomicBoolean(true);
        LongAdder lSum = new LongAdder();
        LongAdder lCount = new LongAdder();

        long t0 = System.nanoTime();
        Thread sampler = Thread.ofPlatform().start(() -> {
            while (running.get()) {
                lSum.add(pool.getQueue().size() + pool.getActiveCount());
                lCount.increment();
                LockSupport.parkNanos(5_000_000L);
            }
        });

        long interval = 1_000_000_000L / arrivalPerSec;
        Thread pacer = Thread.ofPlatform().start(() -> {
            long start = System.nanoTime();
            long deadline = start + RUN_NANOS;
            for (long k = 0; ; k++) {
                long target = start + k * interval;
                if (target >= deadline) {
                    break;
                }
                if (target - System.nanoTime() > 200_000L) {
                    LockSupport.parkNanos(target - System.nanoTime() - 100_000L);
                }
                while (System.nanoTime() < target) {
                    Thread.onSpinWait();
                }
                long submitAt = System.nanoTime();
                pool.execute(() -> {
                    LockSupport.parkNanos(SERVICE_NANOS);
                    sumNanos.add(System.nanoTime() - submitAt);
                    completed.increment();
                });
            }
        });

        pacer.join();
        while ((pool.getQueue().size() > 0 || pool.getActiveCount() > 0)
                && System.nanoTime() - t0 < 13_000_000_000L) {
            Thread.sleep(50);
        }
        long elapsed = System.nanoTime() - t0;
        running.set(false);
        sampler.join();
        pool.shutdownNow();

        long done = completed.sum();
        double lambda = done / (elapsed / 1e9);
        double wMillis = sumNanos.sum() / (double) done / 1e6;
        double lSample = lSum.sum() / (double) lCount.sum();
        System.out.printf("target=%d/s actual=%.1f/s W=%.2fms L_sample=%.2f L_calc=%.2f%n",
                arrivalPerSec, lambda, wMillis, lSample, lambda * wMillis / 1000.0);
    }
}

注意采样线程读的是 getQueue().size() + getActiveCount(),这两个值不是原子快照,中间可能有一次交接,所以 L_sample 本身带一点误差。这不影响量级判断,但不能拿它做小数点后两位的比较。

实测:两个数对上了

本机 Apple M1 Pro 10 核 / 32 GB,Temurin 25.0.5+8,load average 62–67。两次独立运行:

target=100/s actual=99.3/s W=27.05ms L_sample=2.69 L_calc=2.69
target=200/s actual=198.7/s W=26.91ms L_sample=5.45 L_calc=5.35
target=300/s actual=289.7/s W=165.49ms L_sample=48.25 L_calc=47.95

--- 第二次运行 ---
target=100/s actual=99.2/s W=27.33ms L_sample=2.75 L_calc=2.71
target=200/s actual=198.6/s W=27.40ms L_sample=5.59 L_calc=5.44
target=300/s actual=291.7/s W=90.36ms L_sample=26.66 L_calc=26.36

三件事值得说:

  1. 100/s 与 200/s 两档,两个口径相差在 0.2 以内。这是 Little’s law 在稳态下的正常表现,也说明采样方法没有系统性偏差。
  2. 名义服务时间 20 ms,实测 W 是 27 ms。LockSupport.parkNanos(20ms) 在本机负载下稳定超出 30% 左右。于是「8 个线程 / 20 ms = 400/s」的名义容量是错的,真实容量是 8 / 0.027 = 296/s。300/s 这一档正好踩在这条线上,队列开始堆积,W 跳到 90 ms,L 跳到 26。
  3. 过载时 L_sample 与 L_calc 依然相等(26.66 对 26.36,48.25 对 47.95)。公式不会因为系统要挂了就失效,它只是告诉你系统里堆了多少东西。第二次运行 W=90 ms、第一次 165 ms,说明 300/s 这一档的排队深度在两次运行间波动很大——只有过载档的绝对数值不稳定,等式本身仍然成立。

「名义 20 ms、实测 27 ms」这一条,是容量估算里最常见的翻车点:所有按服务时间做的除法,分母都该用实测的 W,而不是代码里写的超时值或 sleep 值。

三个用法

用法一:由 QPS 与延迟算所需并发

已知 λ 和 W,需要的并发是 L = λW。回到开头的场景:λ=800/s,W=45 ms,则

L = 800 × 0.045 = 36

意思是:稳态下平均有 36 个请求同时在途。当前 32 个线程、活跃常驻 30 以上,说明线程确实不够——缺口 4 个,而且这是平均值,峰值一定更高。第一位值班的判断方向是对的。

但要补一句:这 36 是「同时在途的请求数」,不等于「线程数」。如果每个请求全程独占一个平台线程,两者相等;如果用了异步下游或虚拟线程,36 是并发上限,线程数可以远小于它。

用法二:由并发与延迟反推吞吐上限

反过来,固定并发 L、固定单请求占用时间 W,吞吐上限是 λ_max = L / W。这条式子解释了为什么「加线程不一定加吞吐」:只有当 W 不随 L 增长时才成立。一旦 L 超过系统真正的处理能力,W 开始随排队上涨,λ 就锁死在容量上:

线程数W公式给出的 λ_max说明
3245 ms711/s低于 800,必然排队
4845 ms1067/s有余量
3260 ms(下游劣化)533/s同样的线程数,容量掉 25%

表里的数字按公式推导,不是实测;它的用途是判断方向和量级,不是拿去做 SLO 承诺。

用法三:判断余量

利用率 ρ = λW / L,也就是「实际并发 ÷ 可用并发」。它比「活跃线程数」更能说明问题:

ρ状态现象
< 0.5宽松排队可忽略,延迟接近服务时间
0.5 – 0.7健康偶发排队,P99 通常是均值的 1.5–3 倍
0.7 – 0.9紧张排队成为延迟主项,任何抖动都会被放大
> 0.9危险队列近似无界增长,一次下游抖动就能压垮

这套分档是经验口径,不是定理;它的价值在于把「活跃线程 30/32」翻译成「ρ=0.94,已经在危险区」。

容量表:假设、计算、结论

场景:借阅查询接口,目标 QPS 800,SLO 是 P99 ≤ 200 ms。表里每个数字都按公式推导,非实测,假设写在同一行里。

#假设计算过程结论
1当前 λ=800/s,实测 W=45 msL = 800 × 0.045 = 36稳态需要 36 并发;32 线程时 ρ=1.13,已过载
2扩到 48 线程,W 不变λ_max = 48 / 0.045 = 1067/s,ρ = 36/48 = 0.75容量余量 33%,但 ρ=0.75 已在紧张区
3下游劣化,W 从 45 涨到 60 ms32 线程:λ_max = 533/s;要保 800/s 需 L = 800 × 0.06 = 48原配置直接掉 25% 容量;48 线程刚好 ρ=1.0,仍无余量
4要求 ρ ≤ 0.7 才上线L ≥ 800 × 0.045 / 0.7 = 51.4取 52 线程;按 60 ms 劣化场景则需 69 线程
5若改成异步下游,线程不再被 IO 占用线程数不再等于 L,改由连接池与 CPU 决定公式仍成立,但 L 的含义变成「在途请求数」,见 5.2

第 4 行是这张表真正的结论:如果目标是 P99,线程数不该按平均并发配,要按「平均并发 ÷ 目标利用率」配。按平均值配出来的池子,稳态下就已经在排队了。

适用前提与四种误用

误用后果正确做法
用 P99 延迟代 W算出的并发高 3–10 倍,池子开得过大用均值;分位数另用排队论模型
把队列长度当作 L漏掉正在执行的线程,低估并发L = 活跃 + 排队 + 在途下游
在压测头两秒取窗口瞬态导致 λ 偏低、L 偏高,比值失真跳过预热段,只取稳态区间
忽略超时后仍在跑的任务等式右小左大,容量被高估超时后必须中断或让任务自行退出

什么时候不要用这个公式:批处理任务(一次提交 1000 条,L 的语义是「批内并发」而不是「请求数」)、有明显优先级的系统(均值掩盖了高优先级请求的实际排队)、以及带重试放大的链路(一次用户请求变成 3 次下游调用,λ 的口径不唯一)。这些场景不是公式错了,是你必须先定义清楚「一个请求」是什么。

在 libraryd-conc 里落地

把公式固化成 conc-ops 里的一个纯函数,避免每次容量评审都靠现场心算:

package com.plume.conc.ops;

/** 基于 Little's law 的池容量估算。所有入参必须来自同一段稳态观测窗口。 */
public record PoolPlan(double qps, double meanLatencyMillis, double targetUtilization) {

    public PoolPlan {
        if (qps <= 0 || meanLatencyMillis <= 0) {
            throw new IllegalArgumentException("qps 与延迟必须为正");
        }
        if (targetUtilization <= 0 || targetUtilization >= 1) {
            throw new IllegalArgumentException("利用率目标必须在 (0,1) 开区间");
        }
    }

    /** 稳态下同时在途的平均请求数:L = lambda * W。 */
    public double concurrency() {
        return qps * meanLatencyMillis / 1000.0;
    }

    /** 给定并发下的吞吐上限:lambda_max = L / W。 */
    public static double capacity(double concurrency, double meanLatencyMillis) {
        return concurrency / (meanLatencyMillis / 1000.0);
    }

    /** 为达到目标利用率所需的并发,向上取整。 */
    public long requiredConcurrency() {
        return (long) Math.ceil(concurrency() / targetUtilization);
    }

    /** 当前配置的利用率;大于 1 表示已经过载。 */
    public double utilization(double concurrency) {
        return concurrency() / concurrency;
    }
}

用开头的场景代入:new PoolPlan(800, 45, 0.7).requiredConcurrency() 返回 52,与容量表第 4 行一致。这个类的价值不在算法——算法只有一次乘法——而在于它把「目标利用率」变成了一个必须显式填写的参数,谁想把池子开小,就得在评审时写下自己的 ρ 目标。

常见错误与边界

  • 把 activeCount 当 L。activeCount() 只统计正在执行任务的线程,队列里的不算,已交给下游但没返回的算(因为线程还占着)。三个口径混用,是 Little’s law 校验对不上最常见的原因。
  • 用配置的线程数当 L。线程数只是上限,ρ=0.5 时实际在途只有一半。
  • 拿压测峰值 QPS 乘平均延迟。峰值与均值不能相乘,两个不同口径的统计量乘积没有意义。
  • 在无界队列上做估算。L 会一直涨到 OOM,公式描述的稳态根本不存在。
  • 把公式当性能模型。它不预测「加 8 个线程能快多少」,只描述当前状态三个量之间的关系。

小结

L = λW 是一个不需要分布假设的恒等式,它把「并发」「吞吐」「延迟」三个量锁在一起,因此有三种用法:由 QPS 与延迟算并发、由并发与延迟反推吞吐上限、用 ρ 判断余量。本机实测在 100/s 与 200/s 两档上,采样得到的 L 与 λW 相差不超过 0.15;300/s 那一档因为实际服务时间(27 ms)比名义值(20 ms)长,已经越过了真实容量,队列堆积、W 跳到 90–165 ms,等式依然成立——公式不会因为系统过载而失效,它只是如实地告诉你堆了多少。

容量估算的真正难点从来不是乘法,而是三个口径的对齐:λ 用完成速率、W 用含排队的端到端延迟、L 用同一个边界内的在途数。这三件事对齐了,公式才有意义;对不齐,算出来的数字越大越危险。

下一节把这个模型用到池大小上:并发数不是拍出来的,它由核数、等待时间和计算时间的比例决定。

阅读导航:上一节 4.3 锁顺序规范与超时兜底 | 下一节 5.2 CPU 密集与 IO 密集的池大小

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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