《Java 并发编程·性能诊断与事故》2.3 从微基准到系统压测

把同一个库存扣减分别放进 JMH 与闭环压测:微基准 2848 万 ops/s、零分配,系统压测只有 202 到 1703 rps,同一把锁的位置改动让 P99 差两个数量级。逐项拆解同步、GC、网络、队列与限流带来的差距,并给出可复核的压测报告模板与诚实的数据边界。

承接的问题

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 等待 + 线程调度」。

三条能稳定复现的结论:

  1. 吞吐比值稳定:A/B 实测 8.4 倍与 7.7 倍,理论值 (8 / 3 ms) ÷ (1 / 3 ms) = 8 倍,方向与量级一致。
  2. P50 几乎不动:A 是 4.57/4.55 ms,B 是 4.52/4.53 ms。锁包住了 3 ms 却几乎没有抬高 P50,这与直觉相反;合理推测是不公平锁让一部分线程反复插队、另一部分线程承担长等待(P99 因此爆掉),中位数只反映了插队成功那一半的耗时。本节没有采集每线程的等待分布,这只是推测。
  3. 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 事件

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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