本节目标:用本机真实可用的工具,把吞吐、分位、错误数、连接数、CPU 和 GC 量成一组能比较、能复现的数字。
适用版本:JDK 25(Temurin 25.0.5+8)+ Netty 4.2.19.Final;macOS 26.3 自带ab2.3。
承接的问题
14.1 把参数固定成了起点,但起点本身不说明服务能扛多少。压测最容易出现的两种误判是:用 ab -n 100 -c 1 得到一个漂亮 QPS,就把它当成并发能力;或者只报平均延迟,把用户真正感知的尾部抹平。前一种把「单连接顺序请求」当成「并发吞吐」,后一种把「平均 5 ms、偶尔 2 秒」说成「服务很快」。
压测要产出的是一组能解释的数字,不是单个最大值。可解释要求同时给出:用了什么工具、打了哪个端口、并发和时长、机器口径、预热方式、吞吐、p50/p95/p99、错误数、连接数、CPU 和 GC。缺任何一项,下一次运行就无法判断差异来自服务还是来自测试方法。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面所有数字都来自这台机器,每次测量均为单次运行,未做统计;它们只用于演示口径,不是 linkd-v2 的性能承诺。
先确认工具:ab 在,wrk 和 hey 不在
command -v ab wrk hey
/usr/sbin/ab -V
本机输出是 /usr/sbin/ab 与 ApacheBench 2.3;wrk 和 hey 没有输出,说明本机没有安装。因此本节不写它们的实测,也不把网上抄来的数字当成自己的结果。ab 是 macOS 自带的 HTTP 压测工具,输出包含分位表和连接耗时拆分;它默认发送 HTTP/1.0 请求,-k 让同一批连接复用,而不是每个请求重新建连。很多服务同时接受 HTTP/1.0 和 HTTP/1.1,所以 ab 仍能打 HTTP/1.1 端点,但报告里的 Keep-Alive requests 和 Connect 要按 HTTP/1.0 的语义读。
ab 的边界也很明确:它只会发固定 URL 的 HTTP 请求,不能压 9093 的 LKDF 二进制帧,不能做请求关联,也不能模拟「服务变慢后客户端仍然按原速率到达」的开模型。9093 要用下面的 Java 客户端;HTTP 的业务参数化场景,ab 也不够用。
HTTP/1.1:先让一个 Netty 服务能稳定 keep-alive
为了只观察压测口径,本机用一个最小的 Netty HTTP 服务监听 19090,/healthz 返回 ok。bootstrap 与 14.1 的 BaselineProbe 相同,只把 child handler 换成 HttpServerCodec → HttpObjectAggregator(1 MiB) → HealthHandler:
package demo;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.*;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.*;
import java.nio.charset.StandardCharsets;
public final class HttpHealthServer {
public static void main(String[] args) throws Exception {
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup(4);
ServerBootstrap bootstrap = new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new HttpServerCodec(),
new HttpObjectAggregator(1 << 20), new HealthHandler());
}
});
Channel server = bootstrap.bind(19090).sync().channel();
System.out.println("health listening on 19090");
server.closeFuture().sync();
}
static final class HealthHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
boolean ok = "/healthz".equals(request.uri());
byte[] body = (ok ? "ok" : "not found").getBytes(StandardCharsets.UTF_8);
FullHttpResponse response = new DefaultFullHttpResponse(
request.protocolVersion(),
ok ? HttpResponseStatus.OK : HttpResponseStatus.NOT_FOUND,
Unpooled.wrappedBuffer(body));
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, body.length);
boolean keepAlive = HttpUtil.isKeepAlive(request);
HttpUtil.setKeepAlive(response, keepAlive);
if (keepAlive) {
ctx.writeAndFlush(response);
} else {
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
}
}
}
两个细节不能省。第一,响应版本用 request.protocolVersion(),不要无条件写 HTTP_1_1;ab 默认发 HTTP/1.0 请求,服务端回 HTTP/1.1 且不带 Connection: keep-alive 时,ab 会等不到连接复用,最后报 apr_pollset_poll: The timeout specified has expired (70007)。第二,HttpUtil.setKeepAlive(response, keepAlive) 要在 Content-Length 设置之后调用,keep-alive 的判定依赖请求头和响应长度。
启动后先手工确认一次:
cp=$(find ~/.m2/repository/io/netty -path "*4.2.19.Final*" -name "*.jar" | tr '\n' ':')
javac -cp "out:$cp" -d out src/main/java/demo/HttpHealthServer.java
java -cp "out:$cp" demo.HttpHealthServer
curl -s -i http://127.0.0.1:19090/healthz
本机确认:监听 19090,HTTP/1.1 200 OK,body 为 ok。
一次 ab 运行给了四组数
先跑一轮 keep-alive:
/usr/sbin/ab -k -n 5000 -c 50 http://127.0.0.1:19090/healthz
本机输出(节选,单次运行,未做统计):
Concurrency Level: 50
Time taken for tests: 0.513 seconds
Complete requests: 5000
Failed requests: 0
Keep-Alive requests: 5000
Requests per second: 9753.60 [#/sec] (mean)
Time per request: 5.126 [ms] (mean)
Time per request: 0.103 [ms] (mean, across all concurrent requests)
Percentage of the requests served within a certain time (ms)
50% 4
95% 8
99% 22
100% 36 (longest request)
这份报告要分四组读:
| 组 | 字段 | 本机值 | 说明 |
|---|---|---|---|
| 吞吐 | Requests per second | 9753.60 | 完成的请求/秒,不是连接/秒 |
| 延迟 | Time per request 两行 | 5.126 / 0.103 ms | 第一行是单请求视角,第二行是并发摊薄后的服务端视角 |
| 错误 | Failed requests | 0 | 非 2xx 另有 Non-2xx responses 一行,两个都要看 |
| 连接 | Keep-Alive requests、Connect | 5000、均值 0 | 5000 个请求复用连接,建连时间接近 0 |
两个 Time per request 相差 50 倍,正好等于并发数 50。把第二行当用户延迟,会得出「服务只要 0.1 ms」;把第一行当服务端处理能力,又会把排队时间算进业务。报告里必须写清楚用的是哪一个。Connect 均值 0、最大 21 ms,说明连接被复用;Processing 的均值 5 ms、最大 23 ms,才是服务端处理加排队的范围。p99 的 22 ms 比均值 5.126 ms 高 4 倍多,这个差距就是尾部。
同一组参数连跑三次:
| 运行 | 吞吐(req/s) | 平均延迟(ms) | p50 | p95 | p99 |
|---|---|---|---|---|---|
| 1 | 9753.60 | 5.126 | 4 | 8 | 22 |
| 2 | 10105.03 | 4.948 | 4 | 7 | 12 |
| 3 | 9982.07 | 5.009 | 4 | 7 | 14 |
吞吐只差 3.6%,p99 却从 12 ms 跳到 22 ms。这不是「服务不稳定」的道德判断,而是尾部的样本少、受调度和 JIT 影响大。用一次运行的 p99 做硬门槛,结论会随运行次数翻转;要么多跑几次取中位数,要么把样本量做大。
作为对照,去掉 -k 跑一次非 keep-alive:
/usr/sbin/ab -n 2000 -c 50 http://127.0.0.1:19090/healthz
本机结果(单次运行,未做统计):吞吐 1915.05 req/s、p50 25 ms、p95 29 ms、p99 43 ms、失败 0。同样的 50 并发,非 keep-alive 只有 1915.05 req/s,keep-alive 是 9753.60 req/s,差 5 倍多。差的是 TCP 建连和端口消耗,不是业务处理。测的是连接建立还是服务处理,报告里必须写清楚;把非 keep-alive 的数字拿去和 keep-alive 的数字比,会得出完全相反的结论。
自定义 9093:虚拟线程客户端
9093 是 LKDF 二进制帧,ab 打不了。帧格式在 7.1 定义:16 字节头(magic、version、type、flags、requestId、length)+ 负载,长度上限 1 MiB。客户端的职责是:建连、按固定并发发请求、按 requestId 关联响应、记录每个请求的纳秒耗时、统计错误。JDK 25 的虚拟线程适合这种「每个连接一个顺序请求循环」的闭模型,不需要手写线程池。
下面的 FrameLoadClient 是完整可运行版本。它先为每个连接做 warmupPerConnection 个预热请求,然后等所有连接就绪、同一时刻开始计量;每个连接串行发 requestsPerConnection 个请求,因此并发上限就是连接数。请求帧一次写入并 flush,客户端也设置了 TCP_NODELAY,避免把客户端的 Nagle 延迟算到服务端头上。
package com.plume.linkd.v2.app;
import com.plume.linkd.v2.protocol.FrameType;
import com.plume.linkd.v2.protocol.ProtocolConstants;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public final class FrameLoadClient {
public static void main(String[] args) throws Exception {
String host = args[0];
int port = Integer.parseInt(args[1]);
int connections = Integer.parseInt(args[2]);
int requestsPerConnection = Integer.parseInt(args[3]);
int payloadBytes = Integer.parseInt(args[4]);
int warmupPerConnection = Integer.parseInt(args[5]);
byte[] payload = new byte[payloadBytes];
for (int i = 0; i < payload.length; i++) {
payload[i] = (byte) (i * 31);
}
long[] samples = new long[connections * requestsPerConnection];
AtomicInteger errors = new AtomicInteger();
AtomicInteger success = new AtomicInteger();
CountDownLatch ready = new CountDownLatch(connections);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(connections);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int c = 0; c < connections; c++) {
int connectionId = c;
executor.execute(() -> {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), 2000);
socket.setSoTimeout(5000);
socket.setTcpNoDelay(true);
DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(socket.getOutputStream()));
DataInputStream in = new DataInputStream(
new BufferedInputStream(socket.getInputStream()));
for (int i = 0; i < warmupPerConnection; i++) {
roundTrip(out, in, payload, connectionId * 1_000_000 - 1 - i);
}
ready.countDown();
start.await();
for (int i = 0; i < requestsPerConnection; i++) {
int requestId = connectionId * 1_000_000 + i;
long begin = System.nanoTime();
roundTrip(out, in, payload, requestId);
samples[connectionId * requestsPerConnection + i] =
System.nanoTime() - begin;
success.incrementAndGet();
}
} catch (Exception e) {
errors.incrementAndGet();
ready.countDown();
} finally {
done.countDown();
}
});
}
if (!ready.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("not ready");
long startNanos = System.nanoTime();
start.countDown();
if (!done.await(120, TimeUnit.SECONDS)) throw new IllegalStateException("timeout");
long elapsedNanos = System.nanoTime() - startNanos;
long[] ok = Arrays.stream(samples).filter(v -> v > 0).sorted().toArray();
double seconds = elapsedNanos / 1e9;
double throughput = success.get() / seconds;
double meanMillis = Arrays.stream(ok).average().orElse(0) / 1e6;
System.out.printf("connections=%d perConnection=%d payload=%d warmup=%d%n",
connections, requestsPerConnection, payloadBytes, warmupPerConnection);
System.out.printf("success=%d errors=%d elapsed=%.3fs throughput=%.1f req/s%n",
success.get(), errors.get(), seconds, throughput);
System.out.printf("p50=%.2fms p95=%.2fms p99=%.2fms mean=%.2fms%n",
percentile(ok, 50) / 1e6, percentile(ok, 95) / 1e6,
percentile(ok, 99) / 1e6, meanMillis);
System.out.printf("littleL=%.1f (connections=%d)%n",
throughput * meanMillis / 1000.0, connections);
}
}
private static void roundTrip(DataOutputStream out, DataInputStream in,
byte[] payload, int requestId) throws IOException {
byte[] frame = new byte[ProtocolConstants.HEADER_LENGTH + payload.length];
ByteBuffer buf = ByteBuffer.wrap(frame);
buf.putInt(ProtocolConstants.MAGIC);
buf.put(ProtocolConstants.VERSION);
buf.put((byte) FrameType.REQUEST);
buf.putShort((short) 0);
buf.putInt(requestId);
buf.putInt(payload.length);
buf.put(payload);
out.write(frame);
out.flush();
int magic = in.readInt();
in.readByte();
int type = in.readUnsignedByte();
in.readUnsignedShort();
int responseId = in.readInt();
int length = in.readInt();
if (magic != ProtocolConstants.MAGIC || type != FrameType.RESPONSE
|| responseId != requestId
|| length < 0 || length > ProtocolConstants.MAX_PAYLOAD) {
throw new IOException("bad response id=" + responseId);
}
in.readFully(new byte[length]);
}
private static long percentile(long[] sorted, double p) {
if (sorted.length == 0) return 0;
int rank = (int) Math.ceil(p / 100.0 * sorted.length);
return sorted[Math.max(0, Math.min(sorted.length - 1, rank - 1))];
}
}
服务端用 linkd-v2 的 9093 Echo pipeline(LinkdFrameDecoder → LinkdFrameEncoder → LinkdFrameEchoHandler),本机为了不启动 9090,用一个只绑定 19093 的 TuningCaseServer,完整代码在 14.3。启动和压测命令:
java -Dlinkd.handler=echo -Dlinkd.ioThreads=4 \
-cp "out:$cp" com.plume.linkd.v2.app.TuningCaseServer
java -cp "out:$cp" com.plume.linkd.v2.app.FrameLoadClient \
127.0.0.1 19093 50 200 1024 50
本机输出(三次运行,每次前各做一轮 50 连接的预热;每次为单次运行,未做统计):
| 运行 | 吞吐(req/s) | p50 | p95 | p99 | mean | errors | littleL |
|---|---|---|---|---|---|---|---|
| 1 | 10253.3 | 4.32 ms | 7.08 ms | 19.54 ms | 4.81 ms | 0 | 49.3 |
| 2 | 11036.5 | 4.29 ms | 5.29 ms | 12.23 ms | 4.48 ms | 0 | 49.4 |
| 3 | 11433.4 | 4.27 ms | 4.68 ms | 6.35 ms | 4.33 ms | 0 | 49.5 |
这组数字的测量条件是:50 条连接、每条 200 个请求、1 KiB 负载、每条连接 50 个预热请求、服务端 4 条 worker、客户端与服务端在同一台机器。littleL 在 49.3 到 49.5 之间,和 50 条连接吻合,说明这组数据内部的吞吐、延迟、连接数是一致的;如果它明显偏离 50,先查客户端是否超时、是否有连接没算进并发。
客户端实现会直接改变结果。最初用未缓冲的 DataOutputStream 分多次写头部和负载,且没有设置 TCP_NODELAY,同一台机器、同一个服务端,p50 在 15 ms 到 32 ms 之间;改成一次写入完整帧、加缓冲流和 socket.setTcpNoDelay(true) 后,p50 才降到 4.3 ms 左右。这不是服务端变快了,而是客户端不再把多个小包和 Nagle 延迟算进 RTT。报告里必须写客户端版本;换客户端后,前后数字不能直接比。
指标口径:六个指标与三个陷阱
| 指标 | 定义 | 本机示例 | 常见陷阱 |
|---|---|---|---|
| 吞吐 | 完成的请求数 / 墙钟时间 | 10253.3 req/s | 把连接数当吞吐,或把未完成请求算进分子 |
| p50/p95/p99 | 排序样本的最近秩 | 4.32 / 7.08 / 19.54 ms | 样本太少;对多次运行的 p99 再求平均 |
| 错误率 | 失败请求 / 总请求 | 0 / 10000 | 只看传输错误,漏掉非 2xx 或业务错误码 |
| 连接数 | 同时存在的客户端连接 | 50 | 把 -c 当并发请求数;闭模型下二者才近似相等 |
| CPU | 进程和 EventLoop 线程占用 | ps -p PID -o %cpu | 只看整机 CPU,漏掉单线程打满 |
| GC | 停顿次数、最长停顿 | JFR jdk.GCPhasePause | 把堆大小当停顿;不看分位只看平均 |
分位数用最近秩,不插值:rank = ceil(p / 100 × n),下标取 rank - 1。p99 在 10000 个样本里是第 9900 个,也就是第 100 慢的请求;在 200 个样本里是第 198 个,也就是第 3 慢的请求。样本越少,p99 越接近最大值,波动越大。想让 p99 有统计意义,至少 1000 个样本,最好 10000 个。不要对多次运行的 p99 再求平均:三次的 p99 平均是 12.7 ms,但没有任何一次运行真的出现过 12.7 ms 的 p99;报区间或中位数。
陷阱一:coordinated omission。 上面的客户端是闭模型:每条连接上一个请求完成后才发下一个。服务端卡住时,客户端不会继续按原速率发请求,卡住期间的「本应排队」样本没有被记录,p99 会低估真实尾部。真实用户是开模型:不管服务多慢,请求都会按外部速率到达。要测开模型,得用固定速率发请求的工具,或者自己按时间表补发并单独统计「迟到」的请求。闭模型适合版本间回归,不适合直接对外承诺 SLO。
陷阱二:Little 定律用错量。 L = λ × W 里 W 是平均停留时间,不是 p99。用本机数据验证:吞吐 10253.3 req/s、平均延迟 4.81 ms,预测在途请求数 10253.3 × 0.00481 = 49.3,和 50 条连接一致。用 p99 代入会得到 10253.3 × 0.01954 = 200.3,这是「所有请求都按最慢 1% 走」的上界,不是所需并发。容量规划用均值,SLO 用分位,两者不能混。
陷阱三:没有预热、只跑一轮。 三次运行的 p99 从 6.35 ms 到 19.54 ms,JIT、连接建立、页缓存和调度都在前几秒影响结果。每次运行前先做一轮预热,正式测量至少跑三次;报告里写「三次运行,取中位数」或「三次全列,最差 p99 为多少」,不要只挑最好的一次。ab 的 -n 太小还会让 Time taken 短到受启动开销影响;涉及 p99 时 -n 至少 1000,建议 10000。
在 linkd-v2 里落地:结果记录模板
每次运行追加一行 TSV,字段固定,用 runId 关联原始输出:
timestamp runId tool connections requests throughput p50 p95 p99 errors cpu gcPause note
<ISO8601> <runId> FrameLoadClient <n> <n> <req/s> <ms> <ms> <ms> <n> <%> <ms> <handler,payload,warmup>
这一行只做记录,不做判定;判定阈值来自需求,不来自某次最好成绩。cpu 和 gcPause 用同一次运行的采样值,不要从别的运行抄。没有原始输出文件的行,事后无法复查分位样本数。
常见错误与边界
错误一:用 ab 打 9093。 LKDF 帧的 magic 是 0x4C4B4446,ab 发的是 GET /... HTTP/1.0,解码器会抛 CorruptedFrameException: bad magic: 0x47455420,连接被关闭。9093 只能用 FrameLoadClient 或同等的二进制客户端压。
错误二:不加 -k 就下结论。 本机 2000 个非 keep-alive 请求只有 1915.05 req/s,5000 个 keep-alive 请求有 9753.60 req/s。测的是连接建立还是服务处理,要写清楚;不同工具、不同客户端实现的数字也不能直接比。
错误三:样本太少还算 p99。 200 个请求的 p99 是第 3 慢的请求,一次 GC 或一次调度抖动就能把它拉到几十毫秒。涉及 p99 时至少 1000 个样本,最好 10000;如果只能跑 200 个,就报最大值和 p95,不要报一个没有统计意义的 p99。
边界:同一台机器上的客户端会和服务端争 CPU。 本机 100 条连接、8 KiB 负载时,服务端加到 4 条 worker 后吞吐停在 11.1k 左右,不是服务端线性扩展到了 4 倍,而是客户端先到了瓶颈。压测报告要写客户端放在哪台机器;生产容量评估应把客户端放到另一台机器,并观察两端 CPU。
边界:回环地址没有真实 RTT。 这里的 4 ms 和 10 ms 只适合做同机回归基线,不能外推到跨机房。跨网络压测要把 RTT、丢包、MTU、TLS 握手都算进去,否则得到的并发估算会偏小。
小结
- 本机
ab可用,wrk/hey未安装;HTTP 用ab,9093 用 JDK 虚拟线程客户端,不写未实测的工具结果。 - 一次
ab报告要同时读吞吐、两个Time per request、失败数和连接数;9093 客户端用 50 条连接、每条 200 个请求、1 KiB 负载测得 10253.3 到 11433.4 req/s,p99 在 6.35 到 19.54 ms 之间,Little 定律给出的在途数约 49.3 到 49.5。 - 分位用最近秩,样本至少 1000;多次运行不要对 p99 求平均,报区间或中位数。
- 闭模型会低估尾部(coordinated omission),Little 定律用均值不用 p99;同一台机器上的客户端可能先成为瓶颈。
到这里,工具和口径都有了。14.3 用一个真实的 p99 抖动案例,把 13 章的线程栈、JFR 和 14.1 的参数基线串起来,只改一个变量,再做前后对比和回归。
阅读导航:上一节:14.1 参数基线:水位、缓冲区、线程数 · 下一节:14.3 调优案例与回归 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。