承接的问题
2.1 和 2.2 讲清了怎么把一个方法基准测准。但真正要回答的问题往往不是「这个方法多快」,而是「这套服务在 200 QPS 下 P99 会不会超 100 ms」。两者之间的距离不是精度问题,是量纲问题:微基准的单位是 ns/op,系统压测的单位是请求/秒加毫秒分位,中间隔着锁持有时间、GC、网络、队列和限流。
这一节做一次对照实验:同一段库存扣减逻辑,先放进 JMH,再放进闭环压测,看数字差多少、差在哪。所有数字都是本机实跑,跑不稳的地方会明说。
微基准能回答什么
| 能回答 | 不能回答 |
|---|---|
| 两种原语谁的单次成本低 | 这套服务能扛多少 QPS |
| 一次 CAS 在本机约多少纳秒 | 加了下游调用后 P99 是多少 |
| 临界区代码本身占多少时间 | 锁在整条链路上被持有多久 |
| 某段代码分配多少字节 | GC 在高分配率下的停顿分布 |
| 改动前后同一个函数是快了还是慢了 | 改动前后线上错误率会不会变 |
左边这一列的共同点是:变量只有一个。JMH 把除被测方法外的一切都固定住,所以它能给出可比较的差值。右边那一列的共同点是:变量是一组,而且互相耦合——加一个下游调用会同时改变锁持有时间、线程占用时间、队列深度和超时重试率。
所以从微基准到系统压测,不是「放大规模」,而是「换一个问题」。
同一个扣减的两种测法
被测逻辑是 libraryd-conc 的借阅扣减:扣库存,然后向 mail、sms、push 三个下游发通知。两个版本只差一件事——锁的范围:
static final AtomicInteger STOCK = new AtomicInteger(10_000_000);
static final ReentrantLock LOCK = new ReentrantLock();
static void downstream() {
try {
Thread.sleep(1); // 模拟一次下游 RPC,1 ms
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// A:锁包住扣减与三个下游调用
static void inlineReserve() {
LOCK.lock();
try {
STOCK.decrementAndGet();
for (int i = 0; i < 3; i++) {
downstream();
}
} finally {
LOCK.unlock();
}
}
// B:锁只包住扣减
static void splitReserve() {
LOCK.lock();
try {
STOCK.decrementAndGet();
} finally {
LOCK.unlock();
}
for (int i = 0; i < 3; i++) {
downstream();
}
}
压测驱动是闭环的:8 个平台线程各自「发一次请求、等结果、再发下一次」,跑 10 秒,每线程记录每次请求的墙钟耗时:
static final int THREADS = 8;
static final long DURATION_NANOS = 10_000_000_000L;
static Result run(Op op) throws InterruptedException {
long[][] perThread = new long[THREADS][];
CountDownLatch ready = new CountDownLatch(THREADS);
CountDownLatch go = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(THREADS);
for (int t = 0; t < THREADS; t++) {
final int id = t;
Thread.ofPlatform().name("client-" + t).start(() -> {
long[] samples = new long[1 << 18];
int n = 0;
ready.countDown();
try {
go.await();
long deadline = System.nanoTime() + DURATION_NANOS;
while (System.nanoTime() < deadline) {
long start = System.nanoTime();
op.run();
if (n < samples.length) {
samples[n++] = System.nanoTime() - start;
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
perThread[id] = Arrays.copyOf(samples, n);
done.countDown();
});
}
ready.await();
long start = System.nanoTime();
go.countDown(); // 所有线程对齐起跑线
done.await();
long elapsed = System.nanoTime() - start;
long[] merged = merge(perThread);
Arrays.sort(merged);
return new Result(merged.length, elapsed, merged);
}
merge 把各线程样本拼成一个数组,分位数从排好序的数组里直接取:
static long[] merge(long[][] perThread) {
int total = 0;
for (long[] s : perThread) {
total += s.length;
}
long[] merged = new long[total];
int off = 0;
for (long[] s : perThread) {
System.arraycopy(s, 0, merged, off, s.length);
off += s.length;
}
return merged;
}
static double pct(long[] sorted, double p) {
if (sorted.length == 0) {
return -1;
}
int idx = (int) Math.min(sorted.length - 1L, Math.round(p * (sorted.length - 1)));
return sorted[idx] / 1e6; // ns -> ms
}
用 CountDownLatch 对齐起跑线是为了避免「前面的线程已经跑掉一半,后面的才启动」。样本数组预分配 1 << 18,超出就停止采样但仍继续压测——这样不会因为记录样本本身拖慢被测路径。
开环还是闭环
上面的驱动是闭环的:8 个线程,每个线程等上一次请求返回才发下一次。它的优点是自带背压,不会把服务压垮;缺点是当服务变慢时,客户端也变慢,你测到的是「服务能处理多少」,而不是「给定到达率下服务表现如何」。
| 维度 | 闭环 | 开环 |
|---|---|---|
| 到达模型 | 固定并发,速率随响应时间变化 | 固定速率,与响应时间无关 |
| 会不会把服务压垮 | 不会 | 会,必须配限流 |
| 适合回答 | 容量上限、实现对比 | SLO 验证、过载行为 |
| 尾延迟含义 | 含排队,但排队由客户端并发决定 | 含排队,排队由真实到达率决定 |
| 本机可行性 | 可行,本节采用 | 需要精确计时器,本机负载下误差大 |
本节选闭环,是因为要回答的是「A 和 B 哪个容量高」,闭环足以给出可比数字。如果要验证「2000 QPS 下 P99 是否小于 100 ms」,就必须开环:固定 2000 次/秒的到达,观察拒绝率与尾延迟。开环压测的计时精度要求高,本机 load average 100 以上时没有做——这一项如实标注为未执行,命令与观察项留给 15.3 的验收报告。
本机结果:202 rps 与 1703 rps
两次独立运行(8 线程 × 10 秒,Apple M1 Pro 10 核,Temurin 25.0.5+8,load average 122–176):
--- A: 锁包住三个下游调用 ---
n=2033 rps=202 p50=4.57ms p99=1242.67ms p999=4322.62ms max=4961.66ms
--- B: 锁只包住库存扣减 ---
n=17044 rps=1703 p50=4.52ms p99=11.75ms p999=17.91ms max=25.26ms
--- 第二次运行 ---
--- A ---
n=2142 rps=213 p50=4.55ms p99=20.62ms p999=6843.45ms max=7200.93ms
--- B ---
n=16496 rps=1645 p50=4.53ms p99=12.91ms p999=24.06ms max=35.35ms
同一段 decrementAndGet,在 JMH 里是:
ReserveCounterBench.atomicDecrement thrpt 3 28485746.478 ± 124079265.612 ops/s
ReserveCounterBench.atomicDecrement:gc.alloc.rate.norm thrpt 3 ≈ 10⁻⁴ B/op
ReserveCounterBench.atomicDecrement:gc.count thrpt 3 ≈ 0 counts
2848 万 ops/s,每次操作分配约 0 字节,GC 一次都没发生。系统压测 B 是 1703 rps,相差约 1.67 万倍;A 是 202 rps,相差约 14 万倍。这不是 JMH 测错了,而是两边测的根本不是同一件事:JMH 测的是「扣减原语」,压测测的是「扣减 + 3 次 1 ms 等待 + 线程调度」。
三条能稳定复现的结论:
- 吞吐比值稳定:A/B 实测 8.4 倍与 7.7 倍,理论值
(8 / 3 ms) ÷ (1 / 3 ms) = 8倍,方向与量级一致。 - P50 几乎不动:A 是 4.57/4.55 ms,B 是 4.52/4.53 ms。锁包住了 3 ms 却几乎没有抬高 P50,这与直觉相反;合理推测是不公平锁让一部分线程反复插队、另一部分线程承担长等待(P99 因此爆掉),中位数只反映了插队成功那一半的耗时。本节没有采集每线程的等待分布,这只是推测。
- P99 完全不可信:A 的 P99 从 1242 ms 跳到 20.6 ms,差 60 倍。本机负载 122–176、没有 CPU 隔离,尾部随时被别的进程插队。这一节的尾延迟数据只能作定性说明,不能当容量依据。
把两种测法放在一张表里,差距的来源就清楚了:
| 维度 | JMH(ReserveCounterBench) | 系统压测(LoadProbe) |
|---|---|---|
| 被测对象 | decrementAndGet 原语 | 扣减 + 3 次 1 ms 下游等待 |
| 结果单位 | ops/s,2848 万 | rps,1703(B)/ 202(A) |
| 并发模型 | 4 个 JMH 线程 | 8 个闭环客户端线程 |
| 每操作分配 | ≈ 0 B | 请求对象 + 领域记录 + 通知任务 |
| GC 次数 | ≈ 0 | 未采集 |
| 下游与网络 | 无 | 3 次等待,无网络 |
| 统计量 | 均值 + 99.9% 误差 | 吞吐 + P50/P99/P999 |
| 能回答 | 哪种原语更快 | 哪种实现容量更高 |
差异来自哪里
五层,从内到外。
同步开销。 JMH 里临界区只有一次 decrementAndGet,本机约 35 ns(2848 万 ops/s)。系统里 A 模式的临界区包含 3 次 1 ms 等待,锁被持有的时间放大了约 8.6 万倍。锁的吞吐上限就是 1 / 临界区时间 = 333 次/秒,无论开多少线程都突破不了——实测 202,是理论值的 61%。差额来自 Thread.sleep(1) 的实际精度与锁交接本身;本机没有单独测 sleep 的偏差,所以这 39% 不做进一步归因。B 模式把等待移出临界区后,上限变成 8 / 3 ms = 2666,实测 1703,同样是 64%。
两个 61%/64% 的比例如此接近,恰好说明瓶颈是同一个东西(下游等待),锁的范围只决定它是串行还是并行。
GC。 JMH 那 0 B/op 是真实的:扣减路径上没有任何新对象,所以微基准里 GC 永远不会出场。系统压测里每请求至少产生一个请求对象、一个 Loan/Reservation 记录和三个通知任务;按 1703 rps、每请求 200 B 算,约 0.34 MB/s,仍然很低。真正的问题不在这里,而在 Thread.sleep 期间线程栈与调度器状态的膨胀——这部分不计入 TLAB,但会计入上下文切换。
网络。 这一节的探针刻意不走 HTTP,为的是把变量压到最少。真实链路再加一层 RTT,按常见量级取同机房 0.2–0.5 ms、跨可用区 1–2 ms(示例值,非本机测量)。若按 0.5 ms 推算:加到 A 模式的临界区上,吞吐从 1/3 ms 掉到 1/3.5 ms,约降 14%;加到 B 模式上,单请求时延涨约 15%,吞吐同比例下降。网络对两种模式的比例影响接近,但它会把 P99 的底座整体抬高。
队列。 本探针是闭环的,线程自己阻塞,没有队列。生产环境通常在入口放一个 ArrayBlockingQueue,此时 A 模式会让队列迅速堆满:消费速率 333/s,到达速率 2000/s,队列每秒净增 1667 个元素,几秒内就触发拒绝或 CallerRunsPolicy 把压力反弹回调用方。5.3 与 10.1 分别展开拒绝曲线与堆积分析。
限流。 限流器本身也是一把锁或一个原子计数器。当它放在锁外,它决定了到达速率;放在锁内,它就成了临界区的一部分。本机没有做限流实验——这一项未测,只给判断依据:限流点必须放在最外层,且它的拒绝必须发生在进入业务锁之前。
在 libraryd-conc 里落地
把压测结论固化成一份可判定的报告,而不是一段散文。conc-ops 里给 LoadReport 加一个判定方法,阈值来自 1.3 的 SLO:
package com.plume.conc.ops;
/** 压测结论:通过与否必须由阈值决定,不能由「看起来还行」决定。 */
public record LoadReport(
String scenario,
long requests,
double rps,
double p50Millis,
double p99Millis,
double p999Millis,
double loadAverage,
boolean pass,
String note) {
public static final double MAX_P99_MILLIS = 100.0;
public static final double MIN_RPS = 500.0;
public static LoadReport judge(String scenario, long requests, long elapsedNanos,
long[] sortedNanos, double loadAverage) {
double rps = requests / (elapsedNanos / 1_000_000_000.0);
double p50 = pct(sortedNanos, 0.50) / 1e6;
double p99 = pct(sortedNanos, 0.99) / 1e6;
double p999 = pct(sortedNanos, 0.999) / 1e6;
boolean pass = rps >= MIN_RPS && p99 <= MAX_P99_MILLIS;
String note = pass ? "通过" : (rps < MIN_RPS ? "吞吐不足" : "尾延迟超标");
return new LoadReport(scenario, requests, rps, p50, p99, p999, loadAverage, pass, note);
}
private static long pct(long[] sorted, double p) {
int idx = (int) Math.min(sorted.length - 1L, Math.round(p * (sorted.length - 1)));
return sorted[idx];
}
}
按这套判据,A 的 rps 202 低于 500、p99 1242 ms 超标,判定「吞吐不足」;B 的 rps 1703、p99 11.75 ms 两项都达标,判定「通过」。这个结论比「B 比 A 快 8 倍」有用得多,因为它直接对应到部署时该选哪个实现。
报告里还要写一行「数据可信度」,例如:loadAverage=176.65, p99 在两次运行间波动 60 倍,尾延迟不具统计意义。没有这一行的报告,半年后没人知道那个 1242 ms 该不该信。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 用 JMH 的 ns/op 反推 QPS | 高估 4 个数量级 | 用闭环压测测系统吞吐 |
| 用开环压测测未限流的服务 | 把客户端压垮,测到的是客户端瓶颈 | 闭环起步,再接限流 |
| 只看平均时延 | A 的均值 39 ms 掩盖了 P99 的 1242 ms | 报 P50/P99/P999 |
| 在负载不受控的机器上取尾延迟 | 两次运行差 60 倍 | 固定机器,记录 load,或只取吞吐比值 |
把 Thread.sleep 当成精确的 1 ms | 理论 333、实测 202,误判为锁有问题 | 用真实下游或可注入延迟,并记录实测占位时间 |
| 压测完不留原始分位数据 | 无法复核 | 保存 sorted 或直方图 |
什么时候不要做系统压测:当你要回答的只是「这两种写法哪一种的临界区更短」时。那个问题 JMH 十分钟能答,系统压测要搭环境、要控制负载,还得不出可比数字。反过来,一旦问题里出现「QPS」「P99」「容量」「水位」,微基准就无能为力。
小结
同一段 decrementAndGet,微基准给 2848 万 ops/s、零分配、零 GC;系统压测给 202 到 1703 rps、P50 4.5 ms、P99 在 20 ms 到 1242 ms 之间乱跳。差距不是测量误差,是「有没有下游等待、锁持有多久、线程是否被调度」这三件事的乘积。本节能稳定引用的只有两条:吞吐比值(A/B 约 8 倍,与理论一致)和「锁的持有范围决定吞吐上限」;尾延迟数据在本机负载下不具统计意义,如实标注为未取得。
下一章换一个视角:不再靠猜和压,而是用 JFR 把锁竞争直接抓出来。
阅读导航:上一节 2.2 微基准的陷阱 | 下一节 3.1 JFR 的 Monitor 事件
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。