本节目标:把 linkd-v2 的线程、连接、缓冲区和读开关固定成一张可打印、可复现的基线表。
适用版本:JDK 25(Temurin 25.0.5+8)+ Netty 4.2.19.Final。
承接的问题
13.3 能回答「哪条线程卡住了、哪条连接在排队」,但回答不了「这次改动到底动了哪个参数」。linkd-v2 的参数目前分散在三个地方:LinkdServer.start() 里写死了 SO_BACKLOG=1024 与 TCP_NODELAY=true,LinkdChannelOptions 里定义了 32 KiB / 128 KiB 的水位,LinkdV2Main 用 -Dlinkd.ioThreads 控制 worker 数量。它们没有一个统一的「起点记录」。
没有起点记录时,一次 p99 变化会被归因到最近改动的代码,而不是真正变化的参数。例如把 worker 从默认值改成 4,吞吐下降,可能只是机器从 10 核换成了 4 核;把水位从 32/64 改成 32/128,p99 变好,也可能只是这一次客户端预热更充分。调优的第一步不是改参数,而是把「当前值、验证方法、测量条件」写下来。
本节给 linkd-v2 的基线表,并给出一个 BaselineProbe,让每条值都能在运行时打印出来。基线是可复现的起点,不是标准答案,更不是「最优值」。它的用途是让下一次比较时,除了待验证的那一个变量,其他条件尽量一致。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面的配置输出来自这台机器,单次运行,未做统计。
基线表:七个参数与五种验证方式
| 参数 | 作用 | linkd-v2 起点值 | 怎么验证 | 什么时候该改 |
|---|---|---|---|---|
boss 线程数 | 只做 accept,把新连接交给 worker | 1 | 打印线程名或 EventLoopGroup 迭代器 | 同一进程绑定大量端口且 accept 速率成为瓶颈时 |
worker 线程数 | 已建立连接的读写、定时任务、handler | 显式 4,启动参数 -Dlinkd.ioThreads=4 | 迭代 EventLoopGroup 数线程,或打印配置值 | handler 有可测量的排队、且 CPU 未打满时 |
SO_BACKLOG | 内核 accept 队列长度提示 | 1024 | 监听 channel 的 config().getOptions() | 突发连接出现 reset/超时,且确认是 accept 队列溢出时 |
TCP_NODELAY | 关闭 Nagle,小帧不攒包 | true | 子 channel 的 getOptions() 或 SocketChannelConfig.isTcpNoDelay() | 包数/带宽压力明显大于延迟收益时 |
WRITE_BUFFER_WATER_MARK | 写队列高低水位,驱动 isWritable() | 32 KiB / 128 KiB | 子 channel 的 getWriteBufferLowWaterMark() 与 high | 可写事件频繁翻转,或单连接待写预算需要调整时 |
ALLOCATOR | ByteBuf 分配策略 | PooledByteBufAllocator.DEFAULT | config().getAllocator().getClass().getSimpleName() | 分配画像显示池化不匹配,或排查内存归属时 |
AUTO_READ | 是否自动发起下一轮读 | true | config().isAutoRead() | 需要读背压,且 handler 已能正确手动 read() 时 |
这张表最容易误读的是最后一列。「什么时候该改」写的是信号,不是推荐值。看到 worker 排队,不代表应该把 4 改成 20;先确认排队发生在 EventLoop 的 IO 事件上,还是业务线程池上,再决定改哪个数。看到水位翻转频繁,也不代表应该把 high 无限调大;先看单连接待写字节的分布和一次 flush 能排空多少。
SO_BACKLOG 也不是「最大连接数」。它是内核 accept 队列的长度提示,实际生效值还受系统上限约束;把它调到 100000 不会让 worker 多出处理能力。TCP_NODELAY 只改变延迟与包数的取舍,不承诺更快;AUTO_READ=false 只负责「要不要继续从内核读」,不负责业务队列上限。这些边界在 2.3 和 9.3 已经展开,这里只把它们收进同一张表。
线程数:默认 20,显式 4
new NioEventLoopGroup() 不传参数时,线程数是 availableProcessors() * 2,不是核数。本机 10 核,默认就是 20。下面这段探针只做一件事:创建组、数执行器、关闭:
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
public final class DefaultThreadsProbe {
public static void main(String[] args) throws Exception {
EventLoopGroup group = new NioEventLoopGroup();
int count = 0;
for (var it = group.iterator(); it.hasNext(); ) {
it.next();
count++;
}
System.out.println("defaultWorkerThreads=" + count);
group.shutdownGracefully().sync();
}
}
本机输出:
defaultWorkerThreads=20
linkd-v2 不直接用这个默认值,而是从启动参数取一个显式值:
int ioThreads = Integer.getInteger("linkd.ioThreads", 4);
boss = new NioEventLoopGroup(1);
worker = new NioEventLoopGroup(ioThreads);
Integer.getInteger("linkd.ioThreads", 4) 的默认参数是 4,所以不传 -Dlinkd.ioThreads 时也是 4;传 -Dlinkd.ioThreads=8 时是 8。这个参数和 Netty 自己的 -Dio.netty.eventLoopThreads 不是一回事:后者影响 new NioEventLoopGroup() 的默认值,必须在第一次创建组之前设置;前者是 linkd-v2 自己的应用参数,读一次就固定。两个参数同时存在时,显式传给构造函数的 ioThreads 优先。
为什么基线选 4 而不是 20?因为基线要跨机器可复现。默认值随核数变化,同一份压测脚本在 10 核和 4 核机器上会得到不同的线程数,前后对比就不干净。显式 4 是一个固定起点,便于先回答「4 条线程能跑到哪里」,再决定是否往上加。它不是「10 核机器的最优线程数」;3.1 已经说明,4 到 8 条线程通常能压满一个 10 核节点,但具体要看 handler 里有没有阻塞、有没有 CPU 密集型工作。
反过来说,显式 4 也可能不够。如果 handler 里有无法避免的阻塞,4 条线程会被阻塞调用占满,其他连接只能排队;这种情况下应该先把阻塞移出 EventLoop(3.3),而不是盲目加线程。线程数只是基线的第一个维度,不能替代对 handler 行为的分析。
缓冲区与水位:默认值与 linkd-v2 的偏离
Netty 4.2 的 WriteBufferWaterMark.DEFAULT 是 32 KiB / 64 KiB;9.1 在本机打印过未设置水位的服务端子连接:low=32768 high=65536。linkd-v2 在 LinkdChannelOptions 里改成 32 KiB / 128 KiB:
public static final WriteBufferWaterMark WATER_MARK =
new WriteBufferWaterMark(32 * 1024, 128 * 1024);
high 从 64 KiB 提到 128 KiB,意思是「单连接允许排队的待写字节更多,isWritable() 更晚翻成 false」。这不是因为 128 KiB 更先进,而是 linkd-v2 的单帧上限是 1 MiB、广播通道平均帧在 4 KiB 左右,64 KiB 大约只够 16 帧的余量;一次 flush 还没排空就再次越线,会让 channelWritabilityChanged 频繁触发。128 KiB 给了一次 flush 更大的回旋空间。9.1 的 9093 广播通道进一步用 64 KiB / 256 KiB,那是按广播场景单独记录的偏离,不是全局基线。
ALLOCATOR 也要打印,不能背结论。5.3 的本机输出显示,Netty 4.2 的 ByteBufAllocator.DEFAULT 实际是 AdaptiveByteBufAllocator,不是裸的 PooledByteBufAllocator。linkd-v2 在 childOption 里显式写 PooledByteBufAllocator.DEFAULT,目的是让升级 Netty 时分配行为可预期。打印出来的 allocator=PooledByteBufAllocator 才说明显式设置生效;如果看到 AdaptiveByteBufAllocator,说明这一行没挂到子 channel 上,或者被后面的配置覆盖了。
AUTO_READ 的基线是 true。它表示连接激活后 Netty 自己维持读循环,读到没有数据或达到本轮上限后触发 channelReadComplete,然后继续。9.3 做读背压时把它改成 false,代价是必须在 channelActive 和 channelReadComplete 里手动 ctx.read();漏掉任何一处,连接能建立、能写响应,但读不到请求,而且没有异常。基线保持 true,是为了让默认路径的行为可预测;只有确认业务队列会成为瓶颈时,才按 9.3 的方式切换。
在 linkd-v2 里落地:BaselineProbe
下面的探针把上面七个值装配成一个真实的 ServerBootstrap,绑定 19090(避免占用 linkd-v2 的 9090/9093),再用一个 JDK Socket 触发子 channel 初始化,把服务端和子 channel 的配置都打印出来:
package demo;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.channel.Channel;
import io.netty.channel.ChannelConfig;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.WriteBufferWaterMark;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public final class BaselineProbe {
private static final int BACKLOG = 1024;
private static final WriteBufferWaterMark WATER_MARK =
new WriteBufferWaterMark(32 * 1024, 128 * 1024);
public static void main(String[] args) throws Exception {
EventLoopGroup defaultGroup = new NioEventLoopGroup();
int defaultThreads = 0;
for (var it = defaultGroup.iterator(); it.hasNext(); ) {
it.next();
defaultThreads++;
}
System.out.println("defaultWorkerThreads=" + defaultThreads);
defaultGroup.shutdownGracefully().sync();
int configuredThreads = Integer.getInteger("linkd.ioThreads", 4);
System.out.println("linkdIoThreads=" + configuredThreads);
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup(configuredThreads);
CountDownLatch childReady = new CountDownLatch(1);
try {
ServerBootstrap bootstrap = new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, BACKLOG)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.AUTO_READ, true)
.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, WATER_MARK)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelConfig c = ch.config();
System.out.println("child tcpNoDelay="
+ c.getOptions().get(ChannelOption.TCP_NODELAY)
+ " autoRead=" + c.isAutoRead()
+ " allocator=" + c.getAllocator().getClass().getSimpleName()
+ " low=" + c.getWriteBufferLowWaterMark()
+ " high=" + c.getWriteBufferHighWaterMark());
childReady.countDown();
}
});
Channel server = bootstrap.bind(19090).sync().channel();
System.out.println("server backlog="
+ server.config().getOptions().get(ChannelOption.SO_BACKLOG)
+ " port=" + ((InetSocketAddress) server.localAddress()).getPort());
try (Socket socket = new Socket("127.0.0.1", 19090)) {
if (!childReady.await(3, TimeUnit.SECONDS)) {
System.out.println("child not ready");
}
}
server.close().sync();
} finally {
boss.shutdownGracefully().sync();
worker.shutdownGracefully().sync();
}
}
}
本机输出:
defaultWorkerThreads=20
linkdIoThreads=4
server backlog=1024 port=19090
child tcpNoDelay=true autoRead=true allocator=PooledByteBufAllocator low=32768 high=131072
四行分别对应四组配置:
defaultWorkerThreads=20是 Netty 的默认值,证明「不传参数」在本机是 20,不是 4。linkdIoThreads=4是应用参数,说明 linkd-v2 的显式起点是 4。server backlog=1024来自监听 channel 的getOptions(),证明SO_BACKLOG挂在父 channel 上。- 最后一行来自 accept 出来的子 channel,证明
TCP_NODELAY、AUTO_READ、分配器和 32/128 KiB 水位都挂到了子 channel,没有误挂到监听 socket。
运行命令:
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 src/main/java/demo/BaselineProbe.java
java -cp "out:$CP" demo.BaselineProbe
getOptions() 只返回当前 transport 支持的选项。某个键打印成 null,可能是这个选项不属于当前 channel 类型,也可能是设置位置不对;不要用「代码里写了」代替运行时打印。getWriteBufferLowWaterMark() 和 high 是 ChannelConfig 的直接读取,不受 getOptions() 过滤影响,适合用来核对水位。
基线记录卡:让下一次比较有据可查
只打印一次配置还不够,压测结果必须和当时的配置绑在一起。建议每次基线运行写一张记录卡,字段固定,值从 BaselineProbe 和启动脚本里抄,不要凭记忆填:
| 字段 | 本机示例 | 为什么必须记 |
|---|---|---|
| 时间与提交 | 2026-10-28 / git rev-parse --short HEAD | 代码和配置会一起变,只记日期不够 |
| 机器与系统 | Apple M1 Pro,10 核,32 GB,macOS 26.3 | 线程默认值、调度和回环性能都依赖机器 |
| JDK 与 Netty | Temurin 25.0.5+8 / 4.2.19.Final | 默认水位、分配器实现随版本变化 |
| 启动命令 | java -Dlinkd.ioThreads=4 ... | 同一个 jar 换一个 -D 就是另一组参数 |
| 基线值 | backlog 1024、32/128 KiB、pooled、AUTO_READ=true | 与 BaselineProbe 输出逐项对照 |
| 压测命令 | ab -k -n 5000 -c 50 ... 或 14.2 的客户端命令 | 工具、并发、时长、预热方式都会改变结果 |
| 原始输出 | logs/ab-keep-1.txt 或 logs/frame-1.txt | 只留汇总数字,事后无法复查分位和错误数 |
记录卡不是流程负担,它解决的是「上一次为什么是 8861,这一次为什么是 7000」这类无法回答的问题。没有记录卡的调优,最后只能靠记忆争论;有记录卡时,至少能先排除「机器换了」「参数没生效」「客户端换了」这三种最常见的原因。
常见错误与边界
错误一:把 TCP_NODELAY 设在 ServerBootstrap.option 上。 监听 socket 没有 Nagle 行为,这个选项只对子 channel 有意义。写错时不会报错,child 仍然是默认值:
// 反例:TCP_NODELAY 挂在父 channel,对 accepted 连接无效
new ServerBootstrap()
.option(ChannelOption.TCP_NODELAY, true)
.childHandler(initializer);
正确位置是 childOption。同理,WRITE_BUFFER_WATER_MARK 和 ALLOCATOR 也要用 childOption;写在 option 上只改监听 socket,业务连接看不到。
错误二:把 WRITE_BUFFER_HIGH_WATER_MARK 和 WRITE_BUFFER_WATER_MARK 混着用。 前者是旧式单项设置,后者用 WriteBufferWaterMark(low, high) 一次给两个值。两处都写时,后设置的覆盖先设置的,打印出来只有一个结果;排障时会误以为「代码里明明写了 32/64」。统一用 WRITE_BUFFER_WATER_MARK,并在子 channel 上打印 low/high 核对。
错误三:设置 AUTO_READ=false 后忘记手动 read()。 连接能建立、能写响应,但一个请求都读不到,没有异常。基线保持 true;要切到 false,必须同时实现 channelActive 的首次 ctx.read() 和 channelReadComplete 的续读,并按 9.3 的方式在业务队列满时暂停。
边界:基线不是容量结论。 这七个值只适用于同一台机器、同一个 JDK、同一个 Netty 版本下的比较。换机器、换客户端、换协议,数字都不能直接比。回环地址上的吞吐和延迟只适合做回归基线,不能当成生产容量;生产环境还要把客户端放到另一台机器,并考虑真实网络 RTT、丢包和内核参数。
小结
- 基线是「版本 + 参数 + 验证命令 + 测量条件」的组合,不是一组推荐值,更不是最优值。
- linkd-v2 的起点是 boss=1、worker 显式 4、backlog=1024、TCP_NODELAY=true、水位 32/128 KiB、显式池化分配器、AUTO_READ=true。
- Netty 默认 worker 是
2 × 核数,本机 10 核为 20;显式 4 是为了跨机器可复现,不是因为 4 最优。 - 每个参数都要在运行时打印:父 channel 看
SO_BACKLOG,子 channel 看TCP_NODELAY、AUTO_READ、allocator 和 low/high 水位。 - 基线只用于同机同版本的回归比较;换机器或换客户端后,先重新打印、重新测量,再谈调优。
阅读导航:上一节:13.3 线程栈、JFR 与连接诊断 · 下一节:14.2 压测工具与指标口径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。