承接的问题
构造场景: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 差了三倍,不是公式失效,是三个数里至少有一个口径错了。
它同时有三个前提,缺一个就不成立:
- 稳态。窗口内 λ 和 W 不漂移。压测的前两秒、故障恢复的头十秒都不是稳态,那段时间的 L 平均值没有意义。
- 有界。L(t) 必须有限。队列无界且消费慢于生产时,积分发散,等式左右都是无穷大——这不是公式的问题,是系统已经不在「能用公式描述」的区域了。
- 守恒。窗口内不许有「黑户」:超时返回给调用方但后台仍在跑的任务、被线程池丢弃但没有记账的任务,都还在占 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
三件事值得说:
- 100/s 与 200/s 两档,两个口径相差在 0.2 以内。这是 Little’s law 在稳态下的正常表现,也说明采样方法没有系统性偏差。
- 名义服务时间 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。 - 过载时 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 | 说明 |
|---|---|---|---|
| 32 | 45 ms | 711/s | 低于 800,必然排队 |
| 48 | 45 ms | 1067/s | 有余量 |
| 32 | 60 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 ms | L = 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 ms | 32 线程:λ_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 密集的池大小
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。