本节目标:把「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 | 平均延迟 ms | p50 ms | p95 ms | p99 ms | L = λW |
|---|---|---|---|---|---|---|
| 10 | 3964.7 | 2.52 | 2.0 | 6.5 | 9.5 | 10.0 |
| 20 | 3994.4 | 5.03 | 3.5 | 12.5 | 21.0 | 20.1 |
| 50 | 4146.3 | 12.09 | 8.5 | 30.0 | 57.0 | 50.1 |
| 100 | 3447.1 | 29.02 | 24.0 | 62.5 | 87.5 | 100.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 的借阅端点压测口径:
- 读路径
/api/loans/{id}和写路径/api/loans(借阅)分开测,写路径有数据库事务和行锁。 - 用连接池客户端,带 keep-alive;不要用每次新建连接的
ab默认模式。 - warmup 1000 个请求,正式 3 轮 × 5000 请求,记录每轮 p50/p95/p99 和错误数。
- 数据库预置足够的数据,避免测试中途扩容或主键冲突改变计划。
- 报告里写清工具、并发、连接数、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 一次完整的调优:从现象到参数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。