十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot整合Netty实战:物联网TCP/UDP服务端开发与调优

SpringBoot整合Netty实战:物联网TCP/UDP服务端开发与调优 1. 物联网通信场景的痛点与选型思路1.1 为什么传统Socket开发撑不住物联网场景我最早做物联网服务端的时候用的还是最原生的Java Socket那一套。新连接来了开一个线程客户端多了就开线程池当时觉得也没啥问题。直到设备量从几十台涨到几千台问题就全冒出来了线程池被打满、频繁上下文切换导致CPU使用率直线飙升、客户端断线重连后的半包粘包问题把解析逻辑搞得一团糟。最要命的是设备分布在各种弱网环境里动不动就断线传统阻塞IO模型在这种场景下根本扛不住。后来我换了Netty一个基于NIO的异步事件驱动网络框架。它的核心优势在于一个线程可以同时处理成千上万个连接的读写事件不需要每个连接都占一个线程。这就好比以前每一个顾客都要一个专属服务员全程跟着现在改成了大堂经理模式几个服务员轮流转谁有需求就服务谁人效完全不是一个量级。对于物联网场景Netty解决的不只是并发问题。设备接入的协议五花八门有直接TCP上报的有走UDP的有私有协议封包的。Netty的编解码器链机制可以把协议解析和业务逻辑彻底解耦加一个新的协议只需要往管道里塞一个新的编解码器不用动业务代码。这对于设备种类多、协议迭代快的物联网项目来说是刚需。1.2 TCP还是UDP物联网场景该怎么选很多入门物联网开发的同学经常纠结一个问题设备上报数据到底用TCP还是UDP这里我直接给结论需要可靠传输、有指令下发、对数据完整性要求高的场景无脑选TCP。比如智能门锁的远程开锁指令、充电桩的计费订单上报、工业PLC的状态数据采集这类数据丢一条都可能出大问题必须用TCP。TCP的三次握手建立了可靠的连接通道配合确认重传机制能保证数据有序不丢包。反过来看纯数据上报、允许极少量丢失、对实时性要求极高的场景UDP更合适。比如环境监测传感器每隔几秒上报一次温湿度就算偶尔丢一两条数据也无所谓但网络拥堵时TCP的重传反而会造成延迟堆积UDP就没这个问题。还有设备定位信息上报位置数据本身就有时效性用TCP重传一个几秒前的旧位置没有意义这时候UDP的发完即走反而更合理。在实际项目里我见过很多团队一开始图省事全部走TCP结果设备量大了之后发现服务器连接数成了瓶颈。也见过反过来全用UDP的结果重要指令丢失导致用户投诉。我的建议是服务端把TCP和UDP都支持起来按业务类型区分使用。这也是我下面要讲的这个项目为什么同时实现两种协议的底层原因。1.3 SpringBootNetty这套组合好在哪里Netty本身是一个独立的网络框架不依赖Spring。但放到真实项目里服务端不可能只有网络通信这个模块还得有设备管理、数据入库、指令下发接口、告警通知这些业务功能。用SpringBoot的好处是可以把Netty的启动、销毁纳入Spring容器生命周期管理让Netty的各个组件能通过Spring的依赖注入拿到业务Service优雅地拿到业务Service。优雅实现业务逻辑与网络层的整合。具体来说SpringBoot负责提供IOC容器把Netty的EventLoopGroup、ServerBootstrap、ChannelInitializer这些核心组件注册成Bean通过PostConstruct在应用启动时启动Netty服务端通过PreDestroy在应用关闭时优雅释放资源。业务处理Handler里直接注入其他Spring管理的Service比如设备状态服务、消息推送服务这样就不需要自己维护Netty和业务模块之间的通信架构干净很多。另一个实际好处是SpringBoot的生态。配置管理用application.yml监控走actuator部署直接用fat jar。这些对一个需要长期维护的服务端项目来说都是隐形收益。所以SpringBootNetty的组合对我来说已经是物联网服务端开发的标配了。2. 项目初始化与依赖准备2.1 SpringBoot和Netty的版本匹配问题先聊一个很多人踩过的坑SpringBoot版本和Netty版本的兼容性。SpringBoot从2.x开始内部的webflux模块默认引入的Netty版本是4.1.x系列。如果你自己没有引入Netty依赖直接用SpringBoot的webflux它自带的Netty版本就已经能跑。但我们需要的是在SpringBoot项目里同时使用Netty做TCP/UDP服务端这时候就涉及版本冲突的问题。我的建议是不要使用SpringBoot内置管理的Netty版本做服务端开发自己显式声明Netty依赖的版本。原因有两个第一SpringBoot内置的Netty主要是给webflux用的某些版本的属性配置不一定适合你自定义服务端的场景第二自己声明版本可以保持可控性升级Netty时不受SpringBoot版本约束。具体版本搭配我实测比较稳的组合是SpringBoot 2.7.x Netty 4.1.100.FinalSpringBoot 3.x Netty 4.1.104.Final如果你用SpringBoot 3.x注意JDK版本必须是17以上。这个组合在线上跑了大半年没遇到因版本引起的兼容性问题。2.2 Maven依赖的引入细节在pom.xml里引入Netty依赖时很多教程只让你引入一个netty-all这个其实不太推荐。netty-all是把所有模块打成一个包好处是省事坏处是体积大而且有时候会因为传递依赖导致类冲突。我更推荐按需引入。做TCP/UDP服务端常用到的模块就这几个dependency groupIdio.netty/groupId artifactIdnetty-transport/artifactId version4.1.100.Final/version /dependency dependency groupIdio.netty/groupId artifactIdnetty-handler/artifactId version4.1.100.Final/version /dependency dependency groupIdio.netty/groupId artifactIdnetty-codec/artifactId version4.1.100.Final/version /dependency dependency groupIdio.netty/groupId artifactIdnetty-common/artifactId version4.1.100.Final/version /dependency其中netty-transport是核心包含NIO事件循环、Channel等netty-handler包含了IdleStateHandler心跳处理、LoggingHandler日志这些常用处理器netty-codec包了StringDecoder、ByteToMessageDecoder这些编解码基类。如果后面要自己实现更复杂的协议解析还需要引netty-codec下的子模块比如netty-codec-http。这里还要注意一个细节如果你的项目同时用了SpringBoot的webfluxSpringBoot自带了一个Netty版本和你显式声明的版本可能冲突。解决办法是在pom.xml的dependencyManagement里把你声明的Netty版本放在前面让Maven采用你的版本。如果没有特殊需求最简单的做法是不用webflux直接用spring-boot-starter-web做业务接口网络通信层完全交给自己的Netty实例。2.3 项目结构设计我习惯把项目拆成下面这种结构com.example.iotserver ├── IotServerApplication.java ├── config │ └── NettyConfig.java ├── netty │ ├── tcp │ │ ├── TcpServer.java │ │ ├── TcpChannelInitializer.java │ │ └── TcpMessageHandler.java │ ├── udp │ │ ├── UdpServer.java │ │ ├── UdpChannelInitializer.java │ │ └── UdpMessageHandler.java │ └── common │ ├── MessageCodec.java │ └── HeartbeatHandler.java ├── service │ ├── DeviceService.java │ └── MessageDispatchService.java └── controller └── CommandController.javanetty包下按协议分tcp、udp两个子包各自管理自己的启动器、初始化器和处理器。两个协议共用的编解码逻辑、心跳逻辑放到common子包里。service包放业务处理逻辑controller包对外提供HTTP接口比如通过HTTP接口给设备下发指令内部再通过Netty把指令写给对应的设备通道。这样一个分包的好处是TCP和UDP虽然共用一套业务Service但网络层完全隔离互不影响。后面如果要加MQTT协议再开一个mqtt子包就行改动成本很低。3. TCP通信服务端核心实现3.1 TCP服务端的启动装配先看TCP服务端的核心实现。我先把整个启动器封装成一个Spring组件由Spring容器来管理它的生命周期。Component public class TcpServer { private static final Logger log LoggerFactory.getLogger(TcpServer.class); private EventLoopGroup bossGroup; private EventLoopGroup workerGroup; private Channel serverChannel; Value(${netty.tcp.port:9000}) private int tcpPort; PostConstruct public void start() throws InterruptedException { bossGroup new NioEventLoopGroup(1); workerGroup new NioEventLoopGroup(); try { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new TcpChannelInitializer()); serverChannel bootstrap.bind(tcpPort).sync().channel(); log.info(TCP server started at port {}, tcpPort); } catch (Exception e) { log.error(TCP server start failed, e); shutdown(); throw e; } } PreDestroy public void shutdown() { if (serverChannel ! null) { serverChannel.close(); } if (bossGroup ! null) { bossGroup.shutdownGracefully(); } if (workerGroup ! null) { workerGroup.shutdownGracefully(); } log.info(TCP server stopped); } }几个关键配置我解释一下bossGroup线程数设成1就够了它只负责接受新连接然后把连接注册到workerGroup。真正处理读写的是workerGroup默认线程数是CPU核数的两倍。如果你的设备量大可以手动调大但一般默认值就够用。SO_BACKLOG是TCP连接等待队列的长度表示服务端在处理不过来时内核还能缓存的连接数。物联网设备批量上线时瞬间连接会很多这个值设大一点能避免握手失败。我一般设1024。TCP_NODELAY设为true是关闭Nagle算法保证小数据包能立即发送出去。物联网设备上报的数据很多都是几十字节的小包不开这个参数的话小包会被延迟合并增加指令延迟。SO_KEEPALIVE设为true开启TCP层面的心跳探测。这个参数的作用是让操作系统定期探测连接是否还活着如果对端已经异常关闭服务端能及时感知到。3.2 通道初始化器与编解码链TcpChannelInitializer的核心是定义ChannelPipeline里的Handler链。Netty的管道模型和Servlet的Filter链很像数据进来后按顺序经过各个Handler处理。Component public class TcpChannelInitializer extends ChannelInitializerSocketChannel { Autowired private TcpMessageHandler tcpMessageHandler; Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline ch.pipeline(); // 空闲检测5分钟没有读写就关闭连接 pipeline.addLast(new IdleStateHandler(300, 0, 0, TimeUnit.SECONDS)); // 自定义编解码器 pipeline.addLast(new MessageCodec()); // 业务处理器 pipeline.addLast(tcpMessageHandler); } }IdleStateHandler是Netty自带的心跳检测处理器。第一个参数是读空闲时间这里设的是300秒也就是说5分钟内这个连接没有任何数据进来就触发一次IdleStateEvent。这个事件会沿着管道往下传在业务Handler里处理如果是读空闲就关掉这条连接。这样做的好处是能及时清理那些已经死掉但没有正常断开的TCP连接避免僵尸连接占满服务器资源。MessageCodec是自定义的编解码器负责解决TCP粘包拆包问题。这个我下面单独说。注意我这里的TcpMessageHandler是用了Component注入到ChannelInitializer里的这种方式的巧妙之处在于Spring容器保证了TcpMessageHandler是单例的而Netty的Handler默认每个Channel一个实例。如果我用new TcpMessageHandler()的方式那么每次新连接都会创建一个新的Handler实例里面依赖的Spring Service比如DeviceService就没法注入。所以这里的重点是把Handler交给Spring管理然后在ChannelInitializer里通过注入拿到单例Bean再addLast到管道里。这里要注意TcpMessageHandler本身是无状态的所以多个Channel共享一个实例是安全的。3.3 TCP粘包拆包问题的处理TCP是流式协议数据在传输过程中没有边界。设备连续发送两条消息服务端可能一次就收到了整段数据也可能一次只收到半条消息这就是粘包和拆包。如果直接按收到的字节流去解析第一条消息处理完了后面可能还残留着第二条消息的部分数据。解决思路叫粘包拆包处理常见方案有四种固定长度、分隔符、长度字段、自定义协议帧。物联网项目里最常用的是自定义协议帧也就是在每个消息前面加一个长度字段。我举个实际例子。假设设备上报的数据帧格式是帧头(2字节) | 消息长度(2字节) | 消息类型(1字节) | 数据体( N字节 ) | 校验位(1字节)消息长度字段的值是消息类型长度数据体长度校验位长度的和也就是N 2。服务端解析时先读帧头确认这个包是不是我需要的再读长度字段知道消息体有多长然后继续读消息体。如果当前缓冲区里的数据不够一个完整消息就保留下来等下一次数据到达后再拼起来解析。Netty里有一个现成的类叫LengthFieldBasedFrameDecoder就是专门针对这种长度字段方案做拆包的pipeline.addLast(new LengthFieldBasedFrameDecoder( 1024 * 1024, // 最大帧长防止恶意数据撑爆内存 2, // 长度字段偏移量 2, // 长度字段长度 0, // 长度字段的调整值 0 // 丢弃的初始字节数 ));这个解码器会自动处理粘包拆包问题保证后面的Handler每次拿到的ByteBuf都是一个完整的数据帧。顺带提一句网上很多教程让你用LineBasedFrameDecoder或StringDecoder那是针对按换行符分隔的文本协议。物联网设备大多数是二进制协议用这种文本解码器没法适配。所以这里明确一点方案要跟着协议走不要因为某个解码器用起来简单就强行套用。3.4 业务处理器的核心逻辑TcpMessageHandler是真正的业务处理入口。我继承的是SimpleChannelInboundHandlerByteBuf它会在消息被完整接收后自动释放引用计数避免内存泄漏。Component ChannelHandler.Sharable public class TcpMessageHandler extends SimpleChannelInboundHandlerByteBuf { Autowired private DeviceService deviceService; Autowired private MessageDispatchService messageDispatchService; Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception { byte[] data new byte[msg.readableBytes()]; msg.readBytes(data); // 解析设备ID和消息内容 String deviceId parseDeviceId(data); // 记录设备在线状态绑定通道 deviceService.onDeviceOnline(deviceId, ctx.channel()); // 分发业务消息 messageDispatchService.dispatch(deviceId, data); } Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event (IdleStateEvent) evt; if (event.state() IdleState.READER_IDLE) { // 超过N秒没有收到数据判定设备离线 ctx.close(); } } else { super.userEventTriggered(ctx, evt); } } Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { // 连接断开清理设备在线状态 deviceService.onDeviceOffline(ctx.channel()); super.channelInactive(ctx); } Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { // 异常处理记录日志并关闭连接 ctx.close(); } }有几个设计细节说一下ChannelHandler.Sharable注解很关键。因为TcpMessageHandler是Spring单例会被多个Channel共享必须加上这个注解Netty才能允许多个Channel管道里注册同一个Handler实例。与此同时Handler内部绝不能持有某个设备特有的状态变量只能依赖Channel的Attribute或者外部的Service来维护状态。这是面试Netty经常考的线程安全问题。parseDeviceId这块要配合协议来做。一般设备上报的第一个字节或前几个字节就是设备ID方便服务端做通道绑定。设备上线时我把deviceId和Channel的关系记录下来这样后面指令下发时才能找到该往哪个Channel写数据。userEventTriggered是处理IdleStateEvent的入口。当读空闲超时这里判定设备已经失联主动关闭连接。接TCP还有一层逻辑要注意关闭连接前可以尝试主动发一次心跳探测如果还是没响应再关闭。不过大多数物联网协议里服务端主动发心跳的场景不多更多是设备定时上报数据所以读空闲超时直接断开是够用的。channelInactive里要做设备离线清理。这个的重要性在于如果连接异常断开时没有清理设备在线状态后面指令下发就会往一个已经无效的Channel里写数据导致大量报错。把这个清理逻辑统一放在channelInactive里配合TcpServer的优雅停机能确保无论正常还是异常断线在线状态都收拾干净。4. UDP通信服务端核心实现4.1 UDP服务端和TCP的本质差异UDP服务和TCP最大的区别在于TCP是有连接状态的数据收发建立在一条虚拟连接上UDP是面向无连接的数据报发出去之后就没有后续了接收端能不能收到发送端根本不知道。这就导致服务端的编写模式完全不同。TCP服务端要处理连接建立、连接维护、连接释放这些状态UDP服务端不用关心这些它只有一个固定的端口所有的数据报都从这个端口收发每个数据报里自带源IP和源端口服务端根据这个信息来回包。用Netty实现UDP服务端不需要ServerBootstrap也不需要NioServerSocketChannel。对应地用的是Bootstrap和NioDatagramChannel。4.2 UDP服务端核心代码Component public class UdpServer { private static final Logger log LoggerFactory.getLogger(UdpServer.class); private EventLoopGroup group; private Channel serverChannel; Value(${netty.udp.port:9001}) private int udpPort; PostConstruct public void start() throws InterruptedException { 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(new UdpChannelInitializer()); serverChannel bootstrap.bind(udpPort).sync().channel(); log.info(UDP server started at port {}, udpPort); } catch (Exception e) { log.error(UDP server start failed, e); shutdown(); throw e; } } PreDestroy public void shutdown() { if (serverChannel ! null) { serverChannel.close(); } if (group ! null) { group.shutdownGracefully(); } log.info(UDP server stopped); } }几个参数说明SO_BROADCAST允许接收广播消息。有些设备在局域网里是通过广播方式发现的如果你要支持设备自动发现这个参数必须打开。SO_RCVBUF是UDP接收缓冲区大小。UDP和TCP不一样TCP有协议栈的确认重传机制数据丢了可以重来。UDP不行缓冲区满了之后新的数据报直接丢弃而且发送端毫不知情。所以服务端要把接收缓冲区调大给上层业务处理留出更多时间窗口。这里我设的是1MB你可以根据实际情况调整。UdpChannelInitializer的代码和TCP类似不过要注意UDP的Channel不需要IdleStateHandler因为UDP本身就是无连接的也就没有所谓的心跳超时。你顶多是在业务层面判断某设备超过N秒没有上报数据就算离线但这个判断应该基于一个Map的更新时间戳去计算而不是靠网络层的连接状态。Component public class UdpChannelInitializer extends ChannelInitializerDatagramChannel { Autowired private UdpMessageHandler udpMessageHandler; Override protected void initChannel(DatagramChannel ch) { ChannelPipeline pipeline ch.pipeline(); pipeline.addLast(new MessageCodec()); pipeline.addLast(udpMessageHandler); } }4.3 UDP处理器的特别之处UDP处理器和TCP处理器有一个非常重要的区别TCP模式下每个设备对应一个固定Channel服务端可以根据设备ID找到Channel写数据UDP模式下所有设备共享一个Channel服务端要回复数据时必须携带设备的SocketAddressIP端口才能把数据报发回去。所以UDP处理器里第一个要做的就是把发送方的地址绑到设备ID上Component ChannelHandler.Sharable public class UdpMessageHandler extends SimpleChannelInboundHandlerDatagramPacket { Autowired private DeviceService deviceService; Autowired private MessageDispatchService messageDispatchService; Override protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket packet) throws Exception { ByteBuf content packet.content(); byte[] data new byte[content.readableBytes()]; content.readBytes(data); String deviceId parseDeviceId(data); SocketAddress sender packet.sender(); // 维护UDP设备的伪连接状态 deviceService.onUdpDeviceOnline(deviceId, ctx.channel(), sender); messageDispatchService.dispatch(deviceId, data); } }DeviceService里维护了一个ConcurrentHashMapString, UdpChannelInfo把设备ID映射到ctx.channel()和sender地址。回复指令时public void sendCommand(String deviceId, byte[] command) { UdpChannelInfo info udpDeviceChannels.get(deviceId); if (info ! null) { DatagramPacket packet new DatagramPacket( Unpooled.wrappedBuffer(command), info.getSender() ); info.getChannel().writeAndFlush(packet); } }这个伪连接的思路很重要因为UDP本身没有连接但业务层面我们依然需要知道这个设备是不是活的它的通讯地址是多少。通过维护这张映射表UDP服务端就具备了和TCP差不多的指令下发能力。另外注意一下UDP的数据报可能乱序到达也可能重复到达还可能出现半个数据报数据报被IP层分片后在接收端重组失败的情况。如果你的UDP协议设计里没有应用层序号和去重机制那么业务层要做好幂等处理避免重复消息导致数据重复入库。物联网场景里设备在弱网环境下的重传是很常见的这块不能想当然。5. 实操测试与性能调优5.1 本地联调的工具选择与用法服务端代码写完第一件事就是本地联调。很多新手在这一步卡很久其实工具选对了十分钟就能搞定。TCP客户端调试工具我推荐用NetAssist或者SockTool这两个都是图形化的网络调试助手支持TCP客户端、TCP服务端、UDP三种模式。连接上服务端的9000端口后可以直接发送十六进制数据或ASCII字符串。如果要模拟大量设备并发接入图形工具就不够了得用脚本。我平时会用Python写一个简单的压测脚本import socket import threading import time def connect_and_send(device_id): try: s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 9000)) # 模拟登录包 data bytearray() data b\xAA\x55 # 帧头 data (device_id).to_bytes(2, big) # 设备ID data b\x01 # 消息类型登录 s.send(data) time.sleep(1) # 模拟数据上报 data bytearray() data b\xAA\x55 data device_id.to_bytes(2, big) data b\x02 # 消息类型数据上报 data b\x00\x1A # 模拟温度数据 s.send(data) time.sleep(5) s.close() except Exception as e: print(fdevice {device_id} error: {e}) threads [] for i in range(1, 1001): t threading.Thread(targetconnect_and_send, args(i,)) threads.append(t) t.start() for t in threads: t.join()这个脚本可以快速验证服务器是否能扛住大量并发连接。如果你要打更高并发的压测建议用专门的压测工具比如wrk配合脚本或者JMeter。但真实的物联网设备接入压测我更推荐写脚本模拟因为可以精确控制每个设备的发包频率和消息内容。UDP调试相对简单一些图形工具直接用UDP模式填上服务器的IP和9001端口就能发数据。如果你想测试UDP服务端回包是否正常选UDP模式时注意勾选接收数据监听本地端口这样服务端回的数据才能到你的工具上。5.2 Netty服务端的几个关键调优参数服务端写完后还有几个Netty层面的参数值得仔细调优。这些参数直接影响高并发下的稳定性和资源占用。第一个是EventLoopGroup线程数的设置。我见过很多项目直接把NioEventLoopGroup()的默认线程数当作万能解。事实上bossGroup默认线程数就是机器CPU核数的2倍但对仅负责接收连接的boss来说1个线程绰绰有余。更重要的是workerGroup它承担了所有连接的IO读写和业务Handler执行。如果你在Handler里做了比较耗时的业务逻辑比如查数据库、调外部接口那workerGroup的线程很快就会被占满导致IO事件处理延迟。我的做法是workerGroup线程数设为CPU核数的2倍业务上耗时操作全部丢到独立的业务线程池里去执行Handler里只做解析和分发。这样做能避免业务阻塞IO线程。第二个是WRITE_BUFFER_WATER_MARK水位线。当设备处理速度跟不上服务端的下发速度时写入的数据会堆积在Channel的写缓冲区里。如果这个缓冲区无限增长内存会被耗尽。Netty通过WRITE_BUFFER_WATER_MARK参数来控制写缓冲区的上下限bootstrap.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(64 * 1024, 256 * 1024));当缓冲数据超过256KB时Channel会变成不可写状态业务层通过channel.isWritable()判断暂停给该设备下发数据当积压数据下降到64KB以下时恢复写入。这个机制类似TCP的流量控制是保护内存的生命线。第三个是ALLOCATOR内存分配器。Netty默认在堆外内存分配缓冲区PooledByteBufAllocator是高性能场景的首选它通过对象池复用ByteBuf减少频繁分配回收带来的GC压力。如果你的项目没有特殊需求保持默认即可但要注意在启动参数里加上-Dio.netty.allocator.typepooled显式指定。5.3 部署到服务器后的连通性验证部署到Linux服务器之后很多同学会遇到本地明明是通的服务器上就是连不上的问题。这里我分享一套排查方法。第一步检查服务是否正常监听端口netstat -tunlp | grep -E 9000|9001如果看到TCP和UDP的服务都在监听说明进程起来了。如果只有TCP监听检查UDP的Port是否被防火墙拦截。第二步检查防火墙策略。阿里云、腾讯云这些云厂商的安全组除了系统本身的iptables还有一层云安全组。我踩过坑系统防火墙没关安全组没放行结果UDP端口怎么调都连不通。两个地方都要放行。第三步本地用IP直连测试。在本地开发机上用TCP调试工具直接连服务器的公网IP:9000抓包看看连接是否建立。如果连接建立后马上断开大概率是网络层没问题问题出在服务端处理逻辑上看日志定位。如果你在Windows上用WSL2做开发连本机的Linux子系统时注意WSL2默认的网络模式是NATWSL2内的进程绑定端口后Windows宿主要访问WSL2里的服务通常需要走localhost转发但WSL2里的UDP广播通信有时候会因为NAT问题失败。遇到这种情况优先检查WSL2的网络模式和防火墙配置。6. 常见问题与排查技巧实录6.1 TCP连接频繁断开问题这个问题的表现是设备连上服务器后过一段时间就断或者一收到消息就断。排查思路分三步。第一步查服务端日志里是否有异常。最常见的异常是TooLongFrameException说明你的LengthFieldBasedFrameDecoder设的最大帧长太小设备发了一条超长的消息被解码器判定为非法帧直接断开。解决办法是把最大帧长调大比如从1MB调到4MB同时确认设备的协议是不是真的有这么大包。第二步查是不是空闲超时误杀。如果设备本身设计就是只在有事件时上报平时保持静默而你的IdleStateHandler设置的读空闲又太短就会把正常设备当死连接踢掉。我遇到过一个项目设备每5分钟上报一次心跳服务端却设了3分钟读空闲结果设备刚连上没几分钟就被断开迟迟找不到原因。后来在日志里看到ReadTimeoutException才反应过来。处理方案有两种把空闲时间调到比设备最大静默时间长或者改成在服务端收到任何数据时都刷新活跃时间而不是单纯依赖读空闲。第三步查是不是服务器资源耗尽。用ss -s看看当前TCP连接数如果超过了ulimit -n限制新连接会被拒绝已建立的连接也可能异常。原因是文件描述符不够用。解决办法是调大ulimit -n例如设置成65535以上同时调整内核参数net.core.somaxconn来增加accept队列长度。6.2 UDP数据报频繁丢失UDP丢包的原因比TCP多常见的几种情况我都遇到过。接收缓冲区太小是首要原因。Linux的UDP接收缓冲区默认值很小通常只有几十KB如果服务端处理数据的速度跟不上到达速率新来的数据报直接进不了内核缓冲区就被丢弃了。可以用sorecvbuf动态调整或者在服务端启动时就用SO_RCVBUF参数调大缓冲区。但是注意SO_RCVBUF最大不能超过内核参数net.core.rmem_max的限值如果发现设了没用先检查这个内核参数。业务处理太慢导致粘滞。如果你在Netty的Handler里直接做了耗时操作比如往数据库同步写数据那EventLoop线程就被阻塞了新的UDP数据报无法被及时处理缓冲区很快溢出。解决办法必须是把耗时操作丢到独立业务线程池避免阻塞IO线程。这也是为什么我前面强调MessageDispatchService内部要用线程池的原因。网卡丢包。用netstat -s可以查看UDP层的丢包统计如果packet receive errors在持续增长说明网卡驱动或者内核协议栈这层就有问题。这种情况多发生在高PPS每秒数据包数的场景。解决办法是开启网卡多队列让内核把数据包分发到多个CPU核上处理配合Netty的多EventLoop线程整体吞吐能明显提升。6.3 服务器端口被占用或无法绑定如果你在重启服务时发现端口被占用报错一般是BindException: Address already in use: bind原因通常是上一次运行的服务没有完全退出或者端口处于TIME_WAIT状态。对于TCP端口用netstat -tunlp | grep 9000找到占用进程杀掉重启。对于UDP端口情况更隐蔽因为有些服务会把TCP和UDP都绑在同一端口号上你在查TCP端口列表时看不到UDP占用需要用netstat -unlp | grep 9001查看。还有一个细节如果服务端连接大量设备后你把它关掉再立即重启会发现TIME_WAIT状态的连接占着端口。TCP四次挥手的主动关闭方会进入TIME_WAIT状态等一段时间才能释放。解决方法是设置SO_REUSEADDRbootstrap.option(ChannelOption.SO_REUSEADDR, true)这是Netty推荐的做法允许服务端在TIME_WAIT状态下复用端口。我在本地开发时经常快速重启服务不开这个参数会非常痛苦。6.4 Netty内存泄漏排查内存泄漏是Netty开发里最容易踩、也最难查的问题。Netty自带了一个内存泄漏检测器可以通过启动参数开启-Dio.netty.leakDetectionLeveladvanced检测级别有disabled、simple、advanced、paranoid。平时开发用advanced级别就够了它会记录到具体分配内存的堆栈信息。如果在日志里看到LEAK: ByteBuf.release() was not called before its garbage-collected说明有ByteBuf没有正确释放。我的排查经验是先看我有没有在Handler里手动处理ByteBuf。Netty的SimpleChannelInboundHandler会自动释放传入的消息这一点是安全的。但如果我在代码里用Unpooled.wrappedBuffer创建了新的ByteBuf或者从Channel的alloc()分配了临时缓冲区那就必须自己负责release()。用try-finally包裹确保释放。另外如果你用了RetainedDuplicate()或者slice()这些操作会增加引用计数必须要对应地release()否则就会泄漏。还有一类泄漏不是ByteBuf的引用计数问题而是Channel注册监听器时没有解绑。比如在Handler里给一个Future添加了Listener如果这个Future完全不可能结束Listener就会一直挂在EventLoop上造成积累。虽然是内存泄漏但表现起来更像内存缓慢增长比较难察觉。用jstat观察老年代持续增长、GC频率持续上升时优先怀疑这块。6.5 关于SpringBoot启动时Netty和Web容器端口冲突最后再说一个容易被忽略的坑。如果你的项目里既用了SpringBoot Web模块又用Netty做自定义服务端SpringBoot默认会占用8080端口Netty的端口是你在application.yml里自己定义的一般不会冲突。但如果你图省事把Netty的端口也设成8080启动时会直接报BindException。还有一点SpringBoot 3.x的Web应用默认基于Netty实现如果你又自己创建了Netty的EventLoopGroup等于在同一个JVM里跑了两套Netty它们的线程池互相独立一般不会互相影响但排查问题时要注意你看到的Netty日志是来自Web容器还是自定义服务端别搞混了。我见过有同学把Web容器的Netty日志当成自定义TCP服务的报错来排查绕了一大圈。如果产品形态是纯通信服务端不需要提供HTTP接口我建议直接把spring-boot-starter-web去掉只用SpringBoot的IOC和配置功能。这样既能减少依赖也不用担心端口冲突和两套Netty并存的问题。需要HTTP接口的话考虑用轻量的spring-boot-starter-webflux但要注意WebFlux底层的Netty和你的自定义Netty会共享一些类加载环境务必确认版本一致。7. 关于这个方案的进一步扩展项目跑通之后你可以沿着下面几个方向继续扩展。接入设备认证与权限校验。当前方案里只要是能连上端口的设备服务器就认为是合法设备。真实场景中肯定不行。至少要在设备接入时做密钥校验甚至做指令级鉴权。可以基于Channel.attr存储设备的安全上下文在每个消息进来时校验签名。增加二进制协议编解码框架的封装。如果设备消息类型很多建议把编解码器按消息类型拆分成多个Handler类似一个迷你协议栈。Netty的ByteToMessageDecoder里可以用switch语句按消息类型分发到不同业务Handler也可以基于Pipeline动态增删Handler。对接消息中间件如Kafka、RocketMQ。设备上报的数据不需要全部同步入库可以先转发到消息队列由消费者异步处理。这样Netty服务端把精力集中在高并发接入和数据转发上数据落库、告警计算这些重活交给下游消费者服务端的吞吐上限会高出很多。考虑前端接入协议的统一网关化。如果你的项目后期要支持多种协议MQTT、CoAP、HTTP等可以考虑把所有协议接入层收敛到一个网关服务里Netty的handler负责统一输出内部消息格式再由路由模块分发到不同业务服务。这个架构演进方向是物联网平台从单体走向微服务的一个必经阶段。8. 最后再分享几个小技巧第一调试Netty的时候尽量先确认网络层的可行性再去看代码。我的习惯是先用tcpdump抓包看连接是否正常建立三次握手是否完成然后再判断是编解码问题还是业务处理问题。不然花半天时间查代码结果发现是防火墙把包拦了纯属浪费时间。第二不要过度设计。很多新手一上来就想搞分布式集群、多机热备、消息可靠性保障结果连最基本的单机服务端都没跑稳。先把单机版本的TCP/UDP接入跑通把设备在线管理、心跳检测、指令下发这些基础能力做扎实再逐步演进架构。技术方案演进跟不上业务需求没关系一轮一轮迭代是常态。第三善用Netty自带的LoggingHandler。在编解码器之前加一个LoggingHandler(LogLevel.DEBUG)可以清晰看到每个Channel收到的原始字节流排查协议问题时非常有用。上线前记得把它去掉或者在配置里设置成生产环境关闭不然日志量会有点大。第四写单元测试的时候先不用急着上SpringBootTest。直接用Netty的EmbeddedChannel做Handler层的单测就可以又快又准。把编解码器和业务Handler拆成一个独立类输入一个ByteBuf看输出的业务对象是否符合预期。这样很多协议bug在开发阶段就能暴露比到了联调阶段再去排查效率高太多。我做了那么多年物联网服务端最大的体会是网络通信这块80%的时间不是在写业务代码而是在跟网络的不确定性打交道。Netty帮你把底层的多路复用、线程模型这些问题解决了但协议设计、心跳策略、异常处理、性能调优这些问题还是得靠实际踩坑才能积累出经验。希望这篇实战笔记能帮你少走点弯路。
返回列表