一个 Netty 服务端,bossGroup 给 1 个线程、workerGroup 给 CPU 核数 × 2,几万连接就能稳住。这不是玄学,是 Reactor 主从模型在 IO 多路复用上的固定收益:accept 与读写分离,每个 Channel 的事件永远在同一线程排队。
下面按"模型 → 线程绑定 → 数据流 → 编解码 → 线程安全 → 内存"拆,最后给骨架与泄漏排查手法。
1. BIO / NIO / AIO 与 Reactor 三种形态
BIO 的 accept 与 read 都阻塞,一条连接占一个线程;NIO 把等待交给 selector,一条连接只是一个 Channel 对象;AIO 由内核完成回调,Netty 4 虽有 AioEventLoopGroup 但极少用。Reactor 是在 NIO 之上回答"谁等 IO、谁干活",三种形态:
| 形态 | 结构 | 瓶颈 |
|---|---|---|
| 单线程 Reactor | 一个线程做 accept + 读写 + 业务 | 业务一慢,全卡 |
| 多线程 Reactor | 一个线程 accept,一组线程读写 | accept 线程仍可能成瓶颈 |
| 主从 Reactor | 一组线程 accept,另一组线程读写 | Netty 的默认形态 |
Netty 用 EventLoopGroup 表达"一组 Reactor 线程",ServerBootstrap.group(boss, worker) 就是主从形态的直接映射。
2. EventLoopGroup:boss 只 accept,worker 只读写
完整的主从配置在第 7 节的骨架里:boss 给 1 个线程、worker 给默认的 2 × 核数,b.group(boss, worker) 这一行就是主从形态。
| 角色 | 负责的事件 | 线程数建议 |
|---|---|---|
| boss | OP_ACCEPT,拿到 SocketChannel 后注册到 worker |
1 |
| worker | OP_READ / OP_WRITE / 连接关闭 |
CPU 核数 × 2 起步 |
| 业务线程池 | 耗时逻辑(DB、RPC) | 单独一组,不占 EventLoop |
new NioEventLoopGroup() 默认线程数是 max(1, 可用核数 × 2):EventLoop 既跑 IO 又跑任务队列,留一倍冗余。boss 给多个线程不会更快——bind 之后只有一个 ServerSocketChannel,抢同一个 selector 的 accept 事件收益为零。
3. NioEventLoop:一个线程绑多个 Channel,天然无锁
NioEventLoopGroup 内部是一个 EventLoop 数组,next() 按轮询挑一个(EventExecutorChooser,2 的幂时用位运算)。Channel 在 register 时绑定某个 EventLoop,此后整个生命周期都不换。
三条推论:① 同一 Channel 的读、写、关闭事件在同一线程串行执行,Handler 内部读写 Channel 状态不用加锁;② 不同 Channel 可能落在同一 EventLoop 上,一个慢 Handler 会拖累同组连接;③ 因此不要在 EventLoop 里做阻塞调用(JDBC、Thread.sleep、同步 HTTP),要阻塞就扔业务线程池。
EventLoop 的循环体固定三段:select → processSelectedKeys → runAllTasks。第三段正是 eventLoop().execute(...) 能生效的原因。
4. ChannelPipeline:入站正着走,出站倒着走
pipeline 是一条双向链表,每个节点是 ChannelHandlerContext,包着一个 Handler。
| 方向 | 触发源 | 传播顺序 | 典型 Handler |
|---|---|---|---|
| 入站(inbound) | 对端发来数据、连接建立/断开 | head → tail,即 addLast 的添加顺序 |
解码器、业务处理器 |
| 出站(outbound) | 本地调用 write / flush / close |
tail → head,与添加顺序相反 |
编码器、超时、流量整形 |
关键区别在这一行:
ctx.write(msg); // 从当前 handler 往前(向 head)找下一个 outbound handler
ctx.channel().write(msg); // 从 tail 开始,走完整条出站链
自定义 Handler 里用 ctx.write 是常见做法,但链上位于它之前(更靠 tail)的编码器会被跳过——"数据没编码就发出去"的 bug 都出在这里。addLast 的顺序要同时满足两个方向:decoder → business → encoder。
5. 粘包与半包:ByteToMessageDecoder 的三种解法
TCP 是字节流,一次 read 可能拿到半个包,也可能三个包黏在一起。ByteToMessageDecoder 内部维护 cumulation buffer,不够一个完整消息就等下次。
| 方案 | 类 | 适用 | 关键参数 |
|---|---|---|---|
| 定长 | FixedLengthFrameDecoder |
协议帧长度固定 | 帧长 |
| 长度字段 | LengthFieldBasedFrameDecoder |
自定义二进制协议 | 偏移、长度字段宽度、修正值、剥离字节数 |
| 分隔符 | DelimiterBasedFrameDecoder / LineBasedFrameDecoder |
文本协议、JSON 一行一条 | 最大帧长、分隔符 |
长度字段方案最常用,五个参数必须一次配对:
// 协议:[4 字节长度][body],长度字段值 = body 长度
new LengthFieldBasedFrameDecoder(
1 << 20, 0, 4, 0, 4);
// maxFrameLength, lengthFieldOffset, lengthFieldLength,
// lengthAdjustment, initialBytesToStrip
协议若是 [4 字节长度(含头)][body],lengthAdjustment 写 -4;不想在业务层看到长度头,initialBytesToStrip 写 4。参数配错的表现只有两种:一直等不到完整帧,或 body 错位一个包。
文本协议更简单:new DelimiterBasedFrameDecoder(8192, Delimiters.lineDelimiter())。
ByteToMessageDecoder 有两个约束:不能标 @Sharable(持有累积缓冲与解析状态,共享必然串数据);子类 decode 往 out 里 add 的 ByteBuf 由框架释放,但你 readBytes 出来的副本要自己 release。
6. @Sharable 与线程安全:什么时候能共享
| Handler 类型 | 能否 @Sharable |
原因 |
|---|---|---|
无状态编解码器(如 StringEncoder) |
可以 | 不持有每连接状态 |
ByteToMessageDecoder 子类 |
不可以 | 持有累积缓冲 |
IdleStateHandler |
可以 | 状态在 Channel 属性里 |
标了 @Sharable 却持有可变状态,症状是"偶发串包、A 的响应发给了 B",排查时在 channelRead 里断言 Channel 等于 ctx.channel()。 |
channel.writeAndFlush() 本身线程安全,内部把任务投递到该 Channel 的 EventLoop;但组合操作(先判断再写、先计数再写)必须整体放进 eventLoop().execute(...):
channel.eventLoop().execute(() -> {
if (channel.isActive()) { // 判断与写在同一个线程,无竞态
counter++;
channel.writeAndFlush(resp);
}
});
7. 骨架:一份能跑的 ServerBootstrap
public final class NettyServer {
public static void main(String[] args) throws InterruptedException {
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(boss, worker)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new LengthFieldBasedFrameDecoder(1 << 20, 0, 4, 0, 4))
.addLast(new LengthFieldPrepender(4)) // 出站:补长度头
.addLast(new BusinessHandler());
}
});
try {
b.bind(8080).sync().channel().closeFuture().sync();
} finally {
boss.shutdownGracefully();
worker.shutdownGracefully();
}
}
}
option 作用在 accept 队列,childOption 作用在每条 SocketChannel;shutdownGracefully() 要留两秒静默期。Linux 上换 EpollEventLoopGroup / EpollServerSocketChannel 能拿到边缘触发与更低的 syscall 开销,前提是 native epoll 包的 classifier 与架构匹配,否则报 UnsatisfiedLinkError。
8. 为什么用 FastThreadLocal 与池化 ByteBuf
FastThreadLocal:ThreadLocal 用哈希表,有冲突退化与 WeakReference 的 GC 压力;FastThreadLocal 给每个变量分配稳定下标,FastThreadLocalThread 上挂 Object[],取值就是一次数组访问。EventLoop 线程正是 FastThreadLocalThread,这条优化在 IO 线程上免费。
池化 ByteBuf:PooledByteBufAllocator 是 4.1 默认分配器,按 arena + chunk + page 复用内存,避免每次读写都 allocate。池化带来引用计数,规则是"谁 retain 谁 release"。
| 泄漏信号 | 含义 | 处理 |
|---|---|---|
LEAK: ByteBuf.release() was not called before it's garbage-collected |
有 buf 没释放 | 看堆栈里的 addLast 位置,定位到 Handler |
IllegalReferenceCountException: refCnt: 0 |
释放了两次,或释放后还在用 | 检查 retain 配对 |
| 内存持续上涨但堆 dump 正常 | 泄漏在堆外(direct memory) | 用 -Dio.netty.leakDetection.level=paranoid 复现 |
排查命令与开关:
java -Dio.netty.leakDetection.level=paranoid \
-Dio.netty.leakDetection.samplingInterval=1 -jar app.jar
jcmd <pid> VM.native_memory summary # 需 -XX:NativeMemoryTracking=summary
级别 DISABLED / SIMPLE(默认,约 1% 采样)/ ADVANCED / PARANOID:前两级只报"哪里分配",PARANOID 每次分配都记录才能追到 channelRead 行号。生产先 SIMPLE。
9. 排错表
| 现象 | 原因 | 处理 |
|---|---|---|
| 业务收到半截 JSON | 没加帧解码器,直接读裸流 | 加 LineBasedFrameDecoder 或长度字段方案 |
| 偶发"响应发错人" | Handler 标了 @Sharable 却持有每连接状态 |
去掉注解,状态挪到 Channel.attr() |
| 写出去的数据没编码 | 用了 ctx.write 跳过了编码器 |
改 ctx.channel().write 或调整链顺序 |
LEAK: ByteBuf.release() 报警 |
手动 readBytes 的副本没释放 |
配对 release,或用 SimpleChannelInboundHandler |
10. 边界:什么时候不该用 Netty
| 场景 | 更合适的选择 | 理由 |
|---|---|---|
| 纯 HTTP/1.1 + JSON 的常规服务 | Spring MVC / WebFlux | Netty 的收益在长连接与二进制协议 |
| 协议极简、连接数很少 | 原生 SocketChannel |
引入框架只增加调试成本 |
一句话:Netty 换的不是"更快",是"用固定线程数扛更多连接"。
11. 自测
- boss 组给 4 个线程为什么不会提升 accept 吞吐?
- 同一个 Channel 的两个 Handler 之间共享一个计数器,需要加锁吗,为什么?
ctx.write与ctx.channel().write在出站链上分别从哪里开始传播?LengthFieldBasedFrameDecoder的lengthAdjustment在什么协议下要写负数?ByteToMessageDecoder为什么不能标@Sharable?SIMPLE与PARANOID泄漏检测级别的差别在哪,生产上该先用哪个?