《Netty 高性能网络编程》14.1 参数基线:水位、缓冲区、线程数

调优前先固定一组能复现的起点:boss=1、worker 显式 4、backlog 1024、TCP_NODELAY、32/128 KiB 水位、PooledByteBufAllocator 与 AUTO_READ=true。本节用 BaselineProbe 打印 linkd-v2 的真实配置,说明每个值的作用、验证方式和该改的信号,强调基线不是最优值。

本节目标:把 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,把新连接交给 worker1打印线程名或 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可写事件频繁翻转,或单连接待写预算需要调整时
ALLOCATORByteBuf 分配策略PooledByteBufAllocator.DEFAULTconfig().getAllocator().getClass().getSimpleName()分配画像显示池化不匹配,或排查内存归属时
AUTO_READ是否自动发起下一轮读trueconfig().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 与 NettyTemurin 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 压测工具与指标口径 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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