三个都真的数字
把 conc-core 的 SynchronizedLoanService.reserve 放到 64 并发下压一分钟,得到三个数字:
- 平均延迟 560 µs;
- p50 延迟 8.1 µs;
- p999 延迟 110 ms。
三个都是真的,而且互相矛盾。看平均:还凑合。看 p50:服务快得像没上锁。看 p999:千分之一的请求等了 0.11 秒,如果这是扣减库存,用户会看到转圈。同一个系统,取决于你看哪个数,结论可以从「健康」跳到「事故」。
这一节就拆这件事。要回答四个问题:
- 平均值、p50、p99、p999 分别描述什么,各自在哪里骗人?
- 为什么吞吐不动而延迟线性上涨?Little 定律给出的解释是什么?
- 为什么每个下游 p99 都达标,端到端 p99 仍然不达标?
- 并发从 1 加到 256,p999 能涨多少个数量级?
先看 01-1、01-2 留下的线索:扩展性到顶、cpu_ns/op 恒定、线程切换率线性增长。三条都在说「不是算得慢,是排得久」。这一节把「排得久」量化成分位。
平均、分位与它们各自说谎的方式
平均延迟 = 总时间 / 请求数。它对长尾极其敏感:1000 个请求里 1 个卡了 500 ms,平均值就被抬 0.5 ms,看起来无所谓;但如果有 1% 的请求卡 500 ms,平均值直接涨 5 ms,而 p50 一动不动。
分位数描述的是「第几名的请求等多久」。它们回答的是不同人群的体验:
| 指标 | 回答的问题 | 谁在关心 | 什么时候骗人 |
|---|---|---|---|
| 平均 | 总体花了多少时间 | 容量规划、成本 | 长尾大时,两头都不像 |
| p50 | 典型请求多快 | 产品体验 | 掩盖偶发卡死 |
| p99 | 最慢的 1% 有多慢 | SLO、告警 | 样本少时抖动大 |
| p999 | 最慢的 0.1% 有多慢 | 重试风暴、超时链 | 需要足够样本才稳定 |
下面的阶梯实验里有一行特别能说明问题:64 并发,p50 = 8.1 µs,平均 = 560 µs,相差 69 倍。平均被 0.1% 的毫秒级样本拽着走,而 99% 的请求体验和单线程时一样。
还有一层更隐蔽的:采样单位与干扰单位不一致。 抢占发生在「线程」上,不是「请求」上。一个线程连续跑 300 个请求时才被剥夺一次 CPU,这一次中断会让其中一个请求的耗时变成几毫秒,其余 299 个仍是 8 µs。所以长尾在分位表里表现为「离群样本」,而不是「整体变慢」。这解释了为什么 p50 那么稳、p999 那么炸。
判据很简单:
- p50 稳、p99 微涨、p999 暴涨 → 排队。加机器或减并发有用,优化代码没用。
- p50 也涨 → 服务真的变慢了(GC、缺页、下游变慢、锁内做了更多事)。
- 平均涨、p50 不涨 → 有少量极端样本,去查超时、重试、连接池耗尽。
排队论直觉:L = λW 与它不适用的时候
Little 定律一句话:系统内的平均请求数 = 到达率 × 平均停留时间,写成 L = λW。它不假设分布,只要求稳态,所以几乎总能用来做数量级校验。
本节的实验是闭环的:256 个客户端线程,每个跑完一个请求立刻发下一个。闭环里 L 恒等于客户端数 N,于是
λ = N / W → W = N / λ
吞吐 λ 一旦被锁钉死,W 就只能随 N 线性上涨。这不是「延迟随负载恶化」的模糊说法,是一条硬约束:并发翻倍、吞吐不变,等待时间必然翻倍。
用实测数据验证(TailAmplify 把上表的值代入公式):
/** 尾延迟放大与排队模型:三张对照表,全部由公式计算。 */
public final class TailAmplify {
public static void main(String[] args) {
System.out.println("fanout P(至少一个下游超过 p99)");
for (int k = 1; k <= 5; k++) {
System.out.printf("%6d %26.2f%%%n", k, 100 * (1 - Math.pow(0.99, k)));
}
// 并发阶梯实测值:客户端数、吞吐 op/s、平均延迟 µs、临界区服务时间 µs
int[] conc = {4, 16, 64, 256};
double[] thr = {116683, 117200, 114122, 109213};
double[] meanUs = {34.2, 136.4, 560.3, 2335.1};
double serviceUs = 8.1;
System.out.println("conc W_meas_us (N-1)*S_us L=lambda*W M/M/1_W_us");
for (int i = 0; i < conc.length; i++) {
double lambda = thr[i];
double rho = lambda * serviceUs / 1e6;
double mm1Us = 1e6 / (1e6 / serviceUs - lambda);
System.out.printf("%4d %10.1f %11.1f %11.1f %11.1f%n",
conc[i], meanUs[i], (conc[i] - 1) * serviceUs,
lambda * meanUs[i] / 1e6, rho >= 1 ? Double.NaN : mm1Us);
}
}
}
fanout P(至少一个下游超过 p99)
1 1.00%
2 1.99%
3 2.97%
4 3.94%
5 4.90%
conc W_meas_us (N-1)*S_us L=lambda*W M/M/1_W_us
4 34.2 24.3 4.0 147.6
16 136.4 121.5 16.0 159.8
64 560.3 510.3 63.9 107.1
256 2335.1 2065.5 255.0 70.2
L = λW 一列是 4.0、16.0、63.9、255.0,与客户端数 4、16、64、256 的偏差都在 1% 以内。Little 定律在这组数据上是恒等式,不是近似。 它也直接给出容量结论:想让 256 个客户端都进来,就得接受每个请求等 2.3 ms;不想等,就别放 256 个进来。
(N-1)×S 一列是「每个请求排在其余 N-1 个请求后面」的朴素模型,和实测差 10%–45%,够用来估量级。
M/M/1 这一列是反面教材。它给 4 并发 147.6 µs(实测 34.2),给 256 并发 70.2 µs(实测 2335.1)——两个方向都错。原因在三个假设全不成立:到达不是泊松(闭环、永不停),服务不是指数(spinMicros(8) 是确定性 8 µs),队列不是随机服务(monitor 是竞争式交接)。公式的假设错了,误差不会均匀地小,而会随负载变号。
结论:公式用来判断方向和量级,定容必须用同一台机器、同一种负载测出来的分位。
尾延迟放大:3 路 fan-out 就把 p99 变成 3%
单个下游 p99 = 100 ms,看起来可控。但如果一个页面要并发调 3 个下游、取最慢的那个,整体超过 100 ms 的概率是
1 - (1 - 0.01)^3 = 2.97%
也就是 p97 左右。5 路就是 4.90%,接近 p95。上面的 fanout 表就是这个计算。「每个服务的 p99 都达标」和「端到端 p99 达标」是两件事,中间的差距由扇出宽度决定。
两种组合方式要分开算:
- 串行:总延迟是各段之和。各段独立时,总 p99 不会超过各段 p99 之和,通常远小于。
- 并行取最慢:总延迟是各段的最大值。p99 出现的概率被放大到
1-(1-0.01)^k,这是最常见的误判来源。
所以看到端到端 p99 不达标时,先数扇出,再看单个下游的分位。把 3 路扇出改成 2 路,端到端 p99 超标概率从 2.97% 降到 1.99%,比优化任何一个下游都直接。
并发阶梯实验:1/4/16/64/256
同一个 reserve,同一台机器,只改客户端数。程序用 ready/go 两个闸门让所有客户端同时起跑,每档跑 1 秒,采样全部请求的耗时,最后全局排序求分位。
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
/** 并发阶梯:同一段借阅扣减逻辑,在 1/4/16/64/256 个客户端下测吞吐与分位延迟。 */
public final class LatencyLadder {
static final int[] LEVELS = {1, 4, 16, 64, 256};
static final Object LOCK = new Object();
static final Map<String, Integer> STOCK = new HashMap<>();
static final Map<String, Integer> HELD = new HashMap<>();
static volatile long sink;
public static void main(String[] args) throws Exception {
String mode = args.length > 0 ? args[0] : "locked";
run(16, mode, 300); // 预热,结果丢弃
System.out.printf("mode=%s cores=%d%n", mode, Runtime.getRuntime().availableProcessors());
System.out.println("conc ops thr_op/s mean_us p50_us p99_us p999_us max_us");
for (int c : LEVELS) {
report(c, run(c, mode, 1000));
}
}
static long[] run(int clients, String mode, long millis) throws Exception {
CountDownLatch ready = new CountDownLatch(clients);
CountDownLatch go = new CountDownLatch(1);
long[][] buf = new long[clients][];
Thread[] pool = new Thread[clients];
for (int i = 0; i < clients; i++) {
final int idx = i;
pool[i] = Thread.ofPlatform().unstarted(() -> {
ready.countDown();
try { go.await(); } catch (InterruptedException e) { return; }
Map<String, Integer> mine = new HashMap<>();
long[] local = new long[4096];
int n = 0;
long end = System.nanoTime() + millis * 1_000_000L;
while (System.nanoTime() < end) {
long t0 = System.nanoTime();
if (mode.equals("locked")) {
reserve("978-7-111-00001-1", "m-" + (idx & 7));
} else {
mine.put("k" + idx, mine.getOrDefault("k" + idx, 1_000_000) - 1);
spinMicros(8);
}
long dt = System.nanoTime() - t0;
if (n == local.length) local = Arrays.copyOf(local, n * 2);
local[n++] = dt;
}
buf[idx] = Arrays.copyOf(local, n);
});
pool[i].start();
}
ready.await();
long wallStart = System.nanoTime();
go.countDown();
for (Thread t : pool) t.join();
long wall = System.nanoTime() - wallStart;
int total = 0;
for (long[] b : buf) total += b.length;
long[] all = new long[total + 1];
int off = 0;
for (long[] b : buf) { System.arraycopy(b, 0, all, off, b.length); off += b.length; }
all[total] = wall;
return all;
}
/** 与 conc-core 的 SynchronizedLoanService.reserve 同形:锁内查库存、查配额、扣减。 */
static void reserve(String isbn, String memberId) {
synchronized (LOCK) {
int left = STOCK.computeIfAbsent(isbn, k -> 1_000_000);
int held = HELD.getOrDefault(memberId, 0);
if (held >= 5) { // 借满 5 本后归还,否则锁内工作退化成空操作
HELD.put(memberId, 0);
held = 0;
}
if (left <= 0) return;
STOCK.put(isbn, left - 1);
HELD.put(memberId, held + 1);
spinMicros(8);
}
}
static void spinMicros(int micros) {
long end = System.nanoTime() + micros * 1000L;
long x = 1;
while (System.nanoTime() < end) x = x * 6364136223846793005L + 1;
sink += x;
}
static void report(int clients, long[] lat) {
long[] s = Arrays.copyOf(lat, lat.length - 1);
long wall = lat[lat.length - 1];
Arrays.sort(s);
double mean = 0;
for (long v : s) mean += v;
System.out.printf("%4d %7d %11.0f %9.1f %8.1f %8.1f %8.1f %9.1f%n",
clients, s.length, s.length / (wall / 1e9), mean / s.length / 1000.0,
pct(s, 0.50) / 1000.0, pct(s, 0.99) / 1000.0, pct(s, 0.999) / 1000.0,
s[s.length - 1] / 1000.0);
}
static long pct(long[] s, double p) {
int i = (int) Math.ceil(p * s.length) - 1;
return s[Math.max(0, Math.min(s.length - 1, i))];
}
}
本机输出(locked 模式,单次运行,采样时 load 37,Apple M1 Pro 10 核;随调度波动,不作基准):
mode=locked cores=10
conc ops thr_op/s mean_us p50_us p99_us p999_us max_us
1 123003 122976 8.1 8.0 8.2 12.2 82.8
4 116706 116683 34.2 8.1 12.8 6758.2 20351.0
16 117252 117200 136.4 8.1 12.2 34758.0 68992.6
64 114295 114122 560.3 8.1 12.8 110508.9 140854.3
256 110027 109213 2335.1 8.5 16.4 398821.4 432942.5
同一程序、把临界区换成本线程私有 Map(free 模式,java LatencyLadder free),作为「没有锁时能跑多快」的对照:
mode=free cores=10
conc ops thr_op/s mean_us p50_us p99_us p999_us max_us
1 122913 122898 8.1 8.1 8.3 12.1 1088.8
4 442065 441875 8.6 8.6 9.0 13.1 316.5
16 916423 868768 15.6 9.5 11.6 99.7 45502.5
64 1047621 891365 55.1 9.6 11.3 11801.6 184635.5
256 1415260 834971 169.8 10.0 14.7 56105.6 580894.4
四条读数:
第一,有锁时吞吐在 4 并发就到顶。 1 并发 123.0k op/s,4 并发 116.7k,到 256 并发是 109.2k——把并发从 4 加到 256(64 倍),吞吐没涨还略降。锁把 λ 钉死在临界区服务时间上,这与 1.1 的串行段完全对应。
第二,p50 全程 8.0–8.5 µs。 服务本身没有变慢。任何「优化临界区内的代码」的方案,在 8 µs 这个数上已经没有空间。
第三,p999 从 12.2 µs 涨到 398.8 ms,约 3.3 万倍;max 从 82.8 µs 涨到 432.9 ms。 这中间 4 档的 p99 只从 8.2 涨到 16.4 µs——只报 p99 会得出「延迟几乎没变」的结论。分位越靠尾,越能暴露排队。
第四,free 模式证明尾部不只来自锁。 没有共享状态时吞吐从 122.9k 涨到 868.8k(7.1 倍,接近 10 核的上限),但 p999 仍然从 12.1 µs 涨到 56.1 ms。256 个线程抢 10 个核,光是「轮到 CPU」就能造出几十毫秒的尾巴。锁排队和 CPU 抢占是两个独立的长尾源,去掉一个还剩另一个。
这里有个写压测的坑值得记下来。第一版 reserve 在配额借满 5 本后直接 return,锁内工作退化成空操作,1 并发跑出了 16 M op/s(正确值约 123k)。压测程序必须能证明临界区真的在干活:看一眼 1 并发档位的吞吐是否等于 1/临界区时间,对不上就是逻辑漏了。
从这组数得到的容量口径
按 p999 ≤ 100 ms 定 SLO,本轮的读数:
- 16 并发:p999 = 34.8 ms,安全。
- 64 并发:p999 = 110.5 ms,已破线。
- 256 并发:p999 = 398.8 ms,超 4 倍。
如果按 (N-1)×S 估算,64 并发只需要 510 µs,会得出「64 并发毫无压力」的结论,和实测差 217 倍。因为 p999 由抢占和排队尾决定,不由平均服务时间决定。 容量只能从分位曲线上读,不能从服务时间反推。
归档时至少留三样:λ(吞吐)、W 的分位分布(p50/p99/p999/max)、L(并发数)。三者由 Little 定律绑定,缺一个就无法判断是「机器不够」还是「锁太粗」。
常见错误与边界
- 用平均延迟做 SLO。 64 并发下平均 560 µs,p999 110 ms,平均达标而用户体验崩了。
- 把不同实例的 p99 求平均。 分位不能跨实例算术平均;要合并必须合并直方图或保留原始分桶。
- 只报 p99。 本实验里 4→256 并发 p99 只涨了 8.2 µs,p999 涨了 3.3 万倍。
- 在压测循环里加入额外开销。
containsKey+get这类写法会把服务时间改掉,测出来的是压测程序的锁,不是被测代码的锁。 - 拿单次 p999 当基线。 本轮 64 并发两次运行 p999 分别是 101 ms 和 110 ms;01-2 里同一个调度延迟程序四次运行的 p99 差 2.4 倍。尾部分位必须给区间。
- 忽略客户端自己的排队。 256 个客户端线程本身也要抢 CPU,测到的「服务延迟」里含客户端的调度时间。要分离,得让客户端数量远离核数,或用协调遗漏(coordinated omission)检测工具。
小结
- 平均、p50、p99、p999 描述的是不同人群;长尾大时平均值两头都不像。
- 抢占发生在「线程」上,一次抢占污染一个样本,所以长尾表现为离群样本而不是整体变慢。
- 闭环下
L = N,Little 定律退化成W = N/λ:吞吐被锁钉死,等待时间必然随并发线性上涨。实测L与客户端数偏差在 1% 以内。 M/M/1在闭环、确定性服务、竞争式锁的场合两个方向都会错(4 并发高估 4 倍,256 并发低估 33 倍),只能当直觉,不能定容。- 3 路 fan-out 把单下游 p99 的超标概率放大到 2.97%;端到端 p99 取决于扇出宽度,不取决于单个下游。
- 实测:加锁时吞吐 109–123k 恒定,p999 从 12 µs 涨到 399 ms;无锁时吞吐涨 7.1 倍,p999 仍从 12 µs 涨到 56 ms。
阅读导航:上一节 1.2 上下文切换、调度延迟与 CPU 配额 | 下一节 2.1 JMH 的正确姿势
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。