《Netty 高性能网络编程》14.3 调优案例与回归

一个可复现的 linkd-v2 调优案例:100 条 9093 连接、8 KiB 负载,worker=1 时单条 EventLoop 接近满核,p50 12 ms、p99 33 ms。用 jcmd 线程栈、JFR ExecutionSample 与 GC 停顿排除法定位,只把 worker 从 1 改成 4,给出前后对比表与回归清单,所有数字标注单次运行、未做统计。

本节目标:用一个能复现的 linkd-v2 案例,走完「现象 → 假设 → 定位 → 只改一个变量 → 前后对比 → 回归」的完整路径。
适用版本:JDK 25(Temurin 25.0.5+8)+ Netty 4.2.19.Final。

承接的问题

14.1 固定了参数基线,14.2 给出了压测工具和指标口径。现在可以把它们用在一个真实问题上:9093 的 p99 从 12 ms 抖到 33 ms,同时 ps 看到一条 nioEventLoopGroup 线程接近满核,其他 worker 线程却很闲。此时最容易犯的错是直接搜「Netty 调优参数」,把水位、线程数、backlog 一起改掉;改完 p99 变好,也不知道是哪一项起的作用,下次换一台机器又复现不了。

本节只改一个变量:worker 线程数从 1 改成 4。其他参数保持 14.1 的基线,客户端、负载、机器、JDK 和 Netty 版本都不变。案例的目标不是证明「4 比 1 好」,而是演示如何把「单条 EventLoop 被 CPU 工作占满」这个假设,用 13 章的工具证实或推翻,再用一组可比较的数字收尾。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面所有性能数字都是单次运行,未做统计,只用于演示方法,不是 linkd-v2 的生产容量承诺。

现象:p99 抖动,一条 EventLoop 接近满核

案例用 14.2 的 FrameLoadClient 打 9093:100 条连接、每条 300 个请求、8 KiB 负载、每条连接 10 个预热请求。服务端是 TuningCaseServer,只绑定 19093,worker 显式设为 1,业务 handler 是 LinkdFrameDigestHandler,对每个请求做 16 轮 SHA-256。这里的 16 轮是人为放大的 CPU 工作,用来模拟「业务校验、序列化或压缩留在 EventLoop 上」的情况,不是生产代码。

先看 worker=1 的一次测量输出(单次运行,未做统计):

connections=100 perConnection=300 payload=8192 warmup=10
success=30000 errors=0 elapsed=4.358s throughput=6883.3 req/s
p50=12.78ms p95=25.38ms p99=33.73ms mean=14.29ms
littleL=98.3 (connections=100)

同时用 ps 采样服务端进程:

ps -p <pid> -o pid,%cpu,%mem,etime

本机输出(两次采样,单次运行,未做统计):

  PID  %CPU %MEM ELAPSED
94724  97.5  0.7   00:15
94724  90.5  0.7   00:17

%CPU 在 90% 到 98% 之间,接近一个核;littleL=98.3 和 100 条连接吻合,说明客户端没有大面积超时,吞吐和延迟是同一组运行里的数。p50 12.78 ms、p99 33.73 ms,尾部是中位数的 2.6 倍。这个差距不算灾难,但足以让「p99 预算 20 ms」的验收失败。

TuningCaseServer 的启动命令:

java -Dlinkd.handler=digest -Dlinkd.ioThreads=1 \
     -cp "out:$cp" com.plume.linkd.v2.app.TuningCaseServer

假设与排除:先证明不是 GC,也不是客户端

在改任何参数之前,先写出可被推翻的假设:

  1. GC 停顿:如果 p99 的 33 ms 里有一次 20 ms 以上的 Full GC,调 worker 数没有意义。
  2. 客户端瓶颈:如果客户端已经到顶,服务端加线程也不会提高吞吐。
  3. CPU 工作留在 EventLoop:LinkdFrameDigestHandler.channelRead0 在 EventLoop 线程上执行,单条线程被 SHA-256 占满,连接上的其他事件排队。

先看 GC。用 JFR 录一段负载,再读 jdk.GCPhasePause:

jcmd <pid> JFR.start name=case settings=profile duration=15s \
     filename=/tmp/linkd-case.jfr
jfr print --events jdk.GCPhasePause /tmp/linkd-case.jfr

本机输出(单次运行,未做统计):

jdk.GCPhasePause {
  duration = 1.95 ms
}
jdk.GCPhasePause {
  duration = 1.76 ms
}

这段记录里只有两次 young GC,最长 1.95 ms,解释不了 21 ms 的尾部。GC 不是这次 p99 抖动的主因。注意这只排除了「这一次运行的长 GC」,不能证明所有运行都没有;回归时仍要每次看 GC 停顿,而不是看一次就下结论。

再看客户端。14.2 已经看到,同一台机器上的 100 条连接客户端在 8 KiB 负载下大约到 11.1k req/s 就上不去;worker=1 的 6883.3 req/s 明显低于这个上限,所以这次不是客户端先到顶。这个判断依赖 14.2 的同条件对照,不是凭感觉;如果没有那个对照,就不能排除客户端。

最后看线程栈。负载运行中连续抓几次 jcmd <pid> Thread.print,其中一次抓到:

"nioEventLoopGroup-3-1" #27 [26119] prio=10 os_prio=31 cpu=4738.02ms elapsed=11.04s ... runnable
   java.lang.Thread.State: RUNNABLE
    at java.security.MessageDigest$Delegate.engineDigest(MessageDigest.java:700)
    at java.security.MessageDigest.digest(MessageDigest.java:411)
    at com.plume.linkd.v2.server.LinkdFrameDigestHandler.channelRead0(LinkdFrameDigestHandler.java:27)
    at io.netty.channel.SimpleChannelInboundHandler.channelRead(SimpleChannelInboundHandler.java:99)
    at io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:361)
    at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:176)

线程名是 nioEventLoopGroup-3-1,状态 RUNNABLE,栈顶在 MessageDigest.digest,下面直接是 LinkdFrameDigestHandler.channelRead0,再往下是 Netty 的读事件。这条证据把「CPU 工作留在 EventLoop」从猜测变成了可复核的事实。JFR 的 jdk.ExecutionSample 也抓到了同一个帧:

jfr print --events jdk.ExecutionSample /tmp/linkd-case.jfr | grep -A 6 LinkdFrameDigestHandler

线程栈是瞬时快照,一次抓到不代表每次都在这里;至少要抓三次,并结合 ps 的 CPU 和 JFR 的采样。反过来,如果栈一直停在 KQueue.poll 或 SocketDispatcher.write0,就不应该把问题归因到业务 handler。

只改一个变量:worker 1 → 4

假设成立后,改动只允许一项:-Dlinkd.ioThreads=1 改成 -Dlinkd.ioThreads=4。TuningCaseServer 用 Integer.getInteger("linkd.ioThreads", 4) 读取这个值,boss 仍是 1,水位、backlog、TCP_NODELAY、AUTO_READ、allocator 都不变。这样前后差异只能来自 worker 数量。

改后的启动命令:

java -Dlinkd.handler=digest -Dlinkd.ioThreads=4 \
     -cp "out:$cp" com.plume.linkd.v2.app.TuningCaseServer

客户端完整代码在 14.2,本节不重复;两次测量用同一个客户端命令,且都在正式测量前跑一轮 100 连接 × 50 请求的预热:

java -cp "out:$cp" com.plume.linkd.v2.app.FrameLoadClient \
     127.0.0.1 19093 100 300 8192 10

前后对比(同一台机器、同一客户端、同一负载、同一预热;单次运行,未做统计):

指标worker=1worker=4读法
吞吐(req/s)6883.311127.7客户端上限约 11.1k,改后已接近上限
p50(ms)12.788.76中位数下降,排队减少
p95(ms)25.389.94尾部开始收敛
p99(ms)33.7312.58从超过 20 ms 预算到低于预算
mean(ms)14.298.93与 Little 定律一起看
errors00两次都没有传输错误
littleL98.399.4都与 100 条连接吻合
ps CPU 采样97.5%155.7%单核接近打满 → 摊到多个核

这里不写「提升 61.7%」之类的百分比。原因有三个:第一,两次都是单次运行,没有置信区间;第二,客户端在同一台机器上,11.1k 附近已经是客户端上限,吞吐被客户端截断;第三,CPU 采样是 ps 的快照,不是一整段的平均值。能说的是:在这个受控条件下,p99 从 33.73 ms 降到 12.58 ms,吞吐从 6883.3 升到 11127.7,且错误数保持 0。是否可复现,要靠下一节的回归清单,不是靠这一张表。

为什么不是 4 倍?因为 100 条连接、8 KiB 负载的客户端上限就在 11.1k 左右;4 条 worker 只是把服务端从「一条线程打满」变成「多条线程分担」,并没有把客户端也扩容。若把客户端放到另一台机器,或者把连接数加到 300,数字会变;那时要重新测,不能把这里的 11127.7 当成 4 条 worker 的固定容量。

还有一个边界:如果只有一条热连接,把 worker 从 1 改成 4 也不会让它变快,因为一个 Channel 终身绑定一个 EventLoop。这种情况下应该把 CPU 工作移到 DefaultEventExecutorGroup(3.3),让 EventLoop 继续读,业务线程并行处理;代价是要处理消息顺序、背压和关闭。worker 数只解决「多条连接分不到多个 EventLoop」的问题,不解决「单连接把一条 EventLoop 占满」的问题。

案例代码:TuningCaseServer 与 DigestHandler

LinkdFrameDigestHandler 是每连接新建的入站 handler。它只做一件事:对 payload 做 16 轮 SHA-256,把 32 字节摘要作为 RESPONSE 写回。16 轮是故意放大的 CPU 工作,真实项目里对应的是签名校验、压缩、序列化或规则匹配;不要把它当成生产实现。

package com.plume.linkd.v2.server;

import com.plume.linkd.v2.protocol.FrameType;
import com.plume.linkd.v2.protocol.LinkdFrame;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public final class LinkdFrameDigestHandler extends SimpleChannelInboundHandler<LinkdFrame> {
    private final MessageDigest digest;

    public LinkdFrameDigestHandler() {
        try {
            this.digest = MessageDigest.getInstance("SHA-256");
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 unavailable", e);
        }
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, LinkdFrame frame) {
        byte[] hash = null;
        for (int i = 0; i < 16; i++) {
            hash = digest.digest(frame.payload());
        }
        ctx.writeAndFlush(new LinkdFrame(
                FrameType.RESPONSE, frame.flags(), frame.requestId(), hash));
    }
}

MessageDigest 不是线程安全的,但一个 handler 实例只属于一个 Channel,而 Channel 只在一个 EventLoop 线程上执行,所以这里不需要额外同步。如果把这个 handler 标成 @Sharable 给多条连接共用,digest 会被并发调用,摘要结果和状态都会出错;4.3 的共享规则在这里同样适用。

TuningCaseServer 是案例的启动器,只绑定 19093,避免和 linkd-v2 的 9090/9093 抢端口。它保留 14.1 的基线,只把 worker 数做成启动参数;-Dlinkd.handler=echo 时换成 14.2 用的回显 handler。

package com.plume.linkd.v2.app;

import com.plume.linkd.v2.protocol.LinkdFrameDecoder;
import com.plume.linkd.v2.protocol.LinkdFrameEncoder;
import com.plume.linkd.v2.server.LinkdFrameDigestHandler;
import com.plume.linkd.v2.server.LinkdFrameEchoHandler;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public final class TuningCaseServer {
    public static void main(String[] args) throws Exception {
        int port = Integer.getInteger("linkd.port", 19093);
        int ioThreads = Integer.getInteger("linkd.ioThreads", 4);
        String handler = System.getProperty("linkd.handler", "digest");
        EventLoopGroup boss = new NioEventLoopGroup(1);
        EventLoopGroup worker = new NioEventLoopGroup(ioThreads);
        try {
            ServerBootstrap bootstrap = new ServerBootstrap()
                    .group(boss, worker)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childOption(ChannelOption.AUTO_READ, true)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) {
                            ch.pipeline().addLast(new LinkdFrameDecoder());
                            ch.pipeline().addLast(new LinkdFrameEncoder());
                            if ("echo".equals(handler)) {
                                ch.pipeline().addLast(new LinkdFrameEchoHandler());
                            } else {
                                ch.pipeline().addLast(new LinkdFrameDigestHandler());
                            }
                        }
                    });
            Channel server = bootstrap.bind(port).sync().channel();
            System.out.println("linkd-tuning listening port=" + port
                    + " boss=1 worker=" + ioThreads + " handler=" + handler);
            server.closeFuture().sync();
        } finally {
            boss.shutdownGracefully().sync();
            worker.shutdownGracefully().sync();
        }
    }
}

编译和运行依赖 14.1 的 classpath:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
cp=$(find ~/.m2/repository/io/netty -path "*4.2.19.Final*" -name "*.jar" | tr '\n' ':')
javac -cp "out:$cp" -d out $(find src/main/java -name "*.java")

回归清单

改完不能只留一张前后对比表。下面十项是回归的最小集合,任何一项缺失,下一次就无法判断问题是复现了还是环境变了:

#检查项命令或证据
1同一提交、JDK、Netty、机器git rev-parse --short HEAD、java -version
2同一客户端命令与负载14.2 的 FrameLoadClient,参数逐字一致
3每轮先预热100 连接 × 50 请求,预热输出不计入
4至少三次正式运行报中位数或三次全列,不报单次最好值
5错误数为 0客户端 errors、服务端异常日志
6p99 与预算20 ms 预算下,四次运行是否都通过
7进程与 EventLoop CPUps -p <pid> -o %cpu,必要时抓线程栈
8GC 停顿JFR jdk.GCPhasePause,看次数与最长值
9连接数与 backlog客户端连接数、`netstat -anv -p tcp
10参数只有一项变化14.1 的 BaselineProbe 前后各打印一次

回归的判定不是「吞吐更高就通过」,而是「在 p99 预算内、错误为 0、CPU 没有恶化到不可接受」。如果 worker=4 的 p99 更好但每条请求的 CPU 明显上升,或者 9090 的 HTTP 延迟变差,仍然要回退或继续定位。BaselineProbe 的前后输出是最便宜的防呆:它能发现「以为改了 worker,其实改的是 io.netty.eventLoopThreads」这类配置错误。

常见错误与边界

错误一:只看吞吐,不看 p99。 worker=1 的 6883.3 req/s 看起来不算低,但 p99 33.73 ms 已经超过 20 ms 预算;只用吞吐做验收会把它放过去。吞吐、p99、错误数必须一起看。

错误二:把「加 worker」当成万能解。 这个案例有效,是因为 100 条连接可以分到 4 个 EventLoop。如果只有一条热连接,它仍然只属于一个 EventLoop,加 worker 不会让这条连接变快;正确方向是 3.3 的业务线程池或拆分连接。

错误三:把两次单次运行当成统计结论。 前后各跑一次就写「提升 61.7%」,既没有重复,也没有置信区间;换一次运行,p99 可能就从 12.58 ms 跳到 20 ms。至少跑三次,报中位数或最差值,并保留原始输出。

边界:合成 handler 不是生产负载。 16 轮 SHA-256 是为了稳定地制造 CPU 压力,真实业务的 CPU 分布、内存分配和锁竞争都不同。案例证明的是「定位方法和单变量对比有效」,不是「linkd-v2 的 9093 只能跑到 11.1k」。

边界:同机客户端会截断吞吐。 4 条 worker 的 11127.7 req/s 已经接近 14.2 观察到的客户端上限,所以这个数字不能当成服务端容量。生产压测要把客户端放到另一台机器,并同时观察两端 CPU 和网络。

小结

  • 案例的起点是 100 条 9093 连接、8 KiB 负载、worker=1:吞吐 6883.3 req/s,p50 12.78 ms,p99 33.73 ms,ps 采样接近一个核。
  • 用 jcmd Thread.print 抓到 EventLoop 线程停在 LinkdFrameDigestHandler.channelRead0,JFR 只有两次 1.95/1.76 ms 的 GC 停顿,排除了 GC 主因。
  • 只改 -Dlinkd.ioThreads=1 为 4,同条件单次运行得到 11127.7 req/s、p50 8.76 ms、p99 12.58 ms,错误数保持 0。
  • 这个提升不是 4 倍,也不写成百分比;客户端同机、上限约 11.1k,吞吐被客户端截断。
  • 回归看四项:p99 预算、错误数、CPU、GC;至少三次运行,参数前后用 BaselineProbe 各打印一次。

下一章把这条 9093 链路接到生产级网关:路由、转发、限流、超时和降级,最后用 15.3 的验收清单把前 14 章收口。

阅读导航:上一节:14.2 压测工具与指标口径 · 下一节:15.1 网关路由与转发 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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