七八年前我第一次用Java写网络服务时,代码特别单纯:一个ServerSocket、一个accept()循环、每个连接开一个线程去读数据。当时百来个连接跑得挺好,等连接数一上到几千,服务就开始频繁Full GC,线程栈和上下文切换把CPU吃满,真正能处理的业务却少得可怜。那段时间我把java.nio的文档翻了又翻,踩了无数坑,最后落到Netty框架上才算把高并发网络编程这件事理顺。
这篇内容我不打算写成教程式的章节罗列,而是想把从BIO到NIO、再从原生NIO到Netty这条路上真正用过才知道的东西讲清楚。包括每个关键设计的“为什么”、我实际遇到的事故、以及现在做选型时的判断依据。适合刚接触NIO和Netty的Java后端开发,也适合已经用过Netty但想回头补一补底层原理的人。
1. 从BIO到NIO:高并发场景下那根线程与那次阻塞
1.1 一个连接一个线程的模式为什么在C10K时代失效
先看一段最典型的BIO服务端代码:
ServerSocket serverSocket = new ServerSocket(8080); while (true) { Socket socket = serverSocket.accept(); // 阻塞:等一个新连接 new Thread(() -> { try (InputStream in = socket.getInputStream()) { byte[] buf = new byte[1024]; int n; while ((n = in.read(buf)) != -1) { // 阻塞:等这个连接的数据 // 处理这一小段数据 } } }).start(); }这段代码的问题非常直观:一个连接就必须有一个线程去伺候。这个线程大部分时间并不在“干活”,而是阻塞在read()上等数据到达。等数据到达的这段时间里,线程栈、线程内核对象、上下文切换开销全部要提前预支。默认线程栈1MB左右,一万个线程光栈空间就接近10GB,还没算上切换成本。更要命的是CPU可能并不忙,但内存和调度器已经先被拖垮了。
这就是网络编程老生常谈的C10K问题:并发一万连接时,阻塞模型基本走到头了。解决问题的核心思路不是把线程栈调小,而是把“等数据”这件事从线程资源里剥离出去。JDK 1.4引入NIO,正是奔着这个目标去的。
1.2 多路复用、Selector与“一人管千事”的思路
NIO核心有三件套:Channel、Buffer、Selector。Channel相当于传统IO的管道,但支持双向读写,而且SocketChannel可以配置成非阻塞模式。Buffer是数据容器,所有读写都通过它搬运。Selector才是化腐朽为神奇的东西:它可以同时监视多个Channel,等其中任何一个Channel有“感兴趣的事件”发生时,select()才会返回。
我习惯把一个Selector比作银行大厅的大堂经理。BIO模式相当于给每一位顾客配一个专属柜员,顾客坐在沙发上发呆,柜员也只能陪着等;NIO模式里只有一个大堂经理,顾客被叫号(事件触发)才去窗口办业务。大堂经理不断循环“看哪些人叫号了、谁的业务可以推进”,一个人就能管理几千号顾客。
对应到代码里,服务端把ServerSocketChannel注册到Selector上,关注OP_ACCEPT事件;接收新连接后,把SocketChannel也注册到同一个Selector,关注OP_READ或OP_WRITE。一个Selector线程跑一个死循环,调select()得到就绪事件集合,然后逐个处理这些Channel。连接数和线程数不再是1:1的关系,这是NIO解决高并发的根本逻辑。
1.3 NIO是同步非阻塞,别把它叫成异步
这里有个经常被说反的概念:Java NIO是同步非阻塞IO,不是异步IO。
NIO的“非阻塞”体现在Channel上:read()调用不会因为没数据而挂住线程,没数据时立即返回0或-1;accept()也不会因为没新连接而卡住。但整个流程里,程序还是要自己主动调用select()去查询事件、主动调用read()去搬运数据,这个“主动查询+主动处理”的模型是同步的。
真正的异步IO,比如Windows上的IOCP,是内核把数据准备好之后回调或者通过完成端口通知你,期间程序完全不需要去“轮询”。Java里对应的API是AIO(AsynchronousSocketChannel),但它在某些主流服务器系统上支持得不算好,所以实际场景里Java高性能网络库几乎都选择基于NIO的多路复用模型,而不是AIO。Netty里常说的“异步”更多是API风格层面的回调异步,这个到第三章再展开。
2. 用原生NIO手写服务端,我踩过的几个真坑
2.1 一个最小可跑的NIO服务端骨架
知道概念之后,我第一件事就是手写NIO服务端。一个最小骨架大概长这样:
Selector selector = Selector.open(); ServerSocketChannel serverChannel = ServerSocketChannel.open(); serverChannel.configureBlocking(false); serverChannel.bind(new InetSocketAddress(8080)); serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select(); // 阻塞直到至少一个Channel就绪 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); iterator.remove(); // 不remove会导致同一个key被重复处理 if (key.isAcceptable()) { SocketChannel socketChannel = serverChannel.accept(); socketChannel.configureBlocking(false); socketChannel.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { SocketChannel socketChannel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int read = socketChannel.read(buffer); if (read == -1) { socketChannel.close(); } else { buffer.flip(); handleBuffer(buffer); } } } }骨架本身不难,难的是跑到生产环境不出幺蛾子。下面几个坑,代码里的注释根本不会告诉你,全是线上日志和CPU火焰图教我的。
2.2 粘包半包:第一个让我熬夜的Bug
TCP是一个字节流协议,它不像UDP那样天然保留消息边界。你调用一次write()发送一条消息,内核可能把它拆成两段发出去;反过来,两次write()的数据也可能合并到一个TCP段里到达对端。于是我在read()里按1024字节切数据时,经常出现两种诡异情况:
- 半包:一条完整的业务消息被切成了多段,比如消息正文5000字节,第一次
read()只取到前1024字节,后面几段要等后续事件才行。 - 粘包:两条消息合并到达,第一次
read()读到的字节里同时包含“第一条消息的后半截”和“第二条消息的前半截”。
如果直接把这批字节按一条消息解析,结果必然错乱。解决思路是引入“消息帧”的概念:要么固定长度,要么用分隔符,要么在帧头放长度字段。收到数据先拼到一个累积缓冲区,然后不断尝试按协议解出完整帧,刚好多出来的数据继续留在缓冲区等下一波。这个逻辑本身很成熟,但手写起来极其容易出边界错误:缓冲区要不要扩容、长度字段校验、连接关闭时缓存里还压着半包怎么处理……我熬了好几个晚上才把一套基本可用的拆包器调稳。
2.3 空轮询与OP_WRITE:CPU被打满的两种姿势
第二个大坑是空轮询。早期某个JDK版本配合某些服务器系统内核时,selector.select()会偶发地在没有事件的情况下立即返回0。我在循环里不加处理,发现CPU直接冲满100%:线程在死循环里狂转,却没有任何数据可处理。用jstack看线程栈,线程一直停不下Selector相关调用,可它又不阻塞。
排查思路是统计select()返回0的连续次数,超过阈值(比如1000次)就重建Selector,把原有Channel重新注册到新Selector上。重建时还要保存每个Channel的interestOps,不然注册完就失去了感兴趣的事件。后来新JDK修复了这位bug,但生产环境的内核版本往往很杂,防御性重建仍然值得保留。
第三个坑是OP_WRITE误用。简单场景下写数据不理会写事件,直接write()就行,但往缓冲区满的连接上写大对象时,write()返回0,这时才需要注册OP_WRITE等对端窗口腾出来。问题在于OP_WRITE几乎永远就绪——只要发送缓冲区还有空间,这个事件就会不断触发。如果我把写操作挂在OP_WRITE上,事件循环就会疯了一样无限写,CPU很快就拉满。正确姿势是:发现没写完才注册OP_WRITE,写完后立刻从interestOps里清掉这个事件。
2.4 为什么后来我放弃了原生NIO
这几个坑只是冰山一角。真要在生产环境里稳定跑,还得处理断线重连、Connection reset异常、半包缓存释放、连接数增长后的内存配额、读写带宽控制……每一样都可以写成一个小项目。等到实际业务上线,我发现自己在拼命地“重复造轮子”,而造出来的轮子还不够圆。
后来我在项目里引入Netty,不是说原生NIO不能跑,而是Netty已经把那些“网络编程里不该由业务开发反复操心的事”全部收敛进框架了。这才明白为什么那么多Java网络中间件最终都长成Netty的样子。
3. Netty帮我做了什么:EventLoop、Pipeline与ByteBuf的设计意图
3.1 EventLoop线程模型:串行为什么比加锁更值钱
Netty对原生Selector的封装叫EventLoop,一个EventLoop线程对应一个Selector,底下挂着一批Channel。服务端启动时通常分两组:bossGroup负责接收新连接,workerGroup负责处理已建立连接的读写。接收连接本质上是个轻量操作,所以bossGroup线程数一般设为1;真正的读写压力全在workerGroup,线程数默认是CPU核心数乘以2。
它最巧妙的设计是:一个Channel从注册到关闭,所有事件处理器永远在同一个EventLoop线程上串行执行。这意味着你写ChannelHandler业务代码时完全不用加锁,因为不会有两个线程同时操作同一个Channel。串行避免了并发冲突,比“加锁+并发”更值钱,也大大降低了业务开发的心智负担。
代价是:这个线程一旦被阻塞,底下所有Channel的事件都会停摆。在Netty里最不该做的事,就是在ChannelHandler里执行耗时的阻塞调用。见过不止一次线上事故:一个handler里直接Thread.sleep()或者调用外部同步接口,单个事件处理几百毫秒,EventLoop线程被拖死,一千多个连接集体超时。后面第五章会专门复盘这类事故的完整链路。
3.2 ChannelPipeline:把编解码、业务、异常串成一条链
ChannelPipeline是Netty里最体现架构水准的设计。每个Channel都有一条Pipeline,内部串着一堆ChannelHandler。数据进站(inbound)时从Pipeline头部往尾部走;出站(outbound)时从尾部往头部走。
我在项目里最常用的Handler排列顺序一般是:
解码器 -> 业务处理器 -> 异常处理器编码器则加在出站方向合适的位置。这样“粘包半包拆解”“业务逻辑”“异常兜底”全部分离,每个Handler只做一件事。而且Pipeline是运行时可变的,我在做设备接入网关时,认证通过后直接ctx.pipeline().remove(authHandler)把认证Handler拆掉,后续链路就只走业务Handler,这个灵活性手写NIO完全做不到。
需要注意一个容易迷惑的点:在ChannelInboundHandler里调用ctx.writeAndFlush(...)时,写出站事件也是从当前Handler往头部方向走的,不一定会经过Pipeline尾部的编码器。想确保经过整条出站链,我通常用ctx.channel().writeAndFlush(...),它会从尾部开始往回走。
3.3 ByteBuf与内存管理:读写指针分离只是第一步
原生ByteBuffer有一个position指针,读写模式切换时需要手动flip(),写多了很容易忘记复位。Netty的ByteBuf直接拆成readerIndex和writerIndex两个指针,写时往前推进writerIndex,读时往后推进readerIndex,不再需要flip()这种反人类的操作。仅这一点就让我觉得Netty用起来舒服得多。
更关键的是内存管理层:
| 维度 | java.nio.ByteBuffer | Netty ByteBuf |
|---|---|---|
| 读写游标 | 单一position,需flip切换 | readerIndex/writerIndex分离 |
| 池化能力 | 无内置池,每次分配走底层 | PooledByteBufAllocator默认开启,零拷贝场景友好 |
| 引用计数 | 无 | retain/release手动管理,Netty在链路中自动扣减 |
| 堆外/堆内 | 支持DirectByteBuffer,但释放靠手动 | DirectBuffer由Netty统一池化并跟踪泄漏 |
其中引用计数是整个内存模型的核心。ByteBuf被writeAndFlush出去之后,Netty会在消息处理完毕时自动release;但如果你在Handler里retain了一份存到别处用,就必须自己release,否则就会泄漏。这是Netty使用中最容易翻车的地方,第五章节我会讲怎么定位。
3.4 Future与Promise:这里的“异步”指的是API层面
原生JDK的FutureTask.get()调用会阻塞当前线程,这在异步场景里很痛苦。Netty自定义了ChannelFuture和Promise:前者是异步操作的结果对象,你可以给它addListener注册回调,操作完成后回调自动执行,调用方不需要原地等待;后者偏向组件内部状态传递,可以手动让一个Promise“成功”或“失败”。
我举一个具体例子:客户端发起连接时,bootstrap.connect()返回的是一个ChannelFuture。我可以注册一个监听器,在连接成功后主动发第一个握手包,而不是socket.connect()在那傻等。用习惯之后再看代码,整条消息链路都是回调和监听器串起来的,主线程不会因为任何一次网络等待而阻塞,这才是Netty值得叫“异步框架”的原因。
4. 一个设备接入网关的Netty落地过程
4.1 先定协议:魔数、类型、长度字段的帧设计
我拿一个真实的设备接入网关项目来说,场景是一批传感器通过TCP长连接上报数据。动手写引导代码之前,最重要的工作是先定好通信协议。TCP没有消息边界,所有拆包逻辑都依赖协议约定。我们用的帧结构是:
魔数(1字节) | 版本(1字节) | 类型(1字节) | 长度(4字节) | payload- 魔数:固定值
0x5A,用于快速判断是不是本协议的数据,避免把垃圾数据当消息处理。 - 长度:表示payload的字节数,接收端依赖它从累积流里切出完整消息。
Netty里拆这种帧直接用LengthFieldBasedFrameDecoder,参数配置:
new LengthFieldBasedFrameDecoder( 1024 * 1024, // maxFrameLength:单帧最大1MB,防超大帧拖垮内存 3, // lengthFieldOffset:长度字段从payload前第3字节开始 4, // lengthFieldLength:长度字段占4字节 0, // lengthAdjustment:长度字段代表payload长度,无需额外补偿 7 // initialBytesToStrip:解码后去掉7字节帧头,handler拿到的就是纯净payload );这里的关键是理解lengthFieldOffset和initialBytesToStrip:前者告诉解码器从哪里读长度,后者决定解码后要不要保留帧头。业务Handler如果不需要关心魔数和版本,就把7字节都剥掉,拿到干净payload;如果要在Handler里做协议校验,那就设成0保留帧头。我一开始把initialBytesToStrip误解成从第几个字节开始解,结果解析出来的消息永远错位,排查半天。
4.2 服务端引导与ChannelOption参数复盘
协议定了,服务端引导配置如下:
EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .option(ChannelOption.SO_REUSEADDR, true) .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(1024 * 1024, 3, 4, 0, 7)); ch.pipeline().addLast(new ProtocolDecoder()); ch.pipeline().addLast(new ProtocolEncoder()); ch.pipeline().addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS)); ch.pipeline().addLast(new DeviceAuthHandler()); ch.pipeline().addLast(new BusinessHandler()); ch.pipeline().addLast(new ExceptionHandler()); } }); ChannelFuture future = bootstrap.bind(8080).sync();逐个解释这些参数为什么这么设:
SO_BACKLOG=1024:accept队列的长度。高并发瞬间如果同时涌进来超过队列容量的连接,内核会直接拒绝握手。1024是我们压测下来比较稳妥的值,不需要拍脑袋给个几十万,还要配合系统层somaxconn才能生效。SO_REUSEADDR=true:服务端重启时端口不会被TIME_WAIT状态卡住,开发调试必备。TCP_NODELAY=true:禁用Nagle算法。传感器上报这类小报文业务,等包凑满再发只会白白增加延迟,必须开。ALLOCATOR=PooledByteBufAllocator.DEFAULT:让Channel默认使用池化ByteBuf,减少高频消息下的分配开销和GC压力。
这里我没有调SO_RCVBUF/SO_SNDBUF。TCP的收发缓冲区在现代内核里会自动伸缩,非特殊场景别去动它,调错了反而会拖低吞吐。
4.3 心跳、空闲检测与断线清理的状态机
TCP自带的SO_KEEPALIVE默认两小时才探测一次,对长连接业务来说几乎等于没有。所以设备网关里一定要做应用层心跳。
我用IdleStateHandler(60, 0, 0)实现:60秒内没有读事件就触发空闲事件。然后在userEventTriggered里做状态机处理:
@Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { idleCount++; if (idleCount >= 3) { // 连续180秒没有收到心跳,判定设备已失联 ctx.close(); } else { // 第一次、第二次空闲时先等一等,不发心跳给对端 } } else { super.userEventTriggered(ctx, evt); } }设备侧只要每30秒发一次心跳,服务端收到心跳后IdleState的计时器会被重置,不会触发空闲事件。真正断网或设备死掉时,服务端最多多等一小段就能感知并清理连接。
这里我踩过一个小坑:心跳包走了自定义协议Decoder,但如果在Decoder里把心跳包也当普通业务消息传给BusinessHandler,业务侧也得跟着处理心跳,否则又要加一堆判断。我的做法是把心跳识别塞进Decoder,识别出心跳包后直接在ProtocolDecoder里吃掉(不回传给业务Handler),业务Handler只关心真正的上报数据。
4.4 优雅停机:资源释放的最后一公里
服务上线之后有个特别容易被忽视的问题:进程重启时,如果直接kill,所有长连接会被硬生生掐断,客户端那边很快堆积大量重连请求,甚至触发一批告警。正确做法是用优雅停机收尾:
Runtime.getRuntime().addShutdownHook(new Thread(() -> { bossGroup.shutdownGracefully(0, 10, TimeUnit.SECONDS); workerGroup.shutdownGracefully(0, 10, TimeUnit.SECONDS); // 业务线程池也做shutdown,等已有任务跑完 businessExecutor.shutdown(); }));shutdownGracefully(quietPeriod, timeout, unit)的意思是:安静期内不许再有新事件,处理完存量事件后,最长等timeout就强制关闭。我在停机前还会把半包缓存里残留的数据丢弃、把待写队列里的消息尝试flush完,虽然Netty内部会清理,但主动做一遍能让客户端优雅感知服务端下线,比直接断连体面得多。
5. 线上事故复盘:Netty用久了最容易翻车的几个角落
5.1 业务阻塞EventLoop:一次雪崩的完整链条
有一次线上告警,服务端所有连接集体超时,CPU利用率却不高,RSS内存缓慢上涨。我第一反应是看线程栈,发现大量NioEventLoop线程都阻塞在同一个第三方HTTP调用的get()方法上。
事故链条非常清晰:某个Handler在收到消息后,直接调用了外部REST接口并同步等结果。单个调用大约几百毫秒,一次EventLoop线程阻塞期间,它管理的几百个连接全部无法处理读写。外部接口一抖动,整组连接随之中断;客户端重连又打到其他EventLoop上,雪球越滚越大。
修复办法是彻底隔离耗时操作:
@Override public void channelRead(ChannelHandlerContext ctx, Object msg) { businessExecutor.execute(() -> { String result = restClient.get(...).get(); // 在业务线程池里阻塞 ctx.executor().execute(() -> ctx.writeAndFlush(result)); // 回到EventLoop线程写回 }); }记住一条红线:ChannelHandler里的方法只是事件回调,不是一个可以随意耗时的业务入口。凡是要访问数据库、调用下游、做复杂计算的逻辑,一律丢到独立线程池,让EventLoop只处理IO转发和极轻量计算,这是Netty程序稳定性的第一条铁律。
5.2 背压缺失:响应写不出去时,内存先崩
背压问题光看Demo永远不会暴露。压测时如果我们一口气给服务端发一万条请求,每条响应1MB,服务端全部立刻writeAndFlush,而客户端又处理不过来,那么这些数据就会堆积在Netty的发送缓冲区里。
原因在于Channel.writeAndFlush(...)只是把数据写进待发送队列,真正发出去要内核腾出窗口。对端消费慢,队列就越堆越长,堆内存和堆外内存一起涨。我们当时监控ChannelOutboundBuffer.totalPendingWriteBytes(),发现单条连接的待写数据一度冲到几十MB,内存很快吃紧。
处理背压有两种常用手段。一是利用Netty的写水位设置:
channel.config().setWriteBufferWaterMark(new WriteBufferWaterMark(32 * 1024, 64 * 1024));pendingWriteBytes超过高水位时channel.isWritable()变为false,业务侧依据这个flag暂停发送;降到低水位后再恢复。二是给writeAndFlush()的future加回调,一条一条发:
channel.writeAndFlush(message).addListener(f -> { if (f.isSuccess()) { // 发送成功,取队列里的下一条继续 } else { // 发送失败,记录并关闭连接 } });这种“滑动窗口”似的处理能在源头控制内存,而不是等OOM告警找上门。
5.3 ByteBuf泄漏:从LEAK日志到分配点定位
Netty的内存管理高度依赖引用计数,谁租用谁归还。最容易泄漏的场景是:我在Handler里对入站消息做了retain()保存到成员变量,准备稍后异步使用,结果消息处理完没人release(),内存只增不减。
实战排查第一步是打开泄漏检测。启动参数加:
-Dio.netty.leakDetection.level=advanced日志里出现LEAK: ByteBuf.release() was not called before it's garbage-collected之后,advanced级别会打印出“最近采样到的分配点栈”,配合ThreadLocal采样基本能定位到是哪一行创建的ByteBuf没有被归还。生产环境普通模式即可,PARANOID级别开销太大,一般只在测试环境用。
还有一个容易被忽略的点:只有堆外内存泄漏才最难发现。堆内ByteBuf漏了会体现在GC后的堆占用上,堆外DirectBuffer泄漏则完全绕开常规GC观测,内存曲线会像台阶一样不断爬升。我用MaxDirectMemorySize设置堆外上限,并监控进程RSS增长,让自己能尽早察觉异常。
5.4 连接数冲高:文件描述符与半包缓存残留
设备网关曾经经历过一次“连接数到某个数值后不再增长”的诡异现象,看日志全是Too many open files。系统限制进程最多打开1024个文件描述符,一个socket就是一个fd,千级连接已经是极限了。这里先要调整系统层的描述符上限:ulimit -n调到20万以上,再检查/proc/sys/fs/file-max是否撑得住。
经验教训是:压测环境一般不会卡在这个点,生产环境长期跑以后,连接数、fd数、内存曲线、线程数这些基础指标一定都要有监控面板,否则等到报警出来往往已经是雪崩现场。另外一个容易被忽视的细节是自定义拆包器在半包缓存上的清理:如果自己实现了ByteToMessageDecoder,连接关闭时channelInactive里没有清空累积缓存,每次高频重连都会残留一小块Buffer,量大了照样是内存黑洞。
我把这些年最常踩的问题整理成一张对照表,方便排查时快速定位:
| 现象 | 可能根因 | 处理方向 |
|---|---|---|
| 单个Handler阻塞导致整组连接超时 | 在EventLoop线程里做了阻塞调用 | 业务逻辑丢独立线程池,回调回EventLoop |
| 连接卡死、内存持续上涨 | 对端消费慢,发送队列堆积 | 打开isWritable水位判断,滑动窗口发送 |
| LEAK日志、堆外内存斜坡上涨 | ByteBuf retain后未release | 开启泄漏检测,按栈定位分配点 |
| Too many open files | fd限制过低或连接数超预期 | 调高ulimit,同时检查半包缓存释放情况 |
6. 一点延伸:当虚拟线程到来,NIO与Netty的地位还稳吗
6.1 虚拟线程要解决的是“线程廉价化”
Java 21正式带来虚拟线程之后,不少人问我:“是不是以后写网络服务又可以用回‘一个连接一个线程’的老写法了?Netty是不是要凉了?”
虚拟线程确实让线程变得极其廉价,它把阻塞在IO上的虚拟线程从平台线程里剥离,让Java开发者可以用熟悉的同步阻塞代码写出高并发服务。这在简单请求响应场景下非常舒服,代码可读性也高。但网络服务真正复杂的地方从来不只在“等IO”这一步:粘包半包拆解、超时重传、流量控制、背压处理、连接生命周期管理、优雅停机,这些跨切面的东西Netty依然给了一套久经考验的框架。虚拟线程解决的是线程模型问题,不是网络协议栈问题。
6.2 我的选型参考
就目前我自己的感受来说,选型会更偏场景:
- 如果只是一个内部管理系统偶尔暴露几个接口,连接数不高,虚拟线程配合传统Blocking IO完全够用,代码最直观。
- 如果要做高吞吐、高并发的网关、推送服务、物联网接入、RPC通信底座,Netty依旧是第一选择。它的内存池、水位控制、Pipeline扩展机制,是让我在线上少操心的关键。
- 如果是对NIO原理兴趣浓厚的同学,我仍然建议先用原生NIO手写一个聊天服务,踩一遍半包、空轮询、Buffer切换这些坑,再回来看Netty的设计,你会发现框架做的每一件事都事出有因。
技术选型从来不是“谁取代谁”的非黑即白,而是看当前团队的掌控力和业务对稳定性、吞吐量的真实要求。至少到目前,我做这类服务的第一反应还是打开Netty的PoC代码模板改参数,而不是重新从Socket开始写。