《Java 虚拟机原理与性能调优》10.1 压测设计与指标口径:吞吐、分位与协调缺失

同样 5000 个请求、50 并发,ab 不带 -k 是 1909 req/s、p99 78ms,带 -k 是 10201 req/s、p99 17ms。本节用这两份真实输出讲吞吐、均值、分位、连接复用与协调缺失,并用 Little 定律和开环压测器说明为什么并发不是越高越好。

本节目标:把「QPS 多少」拆成吞吐、分位、并发、连接复用和测量模型五个口径,并给出一个只用 JDK HttpClient 的压测客户端。
适用版本:JDK 25(Temurin 25.0.5+8);ab 为 macOS 自带 /usr/sbin/ab。本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125),默认 G1,压缩 oops。

承接的问题

第 9 章解决了启动和预热。接下来团队要回答一个更常见的问题:libraryd 的借阅端点到底能扛多少 QPS?

第一次压测的报告只有一行:

Requests per second: 1909.37 [#/sec] (mean)

有人说「不到 2000 QPS,需要扩容」。第二次换了一台机器,同一份代码跑出 10201 req/s,于是又说「上次是环境问题」。两次用的命令只差一个 -k。

压测数字不可比,通常不是机器问题,而是口径问题:

  • 吞吐是按完成请求数算,还是按成功请求数算?
  • 延迟是均值、p50 还是 p99?
  • 连接是每个请求新建,还是复用?
  • 压测端是闭路(收到响应才发下一个),还是开路(按固定速率发)?
  • 有没有 warmup,测了多久?

这一节用一个与 libraryd 同构的最小 HTTP 服务(com.sun.net.httpserver + 虚拟线程,端口 18150)把这些口径逐个拆开。所有数字都来自本机,不代表真实 libraryd 的生产容量。开头两组 -k 对照各跑一次(单次运行,未做统计),只用于说明口径差异;后面的并发曲线是两轮取中位数。

一、同样 5000 个请求,两个吞吐数字

先看不带 -k 的命令:

ab -n 5000 -c 50 http://127.0.0.1:18150/api/loans/42

本机输出的关键行:

Concurrency Level:      50
Time taken for tests:   2.619 seconds
Complete requests:      5000
Failed requests:        0
Requests per second:    1909.37 [#/sec] (mean)
Time per request:       26.187 [ms] (mean)
Time per request:       0.524 [ms] (mean, across all concurrent requests)
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0   13   3.3     12      51
Processing:     4   13   6.7     12      93
Waiting:        1   13   4.3     12      67
Total:          6   26   8.6     25     109
  50%     25
  90%     27
  95%     32
  99%     78
 100%    109 (longest request)

Connect 的平均值是 13 ms。localhost 的 TCP 建连不应该这么慢;原因是 ab 默认不带 -k,每个请求处理完就关闭连接,下一个请求重新三次握手。5000 次建连的开销被算进了端到端延迟。

加上 -k:

ab -k -n 5000 -c 50 http://127.0.0.1:18150/api/loans/42
Keep-Alive requests:    5000
Requests per second:    10201.75 [#/sec] (mean)
Time per request:       4.901 [ms] (mean)
Time per request:       0.098 [ms] (mean, across all concurrent requests)
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   1.6      0      19
Processing:     3    5   1.3      4      19
Waiting:        2    5   1.3      4      19
Total:          3    5   2.6      4      30
  50%      4
  90%      6
  95%      6
  99%     17
 100%     30 (longest request)

吞吐从 1909 涨到 10202 req/s,p99 从 78 ms 降到 17 ms。代码一行没改,JVM 参数也没改,差别只在连接复用。

这两个数字都「真实」,但回答的是不同问题:

  • 不带 -k:模拟每次请求都新建连接的客户端,适合短连接 API、老式 HTTP 客户端、没有连接池的场景。
  • 带 -k:模拟连接池或长连接客户端,适合大多数微服务调用、浏览器、HttpClient 默认行为。

libraryd 的调用方如果用连接池,压测就必须带 -k;否则测到的是 TCP 握手和内核接受队列,不是服务端处理能力。报告里必须写清用了哪种。

均值也有同样的陷阱。带 -k 的报告里,Time per request 均值是 4.901 ms,p99 是 17 ms。均值看起来很好,但 1% 的请求要 17 ms 以上,50 并发下这些尾部请求会占住连接,进一步推高后续请求的排队时间。容量决策看 p99,不看均值。

二、协调缺失:为什么慢请求会被漏掉

闭路压测器(ab、wrk 的默认模式、大多数手写循环)的模型是:每个并发 worker 发一个请求,收到响应后再发下一个。这个模型有一个结构性缺陷:当服务端停顿 200 ms 时,worker 在这 200 ms 内不会发出新请求。停顿结束后,压测器只记录了「这一个慢请求」,没有记录「本来应该在停顿期间发出的 40 个请求」。这就是 coordinated omission。

要看到这个差距,需要把「计划发出时间」和「实际开始时间」分开记录。下面是一个最小的开环压测器核心:

long interval = 1_000_000_000L / targetRps;
long start = System.nanoTime();
Semaphore permits = new Semaphore(maxInFlight);

for (int i = 0; i < total; i++) {
    long intended = start + i * interval;
    long wait = intended - System.nanoTime();
    if (wait > 0) {
        LockSupport.parkNanos(wait);
    }
    executor.submit(() -> {
        permits.acquire();
        long t0 = System.nanoTime();
        client.send(request, HttpResponse.BodyHandlers.discarding());
        long done = System.nanoTime();
        naive[i] = done - t0;          // 只算请求真正执行的时间
        corrected[i] = done - intended; // 从计划发出时间算起
        permits.release();
    });
}

maxInFlight 模拟客户端的连接数或 worker 数。当目标速率超过「在途数 / 单请求耗时」时,请求会在 permits.acquire() 排队;这段排队时间不计入 naive,但计入 corrected。

本机对一个只做简单 JSON 拼接的端点跑 5000 req/s、3 秒:

maxInFlight=4: naive p50=0.43ms p99=2.29ms | corrected p50=49.04ms p99=329.02ms max=333.78ms
maxInFlight=1: naive p50=0.31ms p99=0.90ms | corrected p50=1435.13ms p99=2338.29ms max=2351.76ms

maxInFlight=1 时,naive p99 只有 0.90 ms,看起来完美;corrected p99 是 2338 ms,因为请求在发出前排队了两秒多。如果只看 naive,会得出「单连接也能扛 5000 QPS」的错误结论。

报告里应该同时给出两个口径,并写清模型:

  • 闭路模型:并发数固定,吞吐由服务端决定;适合容量规划,但会低估停顿期的尾部。
  • 开路模型:目标速率固定,记录计划发出到完成的延迟;适合 SLA 验证,能暴露协调缺失。

ab 是闭路模型。用它做容量规划没问题,但不要用它证明「p99 很好」;停顿期间的请求根本没发出去。

三、Little 定律:并发不是越高越好

Little 定律:L = λW,即在途请求数等于到达率乘以平均停留时间。对一个闭路压测器,L 约等于并发数,W 是平均延迟,λ 是吞吐。用 /api/reports/summary?n=20000(每次请求计算 20000 个 loan 的罚金)跑不同并发,本机两次运行取中位数:

并发吞吐 req/s平均延迟 msp50 msp95 msp99 msL = λW
103964.72.522.06.59.510.0
203994.45.033.512.521.020.1
504146.312.098.530.057.050.1
1003447.129.0224.062.587.5100.0

L 与并发几乎完全一致,验证了闭路模型下 L ≈ c。吞吐在 10 到 50 并发之间基本持平(约 4000 req/s),到 100 反而降到 3447;p99 从 9.5 ms 一路涨到 87.5 ms。

这条曲线的拐点在 20 到 50 之间:再加并发不会提高吞吐,只会把请求堆在队列里。容量规划要选拐点之前的并发,而不是「并发越高吞吐越高」。

四、warmup 与测量窗口

9.1 测过首请求:第一个 /api/loans/42 要 52 到 87 ms,第二个降到 3 ms。压测如果不 warmup,前几百个请求会跑在解释器和 C1 上,p99 被冷启动污染。

本机的做法是先跑 300 到 1000 个请求做 warmup,再开始正式测量。ab 没有内置 warmup,需要单独跑一次:

ab -k -n 1000 -c 20 http://127.0.0.1:18150/api/loans/42 > /dev/null
ab -k -n 5000 -c 50 http://127.0.0.1:18150/api/loans/42

测量窗口也要够长。5000 个请求在 10000 req/s 下只有 0.5 秒,可能刚好覆盖一次 GC 或一次 JIT 编译;对 p99 来说样本也太少(50 个)。正式基线至少跑 3 轮、每轮 5000 到 20000 个请求,取中位数,并记录每轮的原始值。

在 libraryd 里落地

libraryd-loadtest 模块只依赖 JDK,不引入压测框架。报告类型:

package com.plume.library.loadtest;

public record LoadReport(long requests, long errors, long durationMillis,
                         double throughput, long p50Nanos, long p95Nanos,
                         long p99Nanos, long maxNanos) {

    public String oneLine() {
        return "requests=%d errors=%d duration=%dms throughput=%.1f/s p50=%.2fms p95=%.2fms p99=%.2fms max=%.2fms"
                .formatted(requests, errors, durationMillis, throughput,
                        p50Nanos / 1_000_000.0, p95Nanos / 1_000_000.0,
                        p99Nanos / 1_000_000.0, maxNanos / 1_000_000.0);
    }
}

压测器用 HttpClient 加虚拟线程,每个 worker 处理一段请求,最后合并延迟数组:

package com.plume.library.loadtest;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public final class LoadRunner {

    private final String url;

    public LoadRunner(String url) {
        this.url = url;
    }

    public LoadReport run(int concurrency, int requests) throws Exception {
        HttpClient client = HttpClient.newBuilder()
                .executor(Executors.newVirtualThreadPerTaskExecutor())
                .connectTimeout(Duration.ofSeconds(2))
                .build();
        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
                .timeout(Duration.ofSeconds(10))
                .GET()
                .build();

        long[] latencies = new long[requests];
        long[] errors = new long[1];
        long start = System.nanoTime();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = new ArrayList<>(concurrency);
            for (int worker = 0; worker < concurrency; worker++) {
                int from = worker * requests / concurrency;
                int to = (worker + 1) * requests / concurrency;
                futures.add(executor.submit(() -> {
                    for (int i = from; i < to; i++) {
                        long t0 = System.nanoTime();
                        try {
                            HttpResponse<Void> response =
                                    client.send(request, HttpResponse.BodyHandlers.discarding());
                            if (response.statusCode() / 100 != 2) {
                                errors[0]++;
                            }
                        } catch (Exception e) {
                            errors[0]++;
                        }
                        latencies[i] = System.nanoTime() - t0;
                    }
                    return null;
                }));
            }
            for (Future<?> future : futures) {
                future.get();
            }
        }
        long duration = System.nanoTime() - start;
        Arrays.sort(latencies);
        return new LoadReport(requests, errors[0], duration / 1_000_000,
                requests / (duration / 1_000_000_000.0),
                percentile(latencies, 0.50), percentile(latencies, 0.95),
                percentile(latencies, 0.99), latencies[latencies.length - 1]);
    }

    private static long percentile(long[] sorted, double p) {
        int index = (int) Math.ceil(p * sorted.length) - 1;
        return sorted[Math.max(0, Math.min(index, sorted.length - 1))];
    }
}

对最小服务连续跑两次:

requests=5000 errors=0 duration=846ms throughput=5906.3/s p50=4.98ms p95=22.54ms p99=103.09ms max=134.35ms
requests=5000 errors=0 duration=742ms throughput=6735.2/s p50=5.22ms p95=13.33ms p99=45.47ms max=111.09ms

LoadRunner 的吞吐低于 ab -k 的 10201 req/s,p99 也更高。差异来自客户端实现、连接复用策略和虚拟线程调度,不是服务端变了。不同工具的数字不能直接比较;同一个基线必须固定工具、固定参数、固定机器。

libraryd 的借阅端点压测口径:

  1. 读路径 /api/loans/{id} 和写路径 /api/loans(借阅)分开测,写路径有数据库事务和行锁。
  2. 用连接池客户端,带 keep-alive;不要用每次新建连接的 ab 默认模式。
  3. warmup 1000 个请求,正式 3 轮 × 5000 请求,记录每轮 p50/p95/p99 和错误数。
  4. 数据库预置足够的数据,避免测试中途扩容或主键冲突改变计划。
  5. 报告里写清工具、并发、连接数、warmup、数据规模和机器口径。

最小服务没有数据库、没有鉴权、没有 JSON 序列化框架,它的 10000 req/s 是上限参考,不是 libraryd 的生产数字。

常见错误与边界

错误一:ab 不带 -k 就下结论。 本机同一端点,不带 -k 是 1909 req/s、p99 78 ms,带 -k 是 10202 req/s、p99 17 ms。连接建立成本被算进了服务端延迟,扩容决策会完全失真。

错误二:只看均值。 均值 4.9 ms 可能对应 p99 17 ms;50 并发下尾部请求会占住连接,把排队时间传给后续请求。容量看 p99,均值只用于趋势。

错误三:没有 warmup。 首请求比稳态慢 20 倍,前几百个请求会污染分位。先跑 300 到 1000 个请求,再开始计时。

错误四:用闭路压测器证明 p99。 服务端停顿时,闭路压测器少发了请求,慢请求被系统性漏掉。要验证 SLA,用开路模型并记录 corrected 延迟。

错误五:跨工具比较数字。 ab -k 的 10201 req/s 和 LoadRunner 的 5906 req/s 不是同一个客户端。基线报告必须固定工具;换工具要重新建基线。

边界一:localhost 压测不等于生产网络。 本机没有交换机、负载均衡、TLS、跨可用区延迟。localhost 的 p99 是好下限,不是生产 SLA。

边界二:最小服务不是 libraryd。 它没有 JDBC、连接池、鉴权、JSON 序列化、事务。把它的 10000 req/s 当成 libraryd 的容量,会低估数据库和网络的影响。

小结

  • 同样 5000 请求、50 并发,ab 不带 -k 是 1909 req/s、p99 78 ms,带 -k 是 10202 req/s、p99 17 ms;差异来自连接复用。
  • 均值会掩盖尾部;容量决策看 p99,并同时报告 p50、p95、错误数和测量窗口。
  • 闭路压测器有协调缺失:本机 maxInFlight=1 时 naive p99 0.90 ms,corrected p99 2338 ms。
  • Little 定律 L = λW 在闭路模型下约等于并发数;本机吞吐在 10 到 50 并发之间持平,100 并发 p99 涨到 87.5 ms。
  • warmup、3 轮重复、固定工具和机器口径是基线的最低要求;跨工具数字不可直接比较。
  • 最小服务的数字只说明方法,不代表 libraryd 的数据库、网络和事务成本。

有了可信的指标口径,下一步是把「怀疑是 GC」变成一次可复现的调优:现象、证据、假设、改动、回归,每一步都留下数字。

阅读导航:上一节:9.3 AOT 缓存与 Native Image:边界与取舍 · 下一节:10.2 一次完整的调优:从现象到参数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发