引子
我们第一个自研网关,是用 Java NIO 原生 API 写的。功能跑通了,但上线一压测就露馅:P99 延迟 30ms 起步,而且每隔一阵 CPU 会突然飙到 100% 下不来。查了很久才发现两件事——一是Selector空轮询的经典 JDK bug,二是我们在 IO 线程里直接查了数据库,一个慢查询把整个事件循环堵死。
后来我们把网关用 Netty 重写了一遍,P99 直接掉到 3ms,CPU 100% 的问题也没了。这篇文章就把"原生 NIO 到底坑在哪、Netty 的主从 Reactor 又补了什么"讲清楚,顺便把我们那次改造里最关键的三处改动拆给你看。
问题:原生 NIO 不是不能用,是坑太多
Java NIO 的核心就三件套:Selector(多路复用器)、Channel(通道)、ByteBuffer(缓冲区)。一个线程通过Selector监听成百上千个连接,哪个有事件就处理哪个,这就是"多路复用"。写个最小服务端骨架感受一下:
// 原生 NIO 服务端最小骨架 Selector selector = Selector.open(); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.bind(new InetSocketAddress(8080)); ssc.configureBlocking(false); // 必须非阻塞 ssc.register(selector, SelectionKey.OP_ACCEPT); // 关注"有新连接"事件 while (true) { selector.select(); // 阻塞,等任意通道有事件 Iterator<SelectionKey> it = selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key = it.next(); if (key.isAcceptable()) { // 有新连接进来 SocketChannel sc = ssc.accept(); sc.configureBlocking(false); sc.register(selector, SelectionKey.OP_READ); // 转去关注"可读" } else if (key.isReadable()) { // 连接上有数据 ByteBuffer buf = ByteBuffer.allocate(1024); ((SocketChannel) key.channel()).read(buf); // ... 自己解码、自己处理业务(如果这里阻塞,整个 selector 都卡住) } it.remove(); // 必须 remove,否则会重复处理 } }逐行解释:
- 第 4 行
configureBlocking(false)是 NIO 的命门:Channel 必须设成非阻塞,否则select()和accept()会退化成阻塞调用,多路复用就废了。 - 第 5 行
register(selector, OP_ACCEPT)把"服务端通道"注册到 Selector,只关心 accept 事件。 - 第 12 行
selector.select()是事件循环的核心,没事件就阻塞,有事件就返回就绪的 key 集合。 - 第 21 行
it.remove()很容易被漏写——不 remove 的话,下一次循环这个 key 还在selectedKeys里,会被重复处理,轻则重复读,重则逻辑错乱。 - 第 19 行"自己解码、自己处理业务"是埋雷点:所有连接的读写和业务都在同一个 while 循环、同一个线程里串行处理。只要有一处业务阻塞,后面所有连接都得等。
原生 NIO 真正劝退的有三件事:Selector 空轮询 bug(某些 JDK/OS 组合下select()不阻塞直接返回 0,于是while(true)空转,CPU 100%)、半包/粘包要自己处理(read一次不一定收全一个完整报文)、线程模型要自己搭(上面这个单线程模型在生产根本不够用,但你一上多线程,并发安全问题又来了)。
原理:Netty 的主从 Reactor 模型
Netty 把"事件分发"这件事抽象成了 Reactor 模型,而且用的是主从多线程 Reactor:
- Boss Group(主 Reactor):只负责"接受新连接"(
OP_ACCEPT),一般 1 个线程就够,因为建连频率远低于读写频率。 - Worker Group(从 Reactor):负责已建立连接的"读写"(
OP_READ/OP_WRITE),每个连接被固定绑定到一个 Worker 线程(EventLoop),这个线程用唯一一个 Selector 管理成百上千个连接。 - 关键点:一个
EventLoop(Worker 线程)串行处理它名下所有 Channel 的事件。串行意味着你不需要在 Handler 里加锁——同一个 Channel 的事件绝不会并发执行。这就是 Netty "无锁化"设计的精髓。
对比原生 NIO 那坨手写代码,Netty 的服务端长这样:
EventLoopGroup boss = new NioEventLoopGroup(1); // 主 Reactor:只 accept EventLoopGroup worker = new NioEventLoopGroup(); // 从 Reactor:IO 读写,默认 = CPU 核数 * 2 try { ServerBootstrap b = new ServerBootstrap(); b.group(boss, worker) // ① 绑定两组 Reactor .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline() .addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4)) // ② 解决半包/粘包 .addLast(new StringDecoder()) .addLast(new BizHandler()); // ③ 业务 Handler } }); ChannelFuture f = b.bind(8080).sync(); f.channel().closeFuture().sync(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); }逐行解释:
- 第 1 行
new NioEventLoopGroup(1)的1显式指定 Boss 只用 1 个线程,因为 accept 不是瓶颈。不写参数时,Worker 默认线程数 =CPU 核数 * 2,这个数基本够用。 - 第 6 行
.group(boss, worker)就是"主从 Reactor"的接线:Boss 接连接,接完交给 Worker 做后续 IO。 - 第 11 行
LengthFieldBasedFrameDecoder(1024, 0, 4)是神器:它按"报文头里 4 字节长度字段"来切包,半包、粘包一次性解决,你再也不用手写ByteBuffer拼接逻辑。这是原生 NIO 要自己啃的硬骨头。 - 第 13 行
addLast(new BizHandler())把业务挂到 Pipeline 上。Pipeline 是责任链,每个 Handler 只管一件事(解码、业务、编码),可复用、可插拔。
改造的关键:IO 线程绝不能阻塞
我们的 P99 从 30ms 掉到 3ms,最大的一刀砍在"把阻塞操作赶出 IO 线程"。看改造后的业务 Handler:
public class BizHandler extends ChannelInboundHandlerAdapter { // 独立的业务线程池,和 IO 线程彻底隔离 private static final ExecutorService BIZ = Executors.newFixedThreadPool(16); @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { String req = (String) msg; BIZ.submit(() -> { // ① 耗时/阻塞操作丢到业务池 String resp = doDbQuery(req); // 这里查 DB,可能慢 200ms ctx.writeAndFlush(resp); // ② 写回仍由 IO 线程执行,线程安全 }); } }逐行解释:
- 第 4 行
BIZ是单独的业务线程池。Netty 的 Worker(IO)线程宝贵,一个连接卡住就会拖慢它名下的上千个连接。 - 第 9 行
BIZ.submit(...)是关键动作:把doDbQuery这种可能阻塞 200ms 的活,从 IO 线程挪到业务线程执行。IO 线程只负责收、发,永远不阻塞。 - 第 11 行
ctx.writeAndFlush(resp)在业务线程里调用没问题——Netty 内部会把写操作丢回对应的 EventLoop 串行执行,所以写是线程安全的,不用担心并发写同一个 Channel。
这一处改动,让我们之前"一个慢查询把整个网关拖垮"的问题彻底消失:DB 慢只影响那一个业务线程,IO 线程照样高速收发。
我们那次"CPU 100%"怎么没的
前面说的原生 NIO 空轮询 bug,根因是某些环境下Selector.select()返回 0 却不阻塞,于是一直空转。Netty 的解法是给 select 计数:如果短时间内连续多次select()返回 0(空转),就判定 Selector 可能"假死",于是新建一个 Selector,把旧 Selector 上的所有 Channel 重新注册到新的上面,丢弃旧 Selector。这段"重建 Selector"的修复逻辑写在NioEventLoop里,业务层完全无感。我们迁移到 Netty 之后,那个偶发的 CPU 100% 再也没出现过——不是我们修好了,是 Netty 帮我们兜了底。
我的取舍判断
- 生产别用原生 NIO 写网关/IM/长连接服务:原生 NIO 适合用来"学懂原理",但空轮询、半包、线程模型这三座大山,每个都得自己趟一遍坑。Netty 把这些坑都填平了,还白送你 Pipeline、编解码器、内存池(
ByteBuf池化减少 GC)。 - IO 线程和 Worker 线程严格隔离:这是 Netty 性能的生命线。任何
DB/Redis/RPC/ sleep都别出现在 Handler 的channelRead主路径上,一律丢到独立业务线程池。我们 30ms→3ms 几乎全靠这一条。 - Boss 一般 1 个就够,Worker 默认 2×核数:别给 Boss 配太多线程(它只 accept,多了反而白白上下文切换);Worker 也不是越多越好,超过
2×核数通常没收益,连接数极大时再按压测结果微调。 - 半包/粘包直接用
LengthFieldBasedFrameDecoder或LineBasedFrameDecoder,别手写拆包,手写必出 byte 错位。
总结
原生 NIO 教会我们"多路复用"的思想,但它把 Selector 空轮询、半包处理、线程模型这些脏活都甩给了使用者。Netty 用主从 Reactor 把连接接入(Boss)和 IO 读写(Worker)分开,用 EventLoop 串行化保证无锁,用 Pipeline + 现成解码器填平半包坑,又用"IO 线程不阻塞"的纪律把吞吐量拉满。我们那次 P99 从 30ms 到 3ms,不是 Netty 有什么魔法,而是它逼我们把"IO 该干什么、业务该干什么"分清楚了。
思考题
你现在的网络层(如果用了 Netty),业务 Handler 里有没有直接查 DB / 调 RPC /Thread.sleep的?把这些阻塞调用挪到独立线程池后,再压一次看 P99 和吞吐的变化。如果你还在用原生 NIO,不妨先确认下Selector.select()有没有做空轮询计数重建——这是线上 CPU 100% 的高发区。