《Netty 高性能网络编程》14.2 压测工具与指标口径

ab 能打 HTTP/1.1,wrk 和 hey 本机没装;9093 的自定义帧协议要用 Java 客户端压。本节给出可复制的 ab 命令、虚拟线程 FrameLoadClient、吞吐与 p50/p95/p99 口径、Little 定律、coordinated omission、预热与多轮方法,以及结果记录模板,所有数字都标注单次运行、未做统计。

本节目标:用本机真实可用的工具,把吞吐、分位、错误数、连接数、CPU 和 GC 量成一组能比较、能复现的数字。
适用版本:JDK 25(Temurin 25.0.5+8)+ Netty 4.2.19.Final;macOS 26.3 自带 ab 2.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 second9753.60完成的请求/秒,不是连接/秒
延迟Time per request 两行5.126 / 0.103 ms第一行是单请求视角,第二行是并发摊薄后的服务端视角
错误Failed requests0非 2xx 另有 Non-2xx responses 一行,两个都要看
连接Keep-Alive requests、Connect5000、均值 05000 个请求复用连接,建连时间接近 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)p50p95p99
19753.605.1264822
210105.034.9484712
39982.075.0094714

吞吐只差 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)p50p95p99meanerrorslittleL
110253.34.32 ms7.08 ms19.54 ms4.81 ms049.3
211036.54.29 ms5.29 ms12.23 ms4.48 ms049.4
311433.44.27 ms4.68 ms6.35 ms4.33 ms049.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 调优案例与回归 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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