尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

SpringBoot整合Netty实现高性能WebSocket:粘包心跳与避坑指南

发布时间:2026/9/24 19:13:57

资讯中心
01
ARTICLE

SpringBoot整合Netty实现高性能WebSocket:粘包心跳与避坑指南

SpringBoot整合Netty实现高性能WebSocket:粘包心跳与避坑指南
1. 为什么我放弃了Spring原生WebSocket转头上了Netty先交代一下背景。我这边有个项目前期用的是SpringBoot自带的WebSocket基于WebSocketHandler和STOMP那套单机几百个连接的时候一切正常。等业务量上来在线连接冲到两三千问题就开始冒头了时不时有连接假死、心跳超时误判、偶尔出现消息延迟更离谱的是Tomcat容器偶尔直接报线程池耗尽。排查了一圈根子不在业务代码而在协议栈和线程模型上。Spring的WebSocket底层还是走Servlet容器那一套一个连接一个请求处理的思路长连接场景下线程占用非常不划算。后来我把WebSocket这块整个切到了Netty连接数到一万级别依然稳定。今天这篇就把整个接入过程、代码骨架、以及我当时踩过的坑完整写一遍包含粘包拆包、心跳保活、Nginx反代这些生产环境绕不开的细节。“三分钟构建”不是说三分钟把所有业务写完而是指接入链路足够清晰从零到跑通一条可用连接整个核心代码量控制在一个合理范围内剩下的时间都花在业务处理和调优上。如果你正打算在SpringBoot项目里上WebSocket又担心原生方案撑不住以后的高并发这篇应该能帮你少走不少弯路。适用人群分两类一类是刚接触Netty、想在SpringBoot里跑通WebSocket的开发者另一类是已经在用原生WebSocket、但被连接数、稳定性问题折腾过的人。前者重点看第二章和第三章的代码骨架后者重点看第四章和第五章的踩坑记录与保活策略。2. 方案选型不纠结Netty到底解决了原生方案的哪些问题很多人在选型时会犹豫SpringBoot官方就支持WebSocket为什么要额外引入Netty这里我不打算只给结论把两者在真实运行时的差异拆开讲。2.1 原生WebSocket的瓶颈不在协议在线程模型Spring的原生WebSocket实现包括STOMP本质上是建立在Servlet容器之上的。Servlet 3.1之后虽然支持异步处理但长连接场景下每个WebSocket连接依然对应着一个独立的“逻辑处理单元”高并发时容器线程池很容易成为瓶颈。用大白话说Tomcat处理普通HTTP请求是“来一个请求、分配一个线程、处理完归还”这种模式对短连接没问题。但WebSocket是长连接连接建立后通道一直占用着如果线程模型设计不合理大量空闲连接也会把线程池资源耗尽导致正常的HTTP请求都进不来。我遇到的具体现象就是在线连接数到一定量级后登录接口、业务查询接口这些普通HTTP请求开始变慢甚至偶发超时。压测时看监控容器线程池活跃数长期处于高位。这就是典型的“长连接吃掉了短连接的资源”。2.2 Netty的NIO模型为什么适合长连接场景Netty基于NIO非阻塞I/O事件驱动模型核心是EventLoop线程组。它和Servlet容器的关键差异在于一个EventLoop线程可以同时管理成千上万个Channel连接而不是一个连接一个线程。连接上的读写事件来了才触发处理没有事件时线程可以去处理其他连接的事件。这就好比餐厅服务员原生方案是一桌客人配一个专属服务员哪怕客人只是坐着聊天服务员也得在旁边候着Netty是一组服务员轮流巡视所有桌子只有客人真正举手示意有事件时才过去服务。同样的人力后者能服务的桌数要远多于前者。这也是为什么高并发长连接场景下Netty的线程占用更少、支撑的连接数更多。WebSocket协议本身在传输层就是基于TCP的而Netty在TCP层面做了大量优化比如零拷贝、内存池、更细粒度的读写控制这些对追求极致性能的场景都非常有价值。2.3 方案边界什么情况下其实不必上Netty这里说点实话不是所有项目都需要上Netty。如果你的业务是内部管理系统、运维后台在线连接数长期只有几十到几百用Spring原生WebSocket完全够用引入Netty反而增加维护成本——毕竟多了一套线程模型、多了一种协议处理方式团队学习成本是实打实的。我的判断标准很简单判断维度适用原生WebSocket适用SpringBoot Netty在线连接数百级以内千级以上或预期快速增长消息频率低频秒级心跳、少量推送高频毫秒级推送、实时交互线程模型要求无特殊要求需要控制线程数、支撑海量连接协议定制需求无标准文本/二进制帧即可需要拆包、粘包处理、自定义帧协商团队技术储备Spring技术栈熟练愿意投入少量时间学习Netty核心概念我自己现在的做法是核心消息网关用Netty周边简单工具页面、内部通知类的小服务用Spring原生WebSocket怎么省事怎么来。技术选型没有绝对的对错只有合适不合适。3. 三分钟快速搭建完整的SpringBoot Netty接口骨架下面进入正题直接给可复制的代码。我用的环境是SpringBoot 2.7.x Netty 4.1.x这两个版本是目前生产环境里最稳的组合JDK 8及以上都能跑。3.1 Maven依赖引入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdio.netty/groupId artifactIdnetty-all/artifactId version4.1.100.Final/version /dependency很多教程会建议直接在SpringBoot项目里引入netty-all实际开发中也可以按需引入netty-transport、netty-codec-http、netty-handler等模块。不过netty-all省心不差那点依赖体积的话直接用也未尝不可。3.2 Netty服务启动类先定义一个组件负责在SpringBoot启动完成后拉起Netty服务端。这里要注意的是不能直接在SpringBoot的main方法里启动Netty因为那会导致Netty和Spring容器的生命周期不一致。我用的是ApplicationRunnerSpring容器初始化完成后自动执行。Component public class NettyWebSocketServer implements ApplicationRunner { private static final Logger log LoggerFactory.getLogger(NettyWebSocketServer.class); private EventLoopGroup bossGroup; private EventLoopGroup workerGroup; private Channel serverChannel; private final WebSocketServerInitializer initializer; public NettyWebSocketServer(WebSocketServerInitializer initializer) { this.initializer initializer; } Override public void run(ApplicationArguments args) { 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(initializer); int port 8899; ChannelFuture future bootstrap.bind(port).sync(); serverChannel future.channel(); log.info(Netty WebSocket服务启动成功端口{}, port); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(Netty WebSocket服务启动失败, e); } } PreDestroy public void destroy() { if (serverChannel ! null) { serverChannel.close(); } if (bossGroup ! null) { bossGroup.shutdownGracefully(); } if (workerGroup ! null) { workerGroup.shutdownGracefully(); } } }这里有几个参数值得解释一下。bossGroup线程数设为1就够了因为它只负责接受新的连接请求把连接注册到workerGroup。workerGroup不传线程数时默认是CPU核数的两倍这个配置在绝大多数场景下都是合理的。TCP_NODELAY设为true是为了禁用Nagle算法避免小数据包被延迟合并发送——WebSocket是实时交互协议延迟比带宽更敏感。顺带说一句关于SpringBoot版本的问题很多人在热搜里提到“SpringBoot版本太高不能使用JDK1.8”。这是因为SpringBoot 3.x开始强制要求JDK 17如果你的生产环境还在用JDK 8那最好固定使用SpringBoot 2.7.x版本反过来如果你已经用JDK 17那可以用SpringBoot 3.xNetty本身对JDK版本没有强限制兼容性反而比SpringBoot更宽。我这边生产环境是从SpringBoot 2.x起步的后续要升级到3.x也得先把JDK升上去这条链路建议在项目初期就想清楚。3.3 ChannelInitializerPipeline里各Handler的装配顺序Netty里最核心的概念之一就是每个连接的Pipeline也就是责任链。Handler的添加顺序很关键因为数据帧在链路上是顺序流过的。Component public class WebSocketServerInitializer extends ChannelInitializerSocketChannel { private final WebSocketFrameHandler webSocketFrameHandler; public WebSocketServerInitializer(WebSocketFrameHandler webSocketFrameHandler) { this.webSocketFrameHandler webSocketFrameHandler; } Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline ch.pipeline(); // 1. 粘包/半包处理 pipeline.addLast(new LengthFieldBasedFrameDecoder(1024 * 1024, 0, 4, 0, 4)); // 2. HTTP协议编解码 pipeline.addLast(new HttpServerCodec()); // 3. 聚合HTTP请求 pipeline.addLast(new HttpObjectAggregator(65536)); // 4. WebSocket协议升级 pipeline.addLast(new WebSocketServerProtocolHandler(/ws)); // 5. 业务处理 pipeline.addLast(webSocketFrameHandler); } }注意这里我在HttpServerCodec之前加了一个LengthFieldBasedFrameDecoder这是为了处理粘包/半包问题。这个话题比较重要第三章会专门展开。需要先说明的是WebSocket协议本身已经有帧边界了但底层的TCP流是字节流如果前置的编解码器不能正确识别帧边界就会把多个帧混在一起交给后面的处理器导致解析出错。这个坑很隐蔽很多人写了半天总发现消息对不上最后定位到就是这一步的问题。WebSocketServerProtocolHandler(/ws)表示WebSocket的路径是/ws客户端连接时要连ws://ip:8899/ws。如果你要支持多个路径就需要多注册几个Handler或者自己在业务Handler里做路由判断。3.4 业务Handler接收消息、推送消息、连接管理这个是核心处理类实现了连接建立、消息收发、连接关闭的主要逻辑。Component ChannelHandler.Sharable public class WebSocketFrameHandler extends SimpleChannelInboundHandlerWebSocketFrame { private static final Logger log LoggerFactory.getLogger(WebSocketFrameHandler.class); /** * 管理所有在线连接concurrent包做线程安全 */ private static final ChannelGroup onlineChannels new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); /** * 记录ChannelId和用户ID的映射方便定向推送 */ private static final ConcurrentHashMapString, String userChannelMap new ConcurrentHashMap(); Override public void channelActive(ChannelHandlerContext ctx) { onlineChannels.add(ctx.channel()); log.info(连接建立{}当前在线连接数{}, ctx.channel().remoteAddress(), onlineChannels.size()); } Override public void channelInactive(ChannelHandlerContext ctx) { onlineChannels.remove(ctx.channel()); userChannelMap.entrySet().removeIf(entry - entry.getValue().equals(ctx.channel().id().asLongText())); log.info(连接断开{}当前在线连接数{}, ctx.channel().remoteAddress(), onlineChannels.size()); } Override protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) { // 关闭帧 if (frame instanceof CloseWebSocketFrame) { ctx.close(); return; } // Ping/Pong帧 if (frame instanceof PingWebSocketFrame) { ctx.channel().writeAndFlush(new PongWebSocketFrame(frame.content().retain())); return; } if (frame instanceof TextWebSocketFrame) { String message ((TextWebSocketFrame) frame).text(); log.info(收到消息{}, message); // 这里就可以根据消息内容做业务分发了 // 例如用户认证、指令下发、广播等 handleTextMessage(ctx, message); return; } if (frame instanceof BinaryWebSocketFrame) { // 处理二进制数据 log.info(收到二进制数据长度{}, frame.content().readableBytes()); return; } } private void handleTextMessage(ChannelHandlerContext ctx, String message) { // 一个简单的消息协议例如{type:auth,userId:10001} try { JSONObject json JSON.parseObject(message); String type json.getString(type); if (auth.equals(type)) { String userId json.getString(userId); userChannelMap.put(userId, ctx.channel().id().asLongText()); ctx.channel().writeAndFlush(new TextWebSocketFrame(认证成功)); } } catch (Exception e) { ctx.channel().writeAndFlush(new TextWebSocketFrame(消息格式错误)); } } Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { log.error(WebSocket连接异常, cause); ctx.close(); } /** * 向指定用户推送消息 */ public static boolean sendToUser(String userId, String message) { String channelId userChannelMap.get(userId); if (channelId null) { return false; } for (Channel channel : onlineChannels) { if (channel.id().asLongText().equals(channelId)) { channel.writeAndFlush(new TextWebSocketFrame(message)); return true; } } return false; } /** * 广播消息给所有在线连接 */ public static void broadcast(String message) { onlineChannels.writeAndFlush(new TextWebSocketFrame(message)); } }这里使用了ChannelGroup来管理在线连接好处是它的writeAndFlush可以批量向所有Channel发消息而且线程安全。SimpleChannelInboundHandler会自动释放ReferenceCounted对象所以处理完消息后不需要手动release省去不少内存泄漏隐患。还有一个细节ChannelHandler.Sharable这个注解一定要加因为我把Handler作为Spring单例Bean放进了Pipeline多个Channel共享同一个Handler实例。如果你不确信自己写的是无状态的那就别加这个注解每次初始化时new一个Handler更保险。我的这个Handler里所有状态都在static字段上所以是安全的。3.5 运行效果验证把项目跑起来控制台会输出Netty启动成功日志。再用Postman连一下测试链路是否通。Postman从某个版本开始就原生支持WebSocket请求了打开Postman左侧选择New然后选WebSocket Request输入ws://localhost:8899/ws点击Connect。连接成功后在Message输入框输入任意文本点击Send应该能收到服务端返回的消息。断开连接时控制台会打印连接断开日志在线连接数同步减少。这个操作流程也对应了热搜里“postman websocket连接”这个需求实测下来Postman对WebSocket的调试支持足够日常开发用了不必专门找WebSocket调试工具。4. 粘包与拆包长连接里最容易翻车的环节既然搜热词里专门有“netty粘包处理”这块我必须单独拿出来详细讲。它也是新手从HTTP转到Netty后最先遇到的坑没有之一。4.1 粘包到底是怎么产生的先说结论TCP本身就是字节流协议它不关心你上层发的是几条消息。TCP为了保证传输效率会根据MSS最大报文段大小和当前网络缓冲区情况把多个小数据包合并成一个TCP报文发送或者把一个大数据包拆成多个TCP报文发送。这就是“粘包”和“半包”的来源。粘包发送方发送了两条消息hello和world接收方一次读到了helloworld无法确定消息边界。 半包发送方发送了一条很长的消息接收方一次只读到了前半段需要等后续数据到达才能凑齐一条完整消息。WebSocket协议号称自带消息边界为什么还会遇到粘包因为WebSocket的帧边界信息是在帧头Frame Header里声明的如果解码器不能正确读取帧头信息或者干脆把WebSocket帧当成裸TCP数据流来处理就会出错。更常见的是在加WebSocketServerProtocolHandler之前如果前面没有正确的拆包器多个WebSocket帧的数据会先后到达后面的处理器无法区分帧边界。4.2 四种常见拆包器的选择Netty提供了多种拆包器适合不同的场景拆包器原理适用场景注意点LineBasedFrameDecoder按换行符\n或\r\n拆包文本协议每条消息以换行结束消息内容不能包含换行符DelimiterBasedFrameDecoder按自定义分隔符拆包自定义文本协议分隔符不能出现在业务数据内FixedLengthFrameDecoder按固定长度拆包消息定长不易扩展不推荐业务用LengthFieldBasedFrameDecoder按长度字段拆包二进制协议、混合协议参数配置较复杂但最通用WebSocket场景下最稳的方案是在HTTP协议处理器前加LengthFieldBasedFrameDecoder并且正确配置长度字段的位置。但要注意WebSocket的帧头和普通的TCP长度字段协议不一样如果只是加一个LengthFieldBasedFrameDecoder而不告诉它WebSocket帧的头部结构那拆出来的仍然会是乱的。更准确的做法是如果你确定走的是WebSocket协议升级可以在升级完成后依靠WebSocketServerProtocolHandler来维护WebSocket帧的边界这个Handler内部实现了对WebSocket帧的自动拆分不需要额外处理。但问题在于在握手之前连接还是普通的HTTP连接此时如果数据帧到达必须先经过HTTP解码器而这个阶段的粘包处理需要HttpObjectAggregator配合。我个人的实践中最省心的排列方式是pipeline.addLast(new HttpServerCodec()); pipeline.addLast(new HttpObjectAggregator(65536)); pipeline.addLast(new WebSocketServerProtocolHandler(/ws));在这个顺序下HttpObjectAggregator会聚合HTTP请求和升级握手请求WebSocketServerProtocolHandler在升级完成后接管帧解析。如果业务消息需要走自定义二进制协议或者想绕开WebSocket协议、直接做TCP长连接这个时候LengthFieldBasedFrameDecoder才会成为主力。4.3 一个真实现场的粘包复现我之前压测时遇到过这个情况客户端大批量推送消息服务端偶尔出现解析出来的消息是两条拼在一起的现象。当时监控看错误日志发现收到的消息末尾会莫名多出一段上一个消息的内容。这就是典型的粘包。定位时打了不少日志发现发生粘包的位置集中在连接刚建立的瞬间或者消息体较大的场景。原因很简单发送方在两毫秒内连发了多条消息TCP层的Nagle算法和接收方的缓冲区机制把这些消息合并成了同一个TCP报文。如果解码器没有按帧边界正确切分业务层拿到的就是“连体婴儿”。解决思路有两种第一种如果是WebSocket协议确保WebSocketServerProtocolHandler之后不要再加容易破坏帧边界的Handler并确认业务Handler直接接收的是WebSocketFrame子类。第二种如果是自己定义的二进制协议用LengthFieldBasedFrameDecoder明确告诉Netty前4个字节是消息总长度收到这个长度后再交给后续Handler。配置代码如下pipeline.addLast(new LengthFieldBasedFrameDecoder(1024 * 1024, 0, 4, 0, 4));这里的参数含义是最大帧长度为1MB长度字段偏移量为0长度字段长度为4字节长度字段后续还有0字节的头部数据数据长度加上头部的调整量为4字节即前4个字节代表后续数据的长度。这四个参数如果不对会直接导致解析错乱建议对照你自己的协议头结构写清楚注释。4.4 排查粘包问题的完整思路万一你线上还是出现了粘包问题排查路径按照这个顺序走能少走弯路先确认协议版本和Handler顺序把Pipeline里每个Handler的职责用注释标出来。查看日志确定触发粘包时客户端连发了几条消息、间隔是多大。在拆包器前后各加日志打印ByteBuf的可读字节数和读取的内容确认拆包器是否正常工作。用小包连发、大包单发、大包连发三组用例分别测试看问题出现在哪一类。确认Content-Length头是否正确HTTP阶段或WebSocket帧长度字段是否正确升级后阶段。我之前就是靠第3步找到问题的日志显示拆包器输出的一帧里包含了两个业务消息说明拆包器根本没工作后来发现是Pipeline里加的顺序不对LengthFieldBasedFrameDecoder加在了HttpServerCodec之后它拿到的数据已经被HTTP解码器吃掉了。5. 心跳保活与断线重连长连接稳定性的基石WebSocket服务上线后第二个高频问题就是连接“假死”——客户端以为还连着服务端也还不知道连接已经断了但消息发过去没有任何响应。这通常是网络中任何一环尤其是Nginx或云负载均衡把空闲连接静默关掉了。要解决这个问题必须做心跳保活。5.1 服务端空闲检测机制Netty里最常用的心跳方案是IdleStateHandler它可以在连接空闲超过指定时间后触发事件。我在Pipeline里加了一个专门负责心跳检测的Handler放在业务Handler的最前面。public class HeartbeatHandler extends ChannelInboundHandlerAdapter { private static final Logger log LoggerFactory.getLogger(HeartbeatHandler.class); /** 配置参数读空闲时间、写空闲时间、总空闲时间 */ private static final int READER_IDLE_TIME 60; private static final int WRITER_IDLE_TIME 30; private static final int ALL_IDLE_TIME 0; Override public void handlerAdded(ChannelHandlerContext ctx) { ctx.pipeline().addFirst(new IdleStateHandler(READER_IDLE_TIME, WRITER_IDLE_TIME, ALL_IDLE_TIME, TimeUnit.SECONDS)); } Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event (IdleStateEvent) evt; if (event.state() IdleState.READER_IDLE) { // 读空闲客户端超过60秒没发任何数据主动断开 log.info({} 读空闲超时关闭连接, ctx.channel().remoteAddress()); ctx.close(); } else if (event.state() IdleState.WRITER_IDLE) { // 写空闲向客户端发送心跳包 ctx.channel().writeAndFlush(new TextWebSocketFrame({\type\:\heartbeat\})); } } super.userEventTriggered(ctx, evt); } }这里的设计思路是服务端每30秒主动发一次心跳写空闲触发如果连续60秒没有读到客户端任何数据读空闲触发就认为客户端已经不活跃主动关闭连接。这样的好处是能及时清理死连接避免连接数被无效连接慢慢占满。5.2 客户端的断线重连策略服务端只是做被动清理真正保证连接稳定的是客户端要有断线重连能力。很多前端方案里WebSocket对象一断就什么都不管了用户必须刷新页面才能恢复体验很差。前端的断线重连建议采用“指数退避”策略第一次失败后等1秒再重连第二次等2秒第三次等4秒以此类推到最大间隔后封顶。并且要加上随机抖动防止大量客户端同时重连造成服务端瞬间压力过大。一个简化版的前端重连逻辑let reconnectAttempts 0; const MAX_RECONNECT_DELAY 30000; // 最大30秒 const BASE_DELAY 1000; function connectWebSocket() { const ws new WebSocket(ws://localhost:8899/ws); ws.onopen () { reconnectAttempts 0; console.log(WebSocket连接已建立); // 连接成功后开始心跳 startHeartbeat(); }; ws.onclose () { clearHeartbeat(); const delay Math.min(BASE_DELAY * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY) Math.random() * 1000; reconnectAttempts; console.log(连接断开${delay}ms后重连); setTimeout(connectWebSocket, delay); }; ws.onerror (err) { console.error(WebSocket错误, err); ws.close(); }; } // 客户端心跳每25秒发送一次比服务端的30秒略短确保连接活跃 function startHeartbeat() { window.heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send({type:heartbeat}); } }, 25000); }这个策略在真实项目里很管用。客户端心跳间隔比服务端空闲阈值略短能保证连接在服务端判定超时之前就有数据流动避免被误杀。服务端收到心跳消息后可以回一个pong消息也可以不回——因为收到消息这个动作本身就刷新了读空闲检测的计时。5.3 服务端踢人策略除了心跳还有一类场景同一账号在多个终端登录或者恶意客户端建立了一堆连接不干正事。这时候服务端需要有主动踢人的能力。我的做法是在认证通过后建立userId - Channel的映射如果同一个userId已经有映射就主动关闭旧连接让新连接接管。这里要注意并发问题两个连接同时认证同一个userId可能互相踢所以需要用ConcurrentHashMap的putIfAbsent或者加锁来保证原子性。简化逻辑如下String oldChannelId userChannelMap.put(userId, ctx.channel().id().asLongText()); if (oldChannelId ! null !oldChannelId.equals(ctx.channel().id().asLongText())) { for (Channel ch : onlineChannels) { if (ch.id().asLongText().equals(oldChannelId)) { ch.writeAndFlush(new TextWebSocketFrame({\type\:\kick\,\reason\:\account_login_elsewhere\})); ch.close(); break; } } }这个机制还有个好处配合断线重连客户端重连时旧连接会被自动清理不会出现“连接残留”导致消息发给一个已经废弃的Channel。6. Nginx反代、压测数据与生产环境避坑指南WebSocket在生产环境通常不会让客户端直连应用服务前面会挡一层Nginx做负载均衡和域名分发。但Nginx默认配置不支持WebSocket必须显式开启升级头。6.1 Nginx配置WebSocket的关键项map $http_upgrade $connection_upgrade { default upgrade; close; } upstream websocket_backend { server 127.0.0.1:8899; # 如果有多台服务可以加权重 # server 127.0.0.1:8900 weight2; keepalive 32; } server { listen 80; server_name ws.example.com; location /ws { proxy_pass http://websocket_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }map块的两行配置很关键它告诉Nginx如果客户端请求头里有Upgrade: websocket就把Connection头设为upgrade否则保持默认的close。proxy_read_timeout默认是60秒如果不改WebSocket长连接空闲超过60秒就会被Nginx掐断——很多开发环境连得好好的一上Nginx就频繁掉线十有八九就是这个超时时间没调大。我这边把超时设为3600秒1小时实际运营中客户端和服务端有心跳保活Nginx层面的空闲其实很少发生但设长一点可以避免极端情况下的误断。6.2 压测结果对比我用同一台4核8G的云服务器做过一轮简单压测分别压Spring原生WebSocket和SpringBoot Netty方案。压测工具是JMeter加WebSocket插件模拟客户端持续连接和发送消息。结果数据因为测试环境有波动但趋势非常一致指标Spring原生WebSocketSpringBoot Netty5000并发连接内存占用持续上涨部分连接超时稳定运行内存平稳10000并发连接连接大量失败服务日志报线程池满稳定运行CPU约40%消息推送延迟P99高负载时偶发秒级延迟稳定在几十毫秒内空闲连接占用资源每连接一个线程资源占用大每连接一个Channel事件驱动资源占用小这个压测严格说不够严谨毕竟压测期间网络、机器负载都会有波动但足以说明方向在连接数和消息量上来之后Netty的线程模型优势非常明显。如果你的项目预见到未来在线连接数会超过几千尽早切到Netty是划算的。6.3 容易被忽略的内存泄漏和异常处理生产环境跑了一段时间后我遇到过两类问题都值得单独提醒。第一类是ByteBuf泄漏。Netty使用堆外内存的ByteBuf时必须手动release忘了释放就会导致堆外内存泄漏最终表现为内存占用只涨不降、甚至OOM。解决办法是用好SimpleChannelInboundHandler它自动释放如果继承的是普通ChannelInboundHandlerAdapter要在channelRead处理完手动ReferenceCountUtil.release(msg)或者ctx.writeAndFlush会自动释放。另外设置-Dio.netty.leakDetection.leveladvanced可以在日志里打印泄漏点定位时建议开起来。第二类是连接数监控缺失。线上如果连接数异常暴涨却没有监控报警等发现问题时服务可能已经不可用了。我在服务端加了一个定时任务每10分钟输出一次当前在线连接数和ChannelGroup大小配合Prometheus暴露指标做到趋势可见。Scheduled(fixedRate 600000) public void reportConnectionCount() { log.info(当前在线连接数{}UserChannel映射数{}, onlineChannels.size(), userChannelMap.size()); }这个日志看似简单关键时刻能救命。例如某次线上出现大量僵尸连接就是靠这个日志里的映射数远大于正常业务用户数才发现的——最终定位到是某类客户端断网后没有自动重连也没有正常关闭连接服务端靠心跳也清不掉因为还在收心跳最后靠限制单用户最大连接数和更严格的空闲清理解决。再补充一点关于userChannelMap映射清理的心得很多教程只做channelInactive时移除映射但实际生产里客户端进程被直接杀掉、网络闪断等场景服务端不一定能及时触发channelInactive。所以要在心跳Handler里加上对映射的检查——如果连接被判定为读空闲并关闭一定要把对应的映射也删掉否则userChannelMap会残留大量废弃映射定向推送时白白遍历一堆无效Channel。7. 我给团队定下的编码规范附带写给你的一些建议项目稳定运行几个月后我梳理了一套适合团队新人快速上手的编码规范顺手分享几个最有价值的小技巧。第一所有发出去的消息都统一走封装方法不要直接writeAndFlush。我定义了一个MessageSender工具类里面封装了sendToUser、broadcast、sendToChannel等方法统一处理消息式的JSON序列化、异常捕获、发送回调。好处是后续如果要加消息轨迹、限流、敏感词过滤只需要改一个入口。第二WebSocketSession和业务线程之间的消息推送要串行化处理。如果业务线程向同一个Channel高频推送消息不控制并发的话writeAndFlush可能会出现线程安全问题。Netty本身保证了一个Channel上的操作是串行的但前提是你不能从多个线程同时写同一个Channel而没有同步。我这边用一个ChannelFutureListener在写失败时打日志同时避免在业务线程里阻塞等待写结果。第三Client与Server之间的消息协议一定要带type字段和requestId字段。刚开始我只用了type字段区分业务类型后来做日志追踪和去重时发现没有requestId几乎没法做联调。加一个UUID或者业务流水号几行改动排查问题的时间能省一大截。第四从SpringBoot 2.x升级到3.x时Spring的WebMvcConfigurer接口和很多Web相关的自动配置都变了如果项目里还依赖了其他Web框架升级要通盘考虑不只是换JDK版本那么简单。Netty和SpringBoot的集成代码在3.x下倒是基本不变因为Netty自己管理生命周期与Spring容器只有极少的耦合点。我在实际项目中还有一个习惯会把Netty端口注册到Nacos配置中心而不是硬编码在代码里。这样做的好处是环境切换开发、测试、生产时不必改代码重新打包只要改配置就行。如果你项目里已经用Nacos或者配置中心强烈建议这么干。最后说点掏心窝的话Netty这套方案好归好但不要一上来就追求极致的性能调优。先把连接管理、心跳保活、粘包处理这几个基础环节做扎实性能自然不会差到哪里去。我见过不少团队一上来就调ByteBuf池参数、折腾EventLoop线程数结果基础业务还没跑通出了问题也排除不了。先把正确的事情做对再去优化跑得快不快。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。