做物联网项目的通信层,SpringBoot集成Netty这件事在我这儿已经算保留项目了。设备端产生的长连接、心跳、状态上报,还有控制指令下发,统统需要一个稳定、能扛住高并发的TCP和UDP服务端来兜底。如果你正在折腾传感器接入、车机通信或者智能硬件网关,这篇内容可以让你少踩几个坑。
先把话放前面:这不是一篇科普Netty编解码器的入门文章,而是围绕“用SpringBoot同时拉起TCP和UDP两个通信通道”的完整实战记录。里面包含我自己做过的端口规划、协议设计、线程模型选择,还有踩过的网上搜不到答案的坑。不管你是刚接触物联网后端,还是想优化现有通信服务的吞吐量,这套方案都可以直接抄作业。
1. 为什么服务端必须选Netty,而不是原生Socket
1.1 原生Socket在物联网场景下的三个致命伤
前几年我最早做设备接入的时候,用的是Java原生BIO那套ServerSocket逻辑:每个设备连接进来,就new一个线程去读输入流。连几台设备没问题,但接上几百台之后,线程数量直接爆掉。哪怕后来换成线程池,也只是降低了线程频繁创建销毁的损耗,并没有解决根本问题——BIO是阻塞式的,只要有设备不发数据,那个线程就一直在read()上挂起,CPU没闲着,线程却全都卡死。
物联网设备还有一个特点是网络环境极不稳定。设备掉线、弱网、频繁重连是常态。原生的Socket每次断开都要处理异常,稍不注意就留下半关闭的socket和回收不掉的资源。代码写出来又臭又长,还没法应对数百台设备的并发。更麻烦的是,TCP是流式协议,设备上报的数据经常在路由层被拆成好几个包,原生Socket只能自己维护字节缓冲区去处理“半包”和“粘包”,教科书式的while循环堆积buffer写法,调试起来相当折磨人。
1.2 Netty的核心竞争力在哪里
Netty的锁竞争者其实一直是“自己封装Reactor模型”和“其他NIO框架”,但最终胜出的是它的生态完整度。它对网络传输这一层做了全套封装,从缓冲区池化(PooledByteBufAllocator)、零拷贝到精细的内存泄漏追踪,都开箱即用。用NIO的Selector多路复用,一个工作线程可以同时管理成千上万个Channel的读写事件,单机能扛住几万连接,这是BIO永远做不到的。
对业务层来说,Netty的pipeline设计自带handler链,粘包拆包有现成的解码器,心跳超时有IdleStateHandler,断线重连、流量整形这些也都有对应组件。你只需要把自己的业务处理器怼到ChannelPipeline上,剩下的网络细节Netty全包了。做物联网服务端,最值钱的是业务层代码,Netty把通信层的复杂度屏蔽掉,这才是它最大的价值。
1.3 Spring Boot与Netty的组合逻辑
有人问,既然Netty这么强,为什么还要套一层SpringBoot?我的理解是分工不同:Netty管“连接和字节”,SpringBoot管“业务和资源”。设备上报的数据最终要落地到MySQL、要写时序数据库、要触发告警规则,这些能力Netty本身没有,强行用原生Netty做业务集成会非常啰嗦。而SpringBoot恰好提供了IoC容器、事务控制、自动配置和监控端点,二者的边界非常清晰。
在实际项目里,我会把Netty服务端初始化和本身的Handler拆成独立的Configuration组件,通过SpringBoot启动的时候带起来。ChannelHandler内部不直接new业务Service,而是通过构造方法把SpringBean注入进去,让Netty线程池跟Spring容器的生命周期绑定在一起。这样一来,我能用Spring管理连接配置、线程池参数、业务处理对象,同时还能在Netty的Reactor线程模型里专心处理报文。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 通信服务端整体架构与协议设计
2.1 TCP与UDP同时承载的业务划分
物联网通信里TCP和UDP不是二选一,很多场景下它们会同时存在。TCP适合像控制指令下发、设备登录鉴权、OTA升级包传输这种对可靠性要求极高的操作,有确认机制和重传,确保业务不丢数据。UDP则更适合大批量状态上报、GPS定位轨迹、传感器实时采集这类“丢了拉倒、下一条马上来”的数据流,延迟比TCP低很多,还不占那么多连接资源。
我的服务端口规划是这样的:TCP固定监听18888,UDP固定监听18899。两个通道共用同一个业务消息分发层,这样设备端既可以通过TCP发控制类报文,又可以同时用UDP发高频状态报文,服务端接收后统一走同样的协议解析逻辑。从经验来看,这种双通道设计对IoT接入量大的场景特别有用——核心控制走TCP保证可靠性,状态数据走UDP降低带宽压力,互相不干扰。
2.2 自定义通信协议与字段说明
直接用字节流裸传字符串,在调试阶段省事,到了协议字段变更、报文加密、报文拆分的时候就会非常痛苦。所以我宁可前期多写几行代码,也坚持用二进制定长头部+变长包体的自定义协议。每个报文统一走下面的结构:
| 字段名 | 字节数 | 说明 |
|---|---|---|
| magicNumber | 4字节 | 固定魔数0x5A5A5A5A,用于识别合法报文 |
| version | 1字节 | 协议版本号,方便后续兼容升级 |
| msgType | 1字节 | 业务类型,0x01表示登录,0x02表示心跳,0x03表示状态上报 |
| payloadLength | 4字节 | payload数据长度,解决粘包的关键字段 |
| payload | 若干字节 | 实际业务数据,Json或二进制均可 |
头部定长10字节,加上payload长度正好构成一个完整的报文帧。设备端发送时先填好头部,再拼接内容,服务端解码时只要等足头部10个字节,解析出payloadLength,再继续读满对应长度的数据,就能安全地切出一帧完整报文。
2.3 避免粘包拆包难的提前设计
粘包是TCP流式协议独有的老问题,设备端如果连续发送两帧数据,TCP有可能把它们合并成一个包推到服务端,这叫粘包;反过来,如果一帧数据超过TCP分段大小,它会被拆成多个包到达,这叫拆包。解决思路无非两种:一种是报文里自带长度字段,另一种是用特殊分隔符。分隔符方案(比如换行符)实现简单,但数据内容里如果恰好出现了分隔符字符,协议直接错乱。
所以我选用“长度字段”方案:头部10个字节里的payloadLength非常明确地告诉解析器该读多少字节,读完之后自动重置,等下一帧头部。这样既不用逃逸字符,也不用担心内容里出现特定标记。再加上自定义解码器继承ByteToMessageDecoder,处理半包时数据不足就等下一次读事件,完全能Hold住弱网环境下的分包场景。
3. 核心代码落地:TCP通信通道
3.1 工程依赖与版本选型
SpringBoot的版本我推荐2.7.x,同时配netty-all 4.1.100.Final,这两个组合在兼容性和稳定性上经过了大量生产环境验证。如果强行用SpringBoot 3.x,Netty版本最好抬到4.1.107以上,避免与Java 17模块化机制出现冲突。pom里只需要引入一个Netty全量包,不用自己拼netty-handler、netty-codec那一堆模块,省心。
xml复制<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.100.Final</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
starter-web不是必须的,但我习惯加上,用来暴露一个健康检查端点,确认服务有没有正常拉起。真实部署环境里有Docker健康检查、负载均衡器的探活,没HTTP端点会很别扭。
3.2 基于Bootstrap的TCP服务构建
核心启动代码我放在NettyTcpServer这个组件里,用ApplicationReadyEvent触发启动,而不是放在PostConstruct里。原因很简单,PostConstruct执行时机太早,Spring容器可能还没完成所有Bean的初始化,万一通信Handler里注入了还没准备好的Bean,就会出现奇怪的NullPointer。ApplicationReadyEvent是在应用完全ready之后才发出来的事件,这时候去bind端口最安全。
java复制@Component
public class NettyTcpServer {
private static final Logger log = LoggerFactory.getLogger(NettyTcpServer.class);
@Value("${netty.tcp.port:18888}")
private int tcpPort;
@Resource
private TcpMessageHandler tcpMessageHandler;
@EventListener(ApplicationReadyEvent.class)
public void start() {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
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.SO_KEEPALIVE, true)
.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new LengthFieldBasedFrameDecoder(1024 * 1024, 6, 4, 0, 10))
.addLast(tcpMessageHandler);
}
});
ChannelFuture future = bootstrap.bind(tcpPort).sync();
log.info("TCP server started at port {}", tcpPort);
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("TCP server interrupted", e);
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
关于EventLoopGroup线程数,bossGroup只负责accept新连接,设1个线程就够了,多了没有意义。workerGroup不指定数量的话,Netty会默认用CPU核数的两倍,这也是最稳妥的配置。如果设备连接数极大,可以手动调大workerGroup的线程数,但要警惕线程太多导致上下文切换开销超过收益,这个需要压测来定。
3.3 Handler里如何安全调用Spring业务组件
通信Handler往往是生命周期最长的对象,一直挂在pipeline上。如果直接在initChannel里new一个Handler,那Handler内部想用Spring容器里的Service就麻烦。我的做法是把Handler定义为Spring管理的Bean,在bean里注入业务Service,然后把bean引用添加到pipeline。
java复制@Component
@ChannelHandler.Sharable
public class TcpMessageHandler extends SimpleChannelInboundHandler<ByteBuf> {
@Resource
private DeviceMessageService deviceMessageService;
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
// 这里已经是一个完整的数据帧
byte[] data = new byte[msg.readableBytes()];
msg.readBytes(data);
deviceMessageService.handleDeviceMessage(data);
}
}
加了@ChannelHandler.Sharable之后,所有TCP连接可以共享同一个Handler实例,避免每个连接都去创建对象。但有个前提:Handler本身不能持有可变的连接状态,比如“当前连接登录过没有”这种信息绝不能存在Handler的成员变量里,该放AttributeKey的就放AttributeKey。用线程安全的Design,Handler只做无状态消息转发,业务状态全部下沉到Service层。
3.4 资源释放与生命周期关闭
Netty的ByteBuf默认使用池化内存,如果handler处理完ByteBuf不释放,内存池迟早被撑爆。SimpleChannelInboundHandler在channelRead0回调结束之后会自动release消息,所以我选择继承它而不是ChannelInboundHandlerAdapter,这是关键差异。如果确需自己管理ByteBuf,操作完必须手动ReferenceCountUtil.release(msg),别偷懒。
4. 核心代码落地:UDP通信通道
4.1 无连接特性与初始化配置
UDP服务端初始化方式和TCP差异蛮大。它不用bossGroup,因为UDP根本没连接可以accept,只需要一个workerGroup处理读写事件,Channel类型要用NioDatagramChannel。启动时bind的是UDP端口,处理的数据载体从SocketChannel变成了DatagramPacket,每个包都自带发送端的SocketAddress,天然适合“设备发一包就走”的模型。
java复制@Component
public class NettyUdpServer {
@Value("${netty.udp.port:18899}")
private int udpPort;
@Resource
private UdpMessageHandler udpMessageHandler;
@EventListener(ApplicationReadyEvent.class)
public void start() {
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioDatagramChannel.class)
.option(ChannelOption.SO_BROADCAST, true)
.option(ChannelOption.SO_RCVBUF, 1024 * 1024)
.handler(udpMessageHandler);
ChannelFuture future = bootstrap.bind(udpPort).sync();
log.info("UDP server started at port {}", udpPort);
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("UDP server interrupted", e);
} finally {
group.shutdownGracefully();
}
}
}
SO_RCVBUF我建议调大一点,UDP场景下如果socket接收缓冲区太小,内核会直接把来不及消费的包丢掉。特别是设备密集上报数据时,UDP丢包会非常严重。真实环境里,我试过默认8K的接收缓冲,设备加大上报频率后,服务端收到的报文缺了将近三成,把SO_RCVBUF调到1MB后,丢包率才恢复正常。剩余丢包是网络层面的,把UDP做成可丢失的可靠传输模型才是正解。
4.2 UDP报文接收与响应逻辑
UDP的Handler处理的是DatagramPacket,读出来就是一个完整的UDP数据报。由于UDP每个包都是独立的,不存在粘包问题,所以不需要像TCP那样前面做LengthFieldBasedFrameDecoder,直接把payload交给业务解析器处理。
java复制@Component
@ChannelHandler.Sharable
public class UdpMessageHandler extends SimpleChannelInboundHandler<DatagramPacket> {
@Resource
private DeviceMessageService deviceMessageService;
@Override
protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket packet) {
ByteBuf content = packet.content();
byte[] data = new byte[content.readableBytes()];
content.readBytes(data);
deviceMessageService.handleUdpMessage(data, packet.sender());
}
}
要注意的是ChunkedWriteHandler在这里帮不上忙,UDP报文必须一次性写完,DatagramPacket的发送是原子的。如果想回复设备,可以从packet.sender()拿到设备端的IP和端口,然后new一个DatagramPacket然后写到这个socket地址上,不能直接复用原来的packet对象,否则会改掉原包的remote地址。
4.3 UDP服务端如何做设备状态感知
UDP没有连接状态,服务端不会自动感知设备是否下线。我的做法是在服务端主动维护一个设备最后活跃时间表,每次收到UDP上报就更新表,后台每隔30秒扫描一次,超过3分钟没活跃的设备就标为离线,触发告警或通知。这套逻辑本质上就是“伪心跳”,因为UDP设备不一定有固定的心跳包,状态上报本身就带有心跳性质,拿最后上报时间当活跃依据,简单有效。
对比TCP连接,TCP因为有了连接,可以直接挂IdleStateHandler做读超时判定,超过一定时间客户端不发心跳就断开Channel,并触发onLineChange回调。TCP和UDP两套状态的维护策略是两套逻辑,代码层面要分开,否则一合并就容易写糊。
5. 粘包拆包处理的完整落地
5.1 粘包问题的表象与本质
先描述一下场景:设备每200毫秒上报一组温度数据,报文长度200字节,按道理一秒钟能发出5个报文。TCP是流式协议,它不保证把这5个报文边界发给server,可能一次性把5个报文的字节全部堆到接收缓冲区,也可能只推一半不到。如果解码器每次读到的都是没有任何边界的裸字节流,业务层就会收到一坨形如“报文1报文2报文3”混在一起的东西,这就是粘包。
真实项目里,权限校验、报文越界、数组越界、局部变量串包,各种诡异bug都源于粘包。尤其在弱网下,发送窗口频繁调整,数据被TCP分段更不可控,粘包现象会被放大。别跟我扯“测试环境没遇到”,生产环境早该把解码器做对。
5.2 四种解码器方案对比
Netty自带的解码器能覆盖绝大多数场景,核心是选对类型:
| 解码器 | 适用场景 | 缺点 |
|---|---|---|
| LineBasedFrameDecoder | 报文以换行符分隔,日志类文本传输 | 内容不能含换行,需转义处理 |
| DelimiterBasedFrameDecoder | 自定义分隔符,如分号或\0结尾 | 分隔符冲突时需协议兜底 |
| FixedLengthFrameDecoder | 每个报文变长统一 | 要求业务报文定长,灵活性差 |
| LengthFieldBasedFrameDecoder | 协议头含长度字段,通用性最强 | 需要正确配置偏移量 |
物联网协议建议直接选LengthFieldBasedFrameDecoder,它支持在报文中任意位置读取长度字段,再根据长度截取本条报文,天然处理半包、粘包。头部固定有magicNumber的话,配置lengthFieldOffset=6(跳过魔数和版本号)、lengthFieldLength=4、lengthAdjustment=0(长度字段代表的数据长度,不含头部本身)、initialBytesToStrip=10(截帧后剥掉头部,最终只留payload)。这套参数在实际生产里最稳。
5.3 自定义Decoder法兜底
如果非标设备和老协议太多,LengthFieldBasedFrameDecoder配置起来费劲,那就继承ByteToMessageDecoder自己实现。核心思路是:累计读入的数据先攒到累计缓冲里,每次从缓冲里读头部,解析出长度后,看看累计缓冲里的字节数是否达标,不够就当半包继续等;够了就切出一帧,剩余数据留到下一帧。
java复制public class CustomProtocolDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.readableBytes() < 10) {
return;
}
in.markReaderIndex();
int magic = in.readInt();
if (magic != 0x5A5A5A5A) {
in.resetReaderIndex();
in.skipBytes(1);
return;
}
in.skipBytes(2);
int payloadLength = in.readInt();
if (in.readableBytes() < payloadLength) {
in.resetReaderIndex();
return;
}
ByteBuf frame = in.readRetainedSlice(payloadLength);
out.add(frame);
}
}
注意这段代码里的“if magic不匹配”分支,不能直接assert掉,而要跳过1个字节继续尝试,否则一旦设备端误发了脏数据,解码器就会被卡死在头部解析上,后面的正常数据永远解不出来。这类防御式解析,是网上很多demo不会告诉你的细节。
5.4 解码器参数设置的经验值
LengthFieldBasedFrameDecoder的maxFrameLength别设太小,也别设太大。1MB对绝大多数物联网上报报文足够,超过的直接抛异常并断开连接,这样能防止恶意设备发超大报文占内存。这里的异常处理必须是“断开连接”,而不是“忽略继续读”,否则内存危险还在。
TCP_NODELAY和SO_KEEPALIVE两个参数配合解码器也很有用。TCP_NODELAY为true会禁用Nagle算法,小报文不等待立即发送,能显著降低报文转发延迟,代价是网络带宽利用率略降,但对控制类交互场景值得;SO_KEEPALIVE让内核踢掉口头死掉的连接,避免僵尸连接一直占着file descriptor。
6. 常见问题与排查技巧实录
6.1 服务起不来:端口被占用和Address already in use
排查顺序要先看端口监听状态。Linux下用netstat -tlnp | grep 18888或ss -tulpn | grep 18888看看端口是否被其他进程占用。如果服务之前正常,突然绑定失败,多半是旧的Java进程没有完全退出,jps -l看一下Java进程列表,找到PID之后kill掉再来。另外如果应用在容器里跑,还需要确认宿主机的端口映射是否真的映射到了容器内部,我曾经在Docker容器里启动服务时忘了映射18888端口,宿主机完全访问不到,排查了半天才发现问题。
6.2 客户端能连上但收不到数据
这个典型场景是服务端没做数据写回,或者Handler里直接调了业务Service的异步方法没等结果。还有一种可能是Handler忘写了writeAndFlush方法。ChannelHandlerContext.writeAndFlush()不是写到某个确定的连接上的,一定要确认当前ctx对应的channel是发送方的channel,不要拿着别的ctx往错误的channel里写。UDP场景下还要注意,必须用DatagramPacket带上SocketAddress作为目标地址,裸写ByteBuf是发不出去的。
6.3 Netty内存泄漏与ByteBuf未释放
netty自带了内存泄漏检测,日志里如果出现LEAK: ByteBuf.release() was not called before it's garbage-collected,说明有ByteBuf没有释放。常见的处理位置是自定义Decoder里用readSlice返回的派生ByteBuf,它们共享引用计数,必须显式release或使用retainedSlice并管理refCnt。我给一个最稳的约束:对于所有传递给下游Handler的ByteBuf,使用SimpleChannelInboundHandler并让基类处理release;只有从ctx中拿回来的ByteBuf并且你能确保处理完了,才手动release。
6.4 大量设备连接后内存飙升
连接数暴涨后内存问题,大多数是创建太多的EventLoopGroup线程,或者每个连接的pipeline上堆积了大量Handler实例。另外childOption里没设ALLOCATOR,Netty会在不受控情况下使用堆外内存,堆外内存不计入JVM堆,等native内存吃满就Hang。解决方式是指定PooledByteBufAllocator.DEFAULT,并监控Netty的direct memory使用情况。写的话说烂了,但做项目时真能救你一命。
6.5 心跳超时判定不准
心跳超时判定不准分两种:一种是有TCP连接但设备端不主动发心跳,IdleStateHandler设了readerIdleTime仍超时,那是定的阈值太短或者设备没按协议发心跳;另一种是UDP场景,设备上报本身就不规律,但你在服务端用固定时间窗口判定活跃,容易误杀。处理技巧是把心跳阈值设成正常发送间隔的3~5倍,且UDP的“活跃”认定不完全依赖心跳,对状态上报报文的每一次到达都刷新活跃时间。设备离线判定不要只信超时,要联合设备业务层的请求,比如设备在离线前有没有主动发过退出报文。
6.6 Docker部署时的端口与网络问题
容器里跑Netty和普通Java进程没太大区别,端口bind的地址要用0.0.0.0而不是127.0.0.1,否则容器外部无法访问。此外容器内的线程数是受cgroup限制的,Netty默认通过获取CPU核数来创建线程,如果容器限制为4核,但是宿主机是64核,Netty默认创建线程数可能过度,而我们实际只分配了4核,会导致线程上下文切换频繁。处置办法是手动指定workerGroup线程数,比如new NioEventLoopGroup(4),并按容器资源使用率调优。
6.7 SpringBoot版本过高导致的Netty兼容性问题
SpringBoot 2.7.x和Netty 4.1.100.Final这个组合一直在线上跑,问题不大。但如果你用了SpringBoot 3.x,要注意它默认带了Java 17的模块系统,Netty 4.1.100.Final的某些反射操作会报InaccessibleObjectException,需要加JVM参数--add-opens java.base/java.nio=ALL-UNNAMED。推荐做法是直接用SpringBoot 2.7.x + Netty 4.1.100.Final,省去这一堆jdk module相关的排查。升级Netty之前,非要先看它声明支持的Netty版本区间,不然编码器在运行时遇到找不到类,排查方向就偏了。
6.8 排查技巧小结
最后分享一个通用的排查套路:先用tcpdump -i any port 18888抓包确认TCP/UDP设备端是否真的把数据发到了服务端;然后看Netty日志里有没有异常EventLoop异常;最后加上业务日志,一条设备上报经过解码器、业务处理、落库,每一步留一条log,直接定位是网络层没到、解码没切对还是业务层异常。这套组合拳解决了我至少七成以上的通信疑难杂症。
7. 后续扩展方向与真实体会
这套TCP/UDP服务端跑起来之后,我把重心转到了扩展性上。目前Netty支持上行到Kafka、落MySQL、触发告警,后续如果要接设备主动请求的RPC协议,可以在Handler里加一层动态Router,按msgType分发到不同的ChannelHandler,不用改pipeline结构。另外想加上设备鉴权、在线统计、服务端主动下发指令,可以在Netty里维护一个ChannelGroup,按设备业务ID动态选择Channel推送数据。
做过物联网通信这段经历,我自己觉得最难的不是把Netty集成到SpringBoot,而是设计一套不出乱子的协议,以及让Netty的线程模型和Spring业务线程各司其职。Netty的Handler里坚决不碰数据库、不处理耗时业务逻辑,只做报文解析和转发,把真正重的任务扔给业务线程池。这样既保住了通信层的并发能力,又不会因为JDBC慢查询把EventLoop线程拖垮。
如果你手头正有设备接入任务,建议从这套双通道方案起步,TCP和UDP分开跑,协议定长头部加上LengthFieldBasedFrameDecoder,再配上一份适合业务场景的心跳判定,基本上能把90%的通信问题挡在门外面。后面遇到具体的性能瓶颈,再往线程模型和内存优化方向深挖,路会越走越顺。
