主题
核心架构与线程模型
一句话: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,串行”的放大效应 |
| 是否 GC | GC 日志与慢的时间点是否重合 | 排除后再怀疑线程模型 |
根因常是一个看起来无害的写法: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=8192initChannel 只做本地、纯内存的 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
# 期望输出 helloWindows 没有 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 上,靠进程重启或多实例兜底。