本节目标:用
ab和 JDK 自带代码量出可复现的吞吐、分位、错误数与连接数,并用 Little 定律解释这三个数为什么必须一起看。
适用版本:JDK 25(Temurin 25.0.5+8);macOS 26.3 自带的ab2.3。
承接的问题
15.1 能回答「这条连接停在哪」,但回答不了「这个服务能扛多少」。linkd v1 评审时最容易出现的两种数字是:
- 用
ab -n 100 -c 1跑出几千 QPS,就认为服务能扛几千并发。并发一加,连接排队、accept变慢,数字立刻掉下来。 - 报告写「平均延迟 5ms」,但用户投诉的是偶尔转圈两秒。平均值把尾部抹平了,p99 才是用户真正感知的部分。
压测的目标不是得到一个漂亮数字,而是得到一组能复现、能解释、能比较的数字。可复现要求给命令、机器口径和时长;能解释要求吞吐、延迟分位、错误数、连接数一起看;能比较要求同一个工具、同一组参数、同一个服务版本。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。服务是 15.1 的替身(JDK HttpServer,16 个平台线程,/healthz 返回 ok),端口 9091。它不是 linkd 本体,绝对数字只用于演示口径,不能当成 linkd 的性能承诺。
先检查工具:ab 在,wrk 不在
command -v ab wrk hey
/usr/sbin/ab -V
本机输出:
/usr/sbin/ab
This is ApacheBench, Version 2.3 <$Revision: 1913912 $>
wrk、hey 都没有安装,所以本节不写它们的实测。ab 的优点是 macOS 自带、输出包含分位表;缺点是默认 HTTP/1.0、不支持脚本化场景。压测 linkd 的 /healthz 足够,压测带业务参数的接口就不够了。
如果目标机器连 ab 都没有,用本节「核心机制四」的 JDK 最小压测器;它只依赖 java.net.Socket,能给出吞吐、错误数和分位,但没有 ab 的连接耗时拆分。
核心机制一:一次 ab 运行给了四组数
先跑一次 keep-alive:
/usr/sbin/ab -k -n 3000 -c 50 http://127.0.0.1:9091/healthz
本机输出(节选,单次运行,未做统计):
Concurrency Level: 50
Time taken for tests: 0.339 seconds
Complete requests: 3000
Failed requests: 0
Keep-Alive requests: 3000
Requests per second: 8861.74 [#/sec] (mean)
Time per request: 5.642 [ms] (mean)
Time per request: 0.113 [ms] (mean, across all concurrent requests)
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 2.2 0 22
Processing: 2 5 2.1 5 26
Total: 2 5 3.8 5 33
Percentage of the requests served within a certain time (ms)
50% 5
95% 8
99% 32
100% 33 (longest request)
这份报告要分四组读,缺一组就会误判:
| 组 | 字段 | 本机值 | 说明 |
|---|---|---|---|
| 吞吐 | Requests per second | 8861.74 | 单位是「完成的请求/秒」,不是「连接/秒」 |
| 延迟 | Time per request 两行 | 5.642 / 0.113 ms | 第一行是单请求视角,第二行是并发摊薄后的服务端视角 |
| 错误 | Failed requests | 0 | 非 2xx 另有一行 Non-2xx responses,不要只看失败数 |
| 连接 | Keep-Alive requests、Connect | 3000、均值 0 | 3000 个请求复用连接,建连时间接近 0 |
两个 Time per request 差 50 倍,正好等于并发数。把第二行当成用户延迟,会得出「服务只要 0.1ms」的错误结论;把第一行当成服务端处理能力,又会把排队时间算进业务。报告里必须写清楚用的是哪一个。
Connect 均值 0、最大值 22ms,说明连接被复用;Processing 的均值 5ms、最大值 26ms,才是服务端处理加排队的范围。Total 的 p99 是 32ms,比均值 5.642ms 高 5.7 倍,这个差距就是下一节要解释的尾部。如果 Processing 随并发线性上涨而 Connect 仍接近 0,先查 EventLoop 是否在 Handler.onReadable 里做了阻塞调用。
核心机制二:p99 是排出来的,不是算平均
同一组参数连跑三次,本机输出(三次运行,未做统计):
| 运行 | 吞吐(req/s) | 平均延迟(ms) | p50 | p95 | p99 |
|---|---|---|---|---|---|
| 1 | 8861.74 | 5.642 | 5 | 8 | 32 |
| 2 | 9398.44 | 5.320 | 4 | 7 | 25 |
| 3 | 9702.83 | 5.153 | 5 | 7 | 12 |
平均延迟只差 9%,p99 却从 12ms 跳到 32ms。这不是「服务不稳定」的道德判断,而是尾部的样本少、受调度和 GC 影响大。用一次运行的 p99 做硬门槛,结论会随运行次数翻转;要么多跑几次取中位数,要么把样本量做大。
分位数用最近秩(nearest-rank),不插值:
public static long percentile(long[] sortedNanos, double p) {
if (sortedNanos.length == 0) {
throw new IllegalArgumentException("empty samples");
}
int rank = (int) Math.ceil(p / 100.0 * sortedNanos.length);
int index = Math.max(0, Math.min(sortedNanos.length - 1, rank - 1));
return sortedNanos[index];
}
rank 是「第几个样本」,从 1 开始;数组下标要减 1。p99 在 100 个样本里是第 99 个,也就是第二慢的请求;在 3000 个样本里是第 2970 个,也就是第 30 慢的请求。样本越少,p99 越接近最大值,波动越大。想让 p99 有统计意义,至少 1000 个样本,最好 10000 个。
不要对 p99 再求平均:三次运行的 p99 平均是 23ms,但没有任何一次运行真的出现过 23ms 的 p99;报区间或中位数。
核心机制三:Little 定律把三个数绑在一起
Little 定律是 L = λ × W:系统中的平均请求数等于到达率乘以平均停留时间。压测里 L 是并发连接数,λ 是吞吐,W 是平均延迟。三者不是独立的,给定两个,第三个就定了。
用本机数据验证:并发 50,平均延迟 5.642ms,预测吞吐 50 / 0.005642 = 8862.1,ab 报 8861.74,差 0.004%。这不是巧合,是定律。
反过来估算并发。目标 8000 req/s,预期平均延迟 5.6ms,所需并发就是 ceil(8000 × 5.6 / 1000),即 45 条;LoadTestPlan.plan() 只是把这个乘法封成了方法。实际用 50 条跑到 8861,说明估算方向正确,还留了约 10% 余量。
两个边界必须写清楚:
W是平均延迟,不是 p99。 用 p99 代入会得到8000 × 0.032 = 256条,这是「所有请求都按最慢 1% 走」的上界,不是所需并发。容量规划用均值,SLO 用分位,两者不能混。- 闭模型和开模型不同。
ab是闭模型:固定 50 个客户端,上一个请求完成才发下一个,服务变慢时到达率自动下降。真实用户是开模型:不管服务多慢,请求都会按外部速率到达。开模型下排队会发散,ab的 p99 会低估真实尾部,这就是 coordinated omission。要测开模型,得用固定速率发请求的工具,或者自己按时间表补发。
核心机制四:没有 ab 时的 JDK 最小压测器
只用 JDK 也能测。下面的类固定并发、总共发 total 个请求,每个请求新建连接、发 HTTP/1.0、读到状态行,记录纳秒耗时:
package com.plume.linkd.app;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public final class JdkLoadGenerator {
public static void main(String[] args) throws Exception {
String host = args[0];
int port = Integer.parseInt(args[1]);
int concurrency = Integer.parseInt(args[2]);
int total = Integer.parseInt(args[3]);
AtomicInteger next = new AtomicInteger();
AtomicInteger errors = new AtomicInteger();
long[] samples = new long[total];
CountDownLatch done = new CountDownLatch(total);
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
long start = System.nanoTime();
for (int i = 0; i < concurrency; i++) {
pool.execute(() -> {
while (true) {
int index = next.getAndIncrement();
if (index >= total) return;
long begin = System.nanoTime();
try {
request(host, port);
samples[index] = System.nanoTime() - begin;
} catch (IOException e) {
errors.incrementAndGet();
}
done.countDown();
}
});
}
done.await(60, TimeUnit.SECONDS);
pool.shutdownNow();
long elapsed = System.nanoTime() - start;
long[] ok = Arrays.stream(samples).filter(v -> v > 0).sorted().toArray();
System.out.printf("concurrency=%d total=%d errors=%d rps=%.1f%n",
concurrency, total, errors.get(), total / (elapsed / 1e9));
System.out.printf("p50=%.2fms p95=%.2fms p99=%.2fms%n",
LoadTestPlan.percentile(ok, 50) / 1e6,
LoadTestPlan.percentile(ok, 95) / 1e6,
LoadTestPlan.percentile(ok, 99) / 1e6);
}
private static void request(String host, int port) throws IOException {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), 2000);
socket.setSoTimeout(3000);
String request = "GET /healthz HTTP/1.0\r\nHost: " + host + "\r\n\r\n";
socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII));
BufferedReader reader = new BufferedReader(new InputStreamReader(
socket.getInputStream(), StandardCharsets.ISO_8859_1));
String statusLine = reader.readLine();
if (statusLine == null || !statusLine.startsWith("HTTP/1.1 200"))
throw new IOException("bad status: " + statusLine);
}
}
}
本机输出(20 并发、500 个请求,单次运行,未做统计):
concurrency=20 total=500 errors=0 rps=1439.9
p50=11.94ms p95=14.10ms p99=50.00ms
同样是非 keep-alive,ab -n 2000 -c 50 在本机是 1964.34 req/s、p50 25ms、p99 33ms。两个数不相等,因为客户端实现不同:这个生成器每个请求都新建连接、用 HTTP/1.0、没有预热;ab 也有建连,但客户端更紧凑,且 -c 50 的排队行为不同。不同工具的数字不能直接比,只能用同一个工具比较服务版本的前后差异。
这个生成器还有一个盲点:done.await 只等 60 秒,超时请求不会减少 total,吞吐仍按「总数 / 耗时」算,看起来正常。生产压测必须单独统计超时与未完成数。
在 linkd 里落地:LoadTestPlan
把口径固化成类:给目标吞吐和预期平均延迟,算所需并发;解析 ab 报告;用 p99 预算做判定。
package com.plume.linkd.app;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public final class LoadTestPlan {
public record Plan(double targetRps, double meanLatencyMillis,
double p99BudgetMillis, int concurrency) { }
public record AbResult(double rps, long failed, double p50, double p95, double p99) { }
public record Verdict(boolean pass, List<String> reasons) { }
public static void main(String[] args) throws IOException {
Plan plan = plan(Double.parseDouble(args[1]), Double.parseDouble(args[2]),
Double.parseDouble(args[3]));
AbResult result = parseAb(Path.of(args[0]));
System.out.printf("plan targetRps=%.0f mean=%.1fms p99Budget=%.1fms concurrency=%d%n",
plan.targetRps(), plan.meanLatencyMillis(), plan.p99BudgetMillis(),
plan.concurrency());
System.out.printf("actual rps=%.2f failed=%d p50=%.2fms p95=%.2fms p99=%.2fms%n",
result.rps(), result.failed(), result.p50(), result.p95(), result.p99());
Verdict verdict = verify(plan, result);
System.out.println("verdict=" + (verdict.pass() ? "PASS" : "FAIL")
+ " " + verdict.reasons());
}
public static Plan plan(double targetRps, double meanLatencyMillis, double p99BudgetMillis) {
double inFlight = targetRps * meanLatencyMillis / 1000.0;
return new Plan(targetRps, meanLatencyMillis, p99BudgetMillis,
Math.max(1, (int) Math.ceil(inFlight)));
}
public static AbResult parseAb(Path file) throws IOException {
double rps = 0;
double p50 = 0;
double p95 = 0;
double p99 = 0;
long failed = -1;
for (String raw : Files.readAllLines(file)) {
String line = raw.trim();
if (line.startsWith("Requests per second:")) rps = firstNumber(line);
else if (line.startsWith("Failed requests:")) failed = (long) firstNumber(line);
else if (line.startsWith("50%")) p50 = firstNumber(line);
else if (line.startsWith("95%")) p95 = firstNumber(line);
else if (line.startsWith("99%")) p99 = firstNumber(line);
}
if (failed < 0) throw new IllegalArgumentException("not an ab report: " + file);
return new AbResult(rps, failed, p50, p95, p99);
}
public static long percentile(long[] sortedNanos, double p) {
if (sortedNanos.length == 0) throw new IllegalArgumentException("empty samples");
int rank = (int) Math.ceil(p / 100.0 * sortedNanos.length);
return sortedNanos[Math.max(0, Math.min(sortedNanos.length - 1, rank - 1))];
}
public static Verdict verify(Plan plan, AbResult result) {
List<String> reasons = new ArrayList<>();
if (result.failed() != 0) reasons.add("failed=" + result.failed());
if (result.rps() < plan.targetRps())
reasons.add(String.format("rps %.2f < %.0f", result.rps(), plan.targetRps()));
if (result.p99() > plan.p99BudgetMillis())
reasons.add(String.format("p99 %.2fms > %.1fms",
result.p99(), plan.p99BudgetMillis()));
return new Verdict(reasons.isEmpty(), reasons);
}
private static double firstNumber(String line) {
for (String token : line.split("\\s+")) {
try {
return Double.parseDouble(token);
} catch (NumberFormatException ignored) {
// token 不是数字,继续找下一个
}
}
throw new IllegalArgumentException("no number in: " + line);
}
}
验收目标是 8000 req/s、平均 5.6ms、p99 不超过 50ms,对第一次运行做判定:
java -cp out com.plume.linkd.app.LoadTestPlan logs/ab-keep-1.txt 8000 5.6 50
本机输出:
plan targetRps=8000 mean=5.6ms p99Budget=50.0ms concurrency=45
actual rps=8861.74 failed=0 p50=5.00ms p95=8.00ms p99=32.00ms
verdict=PASS []
把 p99 预算收紧到 20ms,同一次运行输出 verdict=FAIL [p99 32.00ms > 20.0ms]。
同一次运行、同一个吞吐,只改预算就翻转结论。这说明判定必须写成「目标 + 分位 + 预算」三元组,不能只说「性能达标」。plan() 给的是起点,verify() 给的是门槛,两者都用同一份 ab 输出,避免「计划用一套数、验收用另一套数」。
三次运行的完整对照:
| 运行 | rps | p99 | 对 8000/50ms 判定 | 对 8000/20ms 判定 |
|---|---|---|---|---|
| 1 | 8861.74 | 32 | PASS | FAIL |
| 2 | 9398.44 | 25 | PASS | FAIL |
| 3 | 9702.83 | 12 | PASS | PASS |
三次都过 50ms,只有一次过 20ms。CI 里如果只取一次运行,20ms 门槛会随机红绿;正确做法是三次全跑、看最差一次,或者把样本量加到 30000 再看 p99。
常见错误与边界
错误一:不加 -k 就下结论。 ab 默认 HTTP/1.0、每个请求新建连接。本机 2000 个非 keep-alive 请求只有 1964 req/s,而 3000 个 keep-alive 请求有 8861 req/s,差 4.5 倍。测的是连接建立还是服务处理,要写清楚。
错误二:把第二个 Time per request 当用户延迟。 0.113ms 是「并发摊薄」后的数字,用户感知接近 5.642ms。报告里两个都写,并注明视角。
错误三:-n 太小。 100 个请求的 p99 是第二慢的请求,一次 GC 就能把它拉到几百毫秒。至少 1000,涉及 p99 建议 10000;-n 太小还会让 Time taken 短到受启动开销影响。
边界:闭模型低估尾部。 ab 的 50 个客户端在上一个请求完成后才发下一个,服务卡住时新请求不会继续到达,p99 里看不到「本应排队」的那些请求。对外承诺 SLO 时,要用开模型工具或按固定速率补发。
边界:并发超过 backlog 会变成连接错误。 -c 远大于 ServerSocket backlog 时,失败会以 Connection reset 或超时出现,看起来像服务崩溃,实际是 accept 队列溢出。先把 -c 控制在压测目标内,再单独测 backlog 上限。
小结
- 本机
ab可用、wrk/hey不在;压测数字必须带命令、机器口径和时长。 - 一次
ab报告要同时读吞吐、两个Time per request、Failed requests与Keep-Alive requests/Connect。 - p99 用最近秩从排序样本里取,样本少于 1000 时波动很大;三次运行 p99 从 12ms 到 32ms。
- Little 定律
L = λW:本机 50 并发、5.642ms 平均延迟预测 8862 req/s,与ab的 8861.74 一致。 LoadTestPlan用平均延迟算并发、用 p99 预算做判定;不同工具的数字不能直接比,回环数字只能当回归基线。
到这里,连接诊断和容量口径都有了。最后一节把它们收成 linkd v1 的验收清单、故障注入项和复盘模板,逐条对应前 14 章。
阅读导航:上一节:15.1 tcpdump、netstat 与连接诊断 · 下一节:15.3 linkd v1 验收与故障复盘 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。