Netty 的主从 Reactor 到底比 NIO 原生强在哪:一次把 P99 从 30ms 压到 3ms 的改造 引子我们第一个自研网关是用 Java NIO 原生 API 写的。功能跑通了但上线一压测就露馅P99 延迟 30ms 起步而且每隔一阵 CPU 会突然飙到 100% 下不来。查了很久才发现两件事——一是Selector空轮询的经典 JDK bug二是我们在 IO 线程里直接查了数据库一个慢查询把整个事件循环堵死。后来我们把网关用 Netty 重写了一遍P99 直接掉到 3msCPU 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(); // 阻塞等任意通道有事件 IteratorSelectionKey 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 模型而且用的是主从多线程 ReactorBoss Group主 Reactor只负责接受新连接OP_ACCEPT一般 1 个线程就够因为建连频率远低于读写频率。Worker Group从 Reactor负责已建立连接的读写OP_READ/OP_WRITE每个连接被固定绑定到一个 Worker 线程EventLoop这个线程用唯一一个 Selector 管理成百上千个连接。关键点一个EventLoopWorker 线程串行处理它名下所有 Channel 的事件。串行意味着你不需要在 Handler 里加锁——同一个 Channel 的事件绝不会并发执行。这就是 Netty 无锁化设计的精髓。对比原生 NIO 那坨手写代码Netty 的服务端长这样EventLoopGroup boss new NioEventLoopGroup(1); // 主 Reactor只 accept EventLoopGroup worker new NioEventLoopGroup(); // 从 ReactorIO 读写默认 CPU 核数 * 2 try { ServerBootstrap b new ServerBootstrap(); b.group(boss, worker) // ① 绑定两组 Reactor .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializerSocketChannel() { 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 线程。看改造后的业务 Handlerpublic 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 的 WorkerIO线程宝贵一个连接卡住就会拖慢它名下的上千个连接。第 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% 的高发区。