本节目标:用一个能复现的 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,也不是客户端
在改任何参数之前,先写出可被推翻的假设:
- GC 停顿:如果 p99 的 33 ms 里有一次 20 ms 以上的 Full GC,调 worker 数没有意义。
- 客户端瓶颈:如果客户端已经到顶,服务端加线程也不会提高吞吐。
- 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=1 | worker=4 | 读法 |
|---|---|---|---|
| 吞吐(req/s) | 6883.3 | 11127.7 | 客户端上限约 11.1k,改后已接近上限 |
| p50(ms) | 12.78 | 8.76 | 中位数下降,排队减少 |
| p95(ms) | 25.38 | 9.94 | 尾部开始收敛 |
| p99(ms) | 33.73 | 12.58 | 从超过 20 ms 预算到低于预算 |
| mean(ms) | 14.29 | 8.93 | 与 Little 定律一起看 |
| errors | 0 | 0 | 两次都没有传输错误 |
| littleL | 98.3 | 99.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、服务端异常日志 |
| 6 | p99 与预算 | 20 ms 预算下,四次运行是否都通过 |
| 7 | 进程与 EventLoop CPU | ps -p <pid> -o %cpu,必要时抓线程栈 |
| 8 | GC 停顿 | 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 网关路由与转发 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。