跳到正文

核心架构与线程模型 ​

一句话:Netty 把每个 Channel 的所有事件绑定到一个 EventLoop 线程上串行执行,用「不共享」换「不加锁」;Boss 只负责 accept,Worker 负责读写。

适用版本:Netty 4.1.x。线程数、水位等默认值以 4.1 源码/官方文档为准。

为什么需要 ​

原生 NIO 能用一个 Selector 管很多连接,但要用它写一个稳定的服务,还得自己处理:

  • 半包/粘包、断线重连、写缓冲背压。
  • 多线程下 SelectionKey 的并发修改。
  • 一个连接的状态被多个线程同时读写时的加锁。
  • 异常发生时如何不拖垮整个 Selector 循环。

Netty 的价值不是「比 NIO 快」,而是把这些通用问题封装成可组合的组件,让业务代码只写 Handler。

核心机制 ​

Reactor 主从模型 ​

一条连接从 accept 到读写的归属关系是:Boss 只负责建立连接并把它注册给某个 Worker,之后的全部 IO 都固定在那一个 EventLoop 线程上。

  • Boss:只处理 OP_ACCEPT。它阻塞会直接导致新连接排队。
  • Worker:处理已建立连接的 OP_READ/OP_WRITE。
  • EventLoop:一个线程 + 一个 Selector + 一个任务队列;循环执行 select → 处理就绪事件 → 跑任务队列。

为什么一个 Channel 绑一个 EventLoop ​

Channel 注册到 EventLoop 后终身绑定。该 Channel 的读、写、Pipeline 回调、用户提交的任务,都在同一个线程顺序执行。因此:

  • Handler 里访问「跟这个 Channel 绑定」的状态不需要加锁。
  • 代价是单个 Channel 的吞吐受限于一个线程;不能让这个线程做耗时操作。

这不是「用锁保证线程安全」,而是「把并发变成串行」。

Boss 挂了会怎样 ​

监听端口对应的 ServerChannel 只注册在 parentGroup 的一个 EventLoop 上。需要区分两件事:

  • 单个 Channel 的异常:Netty 在事件处理外有异常捕获,不会让 EventLoop 退出。
  • EventLoop 线程本身退出(OOM、被 kill、未捕获的严重错误):该 Selector 失效,监听端口不再 accept;已经建立的连接仍在 Worker 上继续工作。Netty 不会自动重建这个 EventLoop。

所以「多配几个 Boss 线程就有备份」是错的:parentGroup 的多个线程主要在你绑定多个端口/多个 ServerChannel 时才各自有用;单个监听端口不会因为有 2 个 boss 线程就获得故障切换。生产上靠进程级健康检查、重启、或多实例部署来兜底。

线程数与任务队列 ​

  • Boss:通常 1 个线程(每个监听端口一个 ServerChannel);绑定多个端口时按端口数配。
  • Worker:NioEventLoopGroup 默认 CPU 核数 × 2。
  • 任务队列:EventLoop 除了 IO 事件,还执行 channel.eventLoop().execute(...) 提交的任务。队列积压说明有任务在 IO 线程上耗时。

阻塞的代价 ​

一个 Handler 里做 500ms 的同步 HTTP 调用,会让该 EventLoop 上所有 Channel 一起等 500ms。表现是「P99 飙升但 CPU 不高」,因为线程在等 IO,不在算。

正确做法:耗时逻辑丢到业务线程池,处理完再切回 ctx.executor() 写回。

实战场景 ​

一、P99 飙到 900ms,CPU 只有 15%:IO 线程被人占了 ​

现象:长连接网关高峰时段 P99 从 40ms 涨到 900ms,CPU 监控平稳在 15% 附近;受影响的不只是发起慢调用的那批连接,同一个 EventLoop 上的其他连接一起慢。重启后立刻恢复,几十分钟后又变慢。

排查:“P99 高但 CPU 不高”指向线程在等,不在算。定位顺序是从队列到栈。

定位层取证据判定
任务队列((SingleThreadEventExecutor) group).pendingTasks() 逐个 EventLoop 采样持续升高 → 有任务在 IO 线程上耗时
线程状态jstack 看 nioEventLoopGroup-2-* 线程栈RUNNABLE 卡在 socketRead0 / HttpURLConnection → 同步 IO
IO 线程里的业务栈里是否出现 DB 驱动、RPC、Cipher.doFinal、文件读写出现即违规
影响面慢连接的 channel.eventLoop() 是否同一个同一个 → 符合“一 Channel 一 EventLoop,串行”的放大效应
是否 GCGC 日志与慢的时间点是否重合排除后再怀疑线程模型

根因常是一个看起来无害的写法:Handler 里同步读了一次配置中心。500ms 的同步调用会让该 EventLoop 上所有 Channel 一起等 500ms,这就解释了“为什么别人也慢”和“重启后暂时好”。

对症做法:

java
// 1. 耗时逻辑离开 IO 线程,处理完切回该 Channel 的 EventLoop 再写回
@Override public void channelRead(ChannelHandlerContext ctx, Object msg) {
    businessPool.submit(() -> {
        Object result = handle(msg);                 // DB / RPC / 加解密在这里
        ctx.executor().execute(() -> ctx.writeAndFlush(result));   // 写回仍走 EventLoop,无需加锁
    });
}

// 2. 业务线程池要有界并暴露拒绝,不能让任务无声排队
new ThreadPoolExecutor(32, 64, 60, SECONDS,
        new ArrayBlockingQueue<>(2000),
        new NamedThreadFactory("biz"),
        new AbortPolicy());        // 拒绝时返回错误,比堆积到 OOM 好
java
// 3. 把 IO 线程被占用做成可观测指标
Gauge.builder("netty_pending_tasks", group, g -> ((SingleThreadEventExecutor) g).pendingTasks());
// 阈值告警:连续 3 个采样点 > 1000(经验值,需校准)

判定标准要能验证:IO 线程栈里不应该出现任何网络客户端或驱动类。可以写一条测试断言——压测时采样 nioEventLoopGroup-* 线程栈,出现 java.net.Socket / JDBC 包名即失败。

复盘要点:Netty 换来的是“不加锁”,代价是“不能阻塞”。团队约定比文档有效:Handler 只做协议解析和状态转移,任何等待都提交到显式线程池,并规定回写必须用 ctx.executor()。相关写法见 ChannelPipeline 与 Handler。

二、建连超时但老连接正常:accept 侧被卡住 ​

现象:客户端偶发 TCP 建连超时(约 3 秒后重试才成功,与 SYN 重传间隔一致),已建立连接的读写完全正常,服务端没有任何异常日志。发生在大促开场 1 分钟内,瞬时新建连接数约为平时 8 倍。

排查:Boss 只负责 OP_ACCEPT,它慢或队列满,表现就是“新连接进不来、老连接不受影响”。

定位层取证据判定
内核监听队列ss -lnt 'sport = :8080' 看 Send-Q(backlog 上限)与 Recv-Q(当前积压)Recv-Q 长期贴近 Send-Q → accept 不过来
丢 SYN 计数netstat -s | grep -i "listen"(times the listen queue of a socket overflowed)计数增长即确认溢出
上限来源cat /proc/sys/net/core/somaxconn、tcp_syncookies代码写 1024,内核限 128 时以内核为准
Boss 线程jstack 看 nioEventLoopGroup-3-1 是否在 select 还是被任务占用Boss 队列里有业务任务 → 配置错误
建连后的初始化initChannel 里是否做 DNS、拉配置、加载证书每个新连接同步 100ms,accept 直接跟不上

常见组合是 SO_BACKLOG 小于内核 somaxconn,加上 ChannelInitializer 里做了远程动作——新连接的注册与初始化都在 Boss/Worker 上,一个慢初始化就能把 accept 拖死。

对症做法:

java
ServerBootstrap b = new ServerBootstrap();
b.group(new NioEventLoopGroup(1), new NioEventLoopGroup())     // Boss 保持 1,多 Boss 不备份同一端口
 .option(ChannelOption.SO_BACKLOG, 4096)                        // 与内核 somaxconn 一起改才有意义
 .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
 .childOption(ChannelOption.TCP_NODELAY, true)
 .childOption(ChannelOption.WRITE_BUFFER_WATER_MARK,
              new WriteBufferWaterMark(32 * 1024, 64 * 1024));   // 背压边界,见 03 篇
bash
# 内核侧同步调整,否则代码值被截断
sysctl -w net.core.somaxconn=4096
sysctl -w net.ipv4.tcp_max_syn_backlog=8192

initChannel 只做本地、纯内存的 Pipeline 装配;证书与配置在启动期加载一次,用 SharedEventLoop/Object 复用。

复盘要点:这条链路要有两个常驻指标:监听队列积压(Recv-Q/Send-Q)与 backlog 溢出计数。它们的价值是在客户端开始报超时之前就告警。多实例部署和进程健康检查是 Boss 故障的唯一兜底——EventLoop 线程真的退出时,Netty 不会自动重建。

三、发布时丢请求:停机顺序写反 ​

现象:每次发布约丢 20~40 个请求(经验值,需校准),客户端报 Connection reset 或读超时;回滚发布方式(直接 kill)与优雅停机方式效果相同,说明顺序而非时间问题。

排查:shutdownGracefully() 只停 EventLoop,不会替你做“先摘流量、再等在途”这两步。

定位层取证据判定
摘流量时机注册中心下线时间戳 vs 关闭监听端口时间戳(日志比对)端口先关 → 客户端还连着已下线的实例
在途请求关闭时 ChannelGroup 里活跃连接数未清零就 shutdownGracefully → 直接 reset
队列任务pendingTasks() 在关闭瞬间的值有残留 → 定时任务/写回被丢弃
客户端行为是否有重试与幂等无重试则任何抖动都是损失

对症做法:

java
ChannelGroup channels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
// initChannel 里 channels.add(ch),channelInactive 里自动移除

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    registry.offline(instance);                       // ① 先摘流量
    serverChannel.close().awaitUninterruptibly();     // ③ 停止 accept
    long deadline = System.currentTimeMillis() + 10_000;   // ④ 排空窗口
    while (!channels.isEmpty() && System.currentTimeMillis() < deadline) {
        Thread.sleep(50);                             // 有空连接时提前退出
    }
    workerGroup.shutdownGracefully(2, 5, SECONDS).awaitUninterruptibly();   // ⑤
    bossGroup.shutdownGracefully(2, 5, SECONDS).awaitUninterruptibly();
}));

K8s 里配 preStop 钩子与 terminationGracePeriodSeconds,让摘流量和排空在 SIGTERM 之前完成;readinessProbe 指向一个可控失败的端点,比依赖 hook 顺序更稳。

复盘要点:丢请求要能度量:客户端记录发布窗口的错误率,作为发布门禁(阈值例如 0.1%,经验值,需校准)。停机顺序应写进部署模板而不是文档,因为“先关端口再摘流量”这种反序写法看起来完全合理,只有指标能证伪。

最小上手 ​

pom.xml:

xml
<dependency>
  <groupId>io.netty</groupId>
  <artifactId>netty-all</artifactId>
  <version>4.1.115.Final</version>
</dependency>

最小 echo server:

java
public final class EchoServer {
    public static void main(String[] args) throws Exception {
        EventLoopGroup boss = new NioEventLoopGroup(1);
        EventLoopGroup worker = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(boss, worker)
             .channel(NioServerSocketChannel.class)
             .option(ChannelOption.SO_BACKLOG, 1024)
             .childOption(ChannelOption.TCP_NODELAY, true)
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 protected void initChannel(SocketChannel ch) {
                     ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                         @Override
                         public void channelRead(ChannelHandlerContext ctx, Object msg) {
                             ctx.writeAndFlush(msg);   // echo;写操作接管 msg 的所有权
                         }
                         @Override
                         public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
                             ctx.close();
                         }
                     });
                 }
             });
            b.bind(8080).sync().channel().closeFuture().sync();
        } finally {
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }
}

验证:

bash
# 终端 1
mvn -q compile exec:java -Dexec.mainClass=EchoServer
# 终端 2(Linux/macOS)
printf 'hello\n' | nc 127.0.0.1 8080
# 期望输出 hello

Windows 没有 nc 时用 telnet 127.0.0.1 8080,输入一行回车,能看到回显。

生产动作 ​

  • 不要在 IO 线程做业务:DB、RPC、加解密、文件 IO 都丢业务线程池;pendingTasks 持续升高是信号。
  • 线程数:Worker 默认 2×核数;不要盲目调大,线程多了上下文切换和内存都上升。
  • 连接队列:SO_BACKLOG 按 accept 峰值设;太小会拒绝连接,太大只是排队。
  • TCP 参数:低延迟小包开 TCP_NODELAY;长连接按需开 SO_KEEPALIVE,但应用层心跳仍要自己做。
  • 优雅停机:shutdownGracefully() 会先停止接收新任务再关闭;不要直接 System.exit。
  • 监听失败:bind() 失败通常是端口占用/权限;sync() 会把异常抛出来,不要吞掉。
  • Boss 故障:靠进程健康检查和重启;不要假设多 boss 线程能接管同一个监听端口。

边界 ​

  • 不是 HTTP 框架:写 HTTP 服务优先 Spring WebFlux/Undertow;Netty 适合自定义协议、长连接、网关底层。
  • 不是业务框架:没有依赖注入、事务、参数校验;这些要自己搭或选上层框架。
  • NIO 不是唯一选择:Linux 高连接数可评估 native epoll;Windows 上用 NIO。
  • 单 Channel 单线程:一个连接的所有处理串行,不能靠加线程提升单连接吞吐。
  • TLS 有成本:SslHandler 的握手和加解密在 EventLoop 上执行,量大时要评估卸载/硬件加速。

讲清楚 ​

Netty 的线程模型是「主从 Reactor + Channel 绑定 EventLoop」:Boss 只 accept,Worker 处理读写;一个 Channel 注册到某个 EventLoop 后就终身绑定,它的所有事件都在同一个线程串行执行,所以 Handler 不需要加锁,代价是单连接吞吐受限于一个线程。EventLoop 的循环是 select、处理就绪事件、跑任务队列;任务队列积压说明有人在 IO 线程上做了耗时操作。Boss 的容错要说准确:监听 Channel 只在一个 EventLoop 上,多配 boss 线程不会给同一个端口做备份;线程死了端口不再 accept,已建立连接还在 Worker 上,靠进程重启或多实例兜底。

把一门技术讲透到既懂也会用