Spring Boot与Netty构建高性能WebSocket聊天系统核心解析

发布时间:2026/10/23 14:40:00
Spring Boot与Netty构建高性能WebSocket聊天系统核心解析 1. 项目背景与整体方案选型1.1 为什么要用Netty而不是Spring自带的WebSocket先说结论如果你的项目只是做一两个页面的实时通知Spring Boot自带的WebSocket基于Tomcat或Jetty容器完全够用。但一旦要做到单聊、群聊、在线状态管理、用户鉴权、离线消息这类事情Spring内置的那套东西很快就别扭起来。举个例子Spring的WebSocket虽然也支持拦截器做握手前的校验但对连接建立之后的消息处理控制粒度比较粗。你很难精确地知道一条消息是从哪个Channel发出去的、目标用户在哪个节点上、要不要做细粒度的流控和限流。而Netty天生就是一个网络通信框架它对Channel的管理、对编解码器的编排、对内存池的掌控都要比直接跑在Servlet容器里的WebSocket实现灵活得多。另一个现实原因是性能。Netty的IO线程模型是经典的Reactor多线程模型Boss线程负责AcceptWorker线程负责读写两者分离。而Servlet容器里的WebSocket一旦连接多了以后线程都耗在阻塞读写上很容易把容器的线程池打满。我做过一个模拟项目X当时用Tomcat默认的WebSocket支撑单机5000长连接CPU和内存都撑不住切到Netty之后单机1万连接非常轻松GC压力也小了很多。还要考虑一个扩展性问题。你做单聊群聊将来大概率要做系统通知、客服会话、直播弹幕、在线人数统计这类功能。Netty的Pipeline机制可以让这些功能像流水线一样拆成一个一个Handler加功能就是加Handler改逻辑就是换Handler不侵入核心代码。Spring WebSocket也能加拦截器和装饰器但扩展起来总觉得隔了一层容器。所以我的判断是以Netty作为底层通信框架以Spring Boot作为业务承载两者各干各的Spring Boot负责业务逻辑、数据持久化、鉴权、REST接口Netty负责长连接管理和消息推送结合WebSocket协议打通两端。这也是目前比较主流的长连接方案。1.2 技术栈与整体架构这个项目的整体架构并不复杂核心思路是前后端分离加中间层代理。前端通过WebSocket客户端连接到Netty服务端服务端内部维护用户ID到Channel的映射关系收到消息后根据消息类型做路由私聊消息找目标用户的Channel群聊消息遍历群成员列表逐个投递。技术栈上选了几个比较稳的组件Spring Boot 2.7.x负责Web层、业务逻辑、鉴权、数据持久化。版本不用太新2.7足够稳定和Netty的兼容性也验证过很多次。Netty 4.1.x底层通信框架负责WebSocket协议解析、Channel管理、心跳检测。Redis存储在线状态、Session映射、群聊关系缓存。如果你的用户量不大用ConcurrentHashMap也能撑但多节点部署时必须上Redis。MyBatis-Plus或JPA持久化聊天记录。这里我偏向MyBatis-Plus写SQL更方便。聊天记录不用实时落库可以先写消息队列或本地内存队列异步批量插入。JWT用户鉴权。客户端在握手时通过URL参数携带Token服务端在握手阶段做校验。整体数据流向是这样前端通过WebSocket建立连接连接地址形如ws://ip:port/ws?tokenxxxNetty收到握手请求时先解析Token校验用户身份连接建立成功后用户ID和Channel对象绑定存入连接管理器之后客户端发来JSON消息体Netty的解码器把JSON解析成Java对象根据消息类型路由到单聊处理器或群聊处理器处理器从连接管理器找到目标Channel把消息写回客户端。2. 核心细节解析与设计思路2.1 WebSocket握手与Netty管道装配WebSocket协议本身分两个阶段握手阶段和数据传输阶段。HTTP Upgrade请求过来之后Netty需要先完成协议升级然后才能收发WebSocket帧。这一块Netty封装得已经很完善不需要你手写握手响应。我在初始化Channel时Pipeline里装配的顺序可以参考下面这种方式ChannelPipeline pipeline ch.pipeline(); // Http协议编解码器用于握手阶段 pipeline.addLast(httpCodec, new HttpServerCodec()); // Http对象聚合把HttpMessage和HttpContent聚合成FullHttpRequest pipeline.addLast(aggregator, new HttpObjectAggregator(65536)); // WebSocket协议处理器处理握手、Close、Ping、Pong等控制帧 pipeline.addLast(webSocketHandler, new WebSocketServerProtocolHandler(/ws)); // 自定义的鉴权Handler处理握手前和握手后的逻辑 pipeline.addLast(authHandler, new AuthHandler()); // 消息解码器把TextWebSocketFrame转成自定义消息对象 pipeline.addLast(messageDecoder, new MessageDecoder()); // 业务处理器消息分发、单聊、群聊 pipeline.addLast(chatHandler, new ChatHandler());这里有几个需要注意的点。第一HttpObjectAggregator必须放在HttpServerCodec之后因为它需要把分片到达的Http请求体聚合成完整对象如果聚合器不在编码器后面数据不完整就没有办法解析。大小我设的是64KB这个值够用了因为握手阶段的GET请求本身没有大体积的Body真正的大消息是在数据传输阶段走的。第二WebSocketServerProtocolHandler这个Handler是Netty自带的你只要给它一个路径它就能自动处理Upgrade请求。这里要注意它处理完握手之后HttpMessage就不会再往下传了后续数据都是WebSocketFrame。所以自定义的鉴权Handler如果放在它后面就只能在连接建立后拿到Channel无法在握手阶段拦截。如果你希望在握手阶段完成鉴权也就是在HTTP请求还没升级之前校验Token有两个常用方案。一个是在HttpServerCodec和WebSocketServerProtocolHandler之间加一个自定义的ChannelInboundHandlerAdapter重写channelRead在消息是FullHttpRequest时解析URL参数做校验校验失败直接返回401并关闭连接。另一个是使用WebSocketServerProtocolHandler的握手策略扩展点自定义HandshakeShaper来校验Token。这两个方案都可行前一个写起来更直接一些。第三Pipeline顺序极其重要。Netty的Handler是有顺序的消息从Inbound方向是从头往后传所以解码器必须在业务Handler之前。如果把自定义Decoder放在WebSocketServerProtocolHandler前面那它收到的是HttpRequest而不是WebSocketFrame类型转换就会报错。这也是初学者最容易踩的坑。2.2 用户鉴权的实现时机与方案用户鉴权这件事关键是要想清楚一个问题在每个WebSocket连接建立之后怎么知道这个连接属于哪个用户用JWT做鉴权的话流程很简单。客户端在登录成功后拿到Token建立WebSocket连接时把Token拼在URL里ws://ip:port/ws?tokeneyJhbGciOiJIUzI1NiJ9...。服务端在握手阶段解析这个Token验签、查过期时间、解析用户ID。校验通过就放行这个连接校验失败就返回401并关闭连接。我建议鉴权逻辑独立成一个Handler不在业务Handler里混着做。原因是为了复用。将来如果要做系统通知服务可能也要走WebSocket长连接这时候直接复用同一个鉴权Handler就行业务逻辑各写各的。再说一个细节Token失效的问题。JWT本身是有过期时间的但是用户如果一直在聊天Token过期了总不能把人踢下线吧所以这里一般有两层策略。第一层握手时校验JWT的有效性过期的一律拒之门外。第二层连接建立之后定期用Redis续期Session的TTL。也就是说鉴权Handler通过JWT解析出userId之后会把这个用户ID写入RedisKey是login:token:{userId}Value是ChannelIDTTL设置成半小时。心跳Handler每次收到心跳包就顺手把这个Redis的TTL续期一次。这样只要用户一直在线Session就一直有效即使JWT到期了也不会被强制断开。等到用户主动断开连接或者心跳超时Redis里的Session记录才被清除。这种方案在单体应用里够用了多节点部署时也能直接用因为Redis是共享的。如果你们公司Redis都没有也可以先用本地ConcurrentHashMap存储userId到Channel的映射但记住本地存储的方案在后续水平扩展时会成为瓶颈上线前一定要先想清楚。需要注意的是鉴权Handler必须放在MessageDecoder之前。否则客户端发来第一条业务消息时你都不知道这个用户是谁就没有办法做路由了。2.3 单聊与群聊的消息模型设计消息模型的设计直接决定后续代码好不好维护。我的做法是设计一个统一的消息基类里面包含消息类型、发送者、接收者、消息内容、时间戳等字段然后根据消息类型做JSON序列化与反序列化。public class ChatMessage { private Integer type; // 消息类型1-单聊2-群聊3-系统通知4-心跳 private String fromUserId; // 发送者用户ID private String toUserId; // 单聊时是接收者ID群聊时是群组ID private String content; // 消息内容 private Long timestamp; // 时间戳 private String messageId; // 消息唯一ID用于去重和回执 }type字段是消息分发的核心。单聊消息服务端从连接管理器找到toUserId对应的Channel直接推送。群聊消息服务端从群组关系表查出所有成员ID列表循环找到每个成员的Channel逐个推送。系统通知服务端自己构造消息推送给指定的用户。一开始我用了两个不同的实体类来表示单聊和群聊消息后来发现太啰嗦了很多字段是重复的。合并成一个统一的消息体之后编解码逻辑也简化了很多。前端也只需要一个消息处理函数根据type字段分发不同的业务逻辑就行。群聊还有一个问题如果群里有几百人逐个推送的方式是比较低效的。这里可以优化如果后续用户量上来可以针对大群引入广播分组或者走消息队列异步推送但单机一两千人的群逐个推送完全够用不用过早优化。3. 实操过程与核心环节实现3.1 依赖引入与项目结构先看一下Maven依赖这里列出了核心部分版本号可以根据你的Spring Boot版本微调。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdio.netty/groupId artifactIdnetty-all/artifactId version4.1.86.Final/version /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version3.19.2/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.alibaba.fastjson2/groupId artifactIdfastjson2/artifactId version2.0.25/version /dependency项目结构建议按下面这种方式组织分包清晰一些后面好维护com.example.chat ├── ChatApplication.java // Spring Boot启动类同时负责启动Netty服务 ├── config │ └── NettyConfig.java // Netty配置类 ├── netty │ ├── NettyServer.java // Netty服务端启动、绑定端口、初始化boss/worker线程 │ ├── NettyServerInitializer.java // Channel初始化编排Pipeline │ ├── handler │ │ ├── AuthHandler.java // 鉴权Handler │ │ ├── MessageDecoder.java // 消息解码器 │ │ ├── ChatHandler.java // 业务分发Handler │ │ └── HeartbeatHandler.java // 心跳处理 │ └── ConnectionManager.java // 连接管理器维护userId - Channel映射 ├── common │ ├── ChatMessage.java // 消息实体 │ ├── ResponseCode.java // 响应码定义 │ └── JsonUtil.java // JSON工具类 ├── service │ ├── AuthService.java // 鉴权服务校验JWT │ ├── ChatService.java // 聊天逻辑服务处理单聊、群聊路由 │ └── FriendService.java // 好友关系/群组成员查询 └── controller └── ChatController.java // REST接口发送消息、查询记录等3.2 Netty服务端的启动环节NettyServer类是整个服务端的入口。按下面这种方式初始化就可以正常跑起来。Component public class NettyServer implements ApplicationListenerApplicationReadyEvent, DisposableBean { private EventLoopGroup bossGroup; private EventLoopGroup workerGroup; private Channel serverChannel; Override public void onApplicationEvent(ApplicationReadyEvent event) { start(); } public void start() { bossGroup new NioEventLoopGroup(1); workerGroup new NioEventLoopGroup(Runtime.getRuntime().availableProcessors() * 2); 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 NettyServerInitializer()); ChannelFuture future bootstrap.bind(8888).sync(); serverChannel future.channel(); System.out.println(Netty服务端启动成功端口: 8888); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } Override public void destroy() { if (serverChannel ! null) { serverChannel.close(); } bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } }这里说明几个细节。第一为什么用ApplicationReadyEvent而不是PostConstruct因为Netty服务端启动之后就要依赖Spring容器里的Service组件如果用PostConstruct触发启动可能Spring Bean还没有完全初始化注入的Service是空的。ApplicationReadyEvent是Spring Boot完全启动之后才触发的这时候所有Bean都就绪了再启动Netty就不存在依赖问题。第二Boss线程数设置为1就够了。Boss线程只负责Accept连接不处理读写单线程完全够用设多了反而浪费。Worker线程数我设置为CPU核数的两倍这个经验值在大多数场景下表现稳定。如果你的机器是4核8线程Worker线程就是8个。第三SO_BACKLOG是TCP连接队列的长度。在高并发连接涌入的场景下如果这个值太小操作系统会丢弃一部分连接请求。1024在大多数Linux机器上是合理的可以再加大但没必要。3.3 ChannelPipeline装配与握手鉴权NettyServerInitializer负责初始化每个新连接的Pipeline同时把Spring容器里的Service组件注入到Handler中。Component public class NettyServerInitializer extends ChannelInitializerSocketChannel { Autowired private AuthService authService; Autowired private ChatService chatService; Autowired private ConnectionManager connectionManager; Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline ch.pipeline(); pipeline.addLast(httpCodec, new HttpServerCodec()); pipeline.addLast(aggregator, new HttpObjectAggregator(65536)); pipeline.addLast(webSocketHandler, new WebSocketServerProtocolHandler(/ws)); pipeline.addLast(authHandler, new AuthHandler(authService, connectionManager)); pipeline.addLast(messageDecoder, new MessageDecoder()); pipeline.addLast(heartbeatHandler, new HeartbeatHandler(connectionManager)); pipeline.addLast(chatHandler, new ChatHandler(chatService, connectionManager)); } }AuthHandler的核心逻辑是解析Token校验用户身份绑定连接。public class AuthHandler extends ChannelInboundHandlerAdapter { private final AuthService authService; private final ConnectionManager connectionManager; private boolean authenticated false; Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (!authenticated msg instanceof FullHttpRequest) { FullHttpRequest request (FullHttpRequest) msg; String uri request.uri(); String token parseTokenFromUri(uri); UserInfo userInfo authService.validateToken(token); if (userInfo null) { sendUnauthorized(ctx); return; } // 记录用户ID到Channel的绑定后续业务Handler直接使用 ctx.channel().attr(AttributeKey.valueOf(userId)).set(userInfo.getUserId()); authenticated true; } // 鉴权通过后继续向下传递握手请求 ctx.fireChannelRead(msg); } }这个设计的关键在于先校验Token再放行握手请求。如果Token校验失败直接写回一个401响应并关闭连接握手不会完成。如果校验通过把userId写入Channel的Attribute中后面的Handler可以通过ctx.channel().attr(...)直接取出来不用再去解析一次Token。关于Attribute的用法提醒一下AttributeKey最好定义成常量不要每次复制字符串创建否则会有性能开销。Netty的AttributeMap本身是线程安全的同一个Channel的Attribute可以被多个Handler读写。还有一点值得注意握手阶段的FullHttpRequest在通过WebSocketServerProtocolHandler之后就不会再走Http编解码了所以如果你的AuthHandler放在WebSocketHandler后面读到的就不是FullHttpRequest了而是升级后的WebSocketFrame。所以上面的写法是让AuthHandler读取到握手阶段的Http请求校验完之后再放行给WebSocketHandler。3.4 连接管理器与在线状态维护连接管理器是整个服务的核心它维护了userId到Channel的双向映射。简单场景下用两个Map就能搞定。Component public class ConnectionManager { private final ConcurrentHashMapString, Channel userChannelMap new ConcurrentHashMap(); private final ConcurrentHashMapChannel, String channelUserMap new ConcurrentHashMap(); public void add(String userId, Channel channel) { userChannelMap.put(userId, channel); channelUserMap.put(channel, userId); } public void remove(Channel channel) { String userId channelUserMap.remove(channel); if (userId ! null) { userChannelMap.remove(userId); } } public Channel getChannel(String userId) { return userChannelMap.get(userId); } public boolean isOnline(String userId) { return userChannelMap.containsKey(userId); } }这里有几个细节处理要注意。第一个是多端登录的问题。同一个用户可能在手机和电脑上同时登录这时候一个userId对应多个Channel。如果只用单值Map后登录的会顶掉先登录的。处理方式有几种比较简单粗暴的是同一账号后登录踢掉前面登录的这个在一些业务场景下反而是需求如果要支持多端在线那userChannelMap的值就要改成CopyOnWriteArrayListChannel。第二点是并发安全。Netty的Handler是单线程执行的同一个Channel的消息不会并发进入Handler但不同Channel的消息会由不同的Worker线程处理它们都要操作ConnectionManager所以这个Manager内部必须使用并发安全的集合。ConcurrentHashMap是首选。第三点是在channelInactive和exceptionCaught里都要调用connectionManager.remove(channel)避免连接断开之后Channel还留在Map里导致推消息时NPE或者推给一个已经关闭的Channel。3.5 单聊流程的编码实现下面这一段是单聊的核心逻辑。ChatHandler收到消息后先解析出消息类型再做路由分发。Component public class ChatHandler extends SimpleChannelInboundHandlerChatMessage { private final ChatService chatService; private final ConnectionManager connectionManager; Override protected void channelRead0(ChannelHandlerContext ctx, ChatMessage message) { String fromUserId ctx.channel().attr(AttributeKey.valueOf(userId)).get(); message.setFromUserId(fromUserId); if (message.getType() 1) { // 单聊 Channel toChannel connectionManager.getChannel(message.getToUserId()); if (toChannel ! null toChannel.isActive()) { toChannel.writeAndFlush(new TextWebSocketFrame(JsonUtil.toJson(message))); } else { // 对方不在线先落库做成离线消息 chatService.saveOfflineMessage(message); } } else if (message.getType() 2) { // 群聊 chatService.sendGroupMessage(message); } } Override public void channelInactive(ChannelHandlerContext ctx) { connectionManager.remove(ctx.channel()); ctx.fireChannelInactive(); } }单聊的逻辑很直接根据接收者的userId找到Channel把消息写回去。如果目标用户不在线也就是找不到Channel就存为离线消息等对方上线时再补推。这里有一个容易忽略的点writeAndFlush是异步方法它返回一个ChannelFuture因为Netty的所有IO操作都是异步的。大部分人写完就拉倒但实际上应该给这个Future加一个监听器推送失败时做处理。ChannelFuture future toChannel.writeAndFlush(new TextWebSocketFrame(JsonUtil.toJson(message))); future.addListener((ChannelFutureListener) f - { if (!f.isSuccess()) { // 推送失败记录日志走离线消息兜底 chatService.saveOfflineMessage(message); } });3.6 群聊流程的编码实现群聊比单聊多了一步在服务端维护群组和成员的关系并完成消息群发。public void sendGroupMessage(ChatMessage message) { String groupId message.getToUserId(); ListString memberIds groupMemberService.getMemberIds(groupId); for (String memberId : memberIds) { // 跳过发送者自己是否需要由业务决定 Channel channel connectionManager.getChannel(memberId); if (channel ! null channel.isActive()) { channel.writeAndFlush(new TextWebSocketFrame(JsonUtil.toJson(message))); } else { // 离线用户的消息落到离线表 saveOfflineMessage(message, memberId); } } }群聊的消息不一定要实时群发如果你的群人数很多或者推送逻辑很重可以把消息投递到消息队列让消费者异步群发。但对于大多数中小规模项目来说一个循环就够了。这里有一个性能优化值得提一下如果成员列表很大比如千人以上的群每次发送群聊时都遍历一遍所有成员逐个做Channel查询会有一定的性能损耗。优化方案是缓存群成员列表群成员变动时刷新缓存。尤其是走HTTP接口查询成员列表的话每次群聊都查一遍数据库压力会比较大。实际操作中我会用Redis缓存群成员ID列表Key是group:members:{groupId}修改群成员时更新缓存。群发时先查缓存再批量推送。4. 常见问题与排查技巧实录4.1 心跳检测与断线识别WebSocket长连接有一个很大的坑TCP连接在中间网络设备比如路由器、防火墙、NAT网关空闲一段时间之后会被静默回收。这时候客户端和服务端都不知道连接已经断了会出现“假在线”的状态。举个例子一个用户挂在座位上中午去吃了个饭过了两个小时回来发消息服务端其实早就不认这个Channel了。如果业务上不做心跳检测推送给这个用户的消息就会一直堆积在Netty的发送缓冲区里造成内存泄漏。Netty解决这个问题有现成的机制IdleStateHandler。它放在Pipeline里如果在一段时间内没有读事件或写事件就会触发对应的空闲状态事件。pipeline.addLast(idleHandler, new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));上面这段配置表示60秒内没有收到任何数据包括心跳包就触发READER_IDLE事件。在HeartbeatHandler中我这样处理空闲事件Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { // 发送一个Ping帧给客户端看对方是否还活着 ctx.writeAndFlush(new PingWebSocketFrame()).addListener((ChannelFutureListener) future - { if (!future.isSuccess()) { // 发送失败说明连接已断直接关闭 ctx.close(); } }); } }这里有个思路上的区别有些团队的做法是连续几次心跳超时直接断开比如60秒没收到心跳就断开。我这边更温和一些先发一个Ping帧试探如果Ping都发不出去那基本可以确定连接已经无效了。如果Ping发出去了但客户端没有回应那就在下一次空闲事件到来时强制关闭连接。客户端也必须配合心跳。通常的做法是客户端每30秒发送一个心跳消息type4服务端收到后重置空闲时间。如果连续3个心跳周期没有收到服务端的任何响应客户端就主动重连。断线重连的细节也很重要客户端重连时要带上同一个userId服务端接到新的连接后要主动关闭旧连接对应的Channel防止出现“幽灵连接”。// 同一个用户重复连接时踢掉旧连接 Channel oldChannel connectionManager.getChannel(userId); if (oldChannel ! null oldChannel.isActive()) { oldChannel.close(); }4.2 消息乱序与重复推送WebSocket本身是基于TCP的TCP保证数据包的顺序性所以单条连接上的消息一般不会乱序。但有两个场景会导致用户感知到消息乱序。第一个场景是客户端多个线程处理消息。这个通常不是服务端的问题但如果你在客户端用了多线程消息处理要注意做顺序保证。第二个场景是服务端推送和离线消息补推重叠。举个例子用户A给用户B发消息B刚好掉线了这条消息存进离线表。B重连之后服务端先把离线消息补推给B同时B在线期间的新消息也会实时推过来。如果补推和实时推送的路由不是同一个逻辑两者可能会穿插到达。我在实际项目中对离线消息做了一个简单的处理B上线后先把所有离线消息按时间排序一次性推过去推完后再开始推实时消息。也就是在上线瞬间做个同步对齐。重复推送也是常见问题。客户端处理消息时要根据消息的唯一ID做去重。我在前面消息体里加了messageId字段客户端收到消息后维护一个最近50条消息的ID集合重复的消息直接丢弃。特别是群聊消息因为服务端可能在推送失败后重试重复的可能性会更大。4.3 内存泄漏与Channel关闭不及时Netty项目最容易出的问题就是内存泄漏尤其是堆外内存。Netty默认使用PooledByteBufAllocator基于内存池分配堆外内存。如果ByteBuf没有被正确释放内存池会被慢慢耗尽最终抛OutOfDirectMemoryError。好消息是使用SimpleChannelInboundHandler处理消息时Netty已经帮你自动释放了消息对象的引用计数所以继承SimpleChannelInboundHandler比继承ChannelInboundHandlerAdapter更安全。但如果你在ChannelInboundHandlerAdapter里手动接收了ByteBuf或WebSocketFrame那么你必须自己负责释放。Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof TextWebSocketFrame) { TextWebSocketFrame frame (TextWebSocketFrame) msg; String text frame.text(); // 业务逻辑... // 注意TextWebSocketFrame继承自DefaultByteBufHolder // 如果不再需要引用调用ReferenceCountUtil.release(frame) ReferenceCountUtil.release(frame); } }我习惯在MessageDecoder中把TextWebSocketFrame解码成自定义的ChatMessage对象这样业务Handler里根本接触不到ByteBuf相关的东西代码更干净也不会因为忘记释放引用导致内存泄漏。解码过程中TextWebSocketFrame在Decoder执行完之后会被自动释放业务层拿到的只是一个普通的Java对象完全不用关心内存管理。还有一个经验不要在业务Handler里持有Channel的长引用尤其是把它存到全局Map里后连接断开时一定要删除。我在ConnectionManager里同时维护了userId到Channel和Channel到userId的双向映射就是为了在关闭连接时能快速找到对应的userId避免Map里积累垃圾数据。4.4 跨域与Nginx代理配置如果你的前端页面部署在某个域名下后端WebSocket服务在另一个端口上浏览器跨域策略会拦截WebSocket连接。Netty里解决这个问题的标准做法是添加一个CORS处理器但实际项目中我遇到的更多是Nginx反向代理的情况。Nginx反向代理WebSocket时必须开启Upgrade相关的Header透传否则浏览器升级WebSocket的握手请求会被Nginx过滤掉。location /ws { proxy_pass http://127.0.0.1:8888; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; }proxy_read_timeout默认是60秒如果不改的话Nginx在60秒内没有从后端读到任何数据就会断开连接。WebSocket长连接会有很多空闲时间这个值必须调大我一般设3600秒配合客户端的定时心跳长连接能稳定挂很久。4.5 推送失败时的兜底策略最后聊一个实际场景里很重要的问题消息推送失败之后怎么办。我在项目里做了三级兜底。第一级是writeAndFlush失败时把消息记录下来写入离线消息表。第二级是目标用户不在线时直接把消息存为离线消息。第三级是如果用户在离线状态下发了群聊消息群聊逻辑会逐成员判断在线状态不在线的成员单独存离线记录。离线消息的推送时机一般是用户上线通过WebSocket重连成功后服务端主动查询该用户的未读消息批量推送给客户端。推完之后客户端收到消息回调HTTP接口告诉服务端哪些messageId已经成功接收服务端再把这些记录删除或标记为已读。这套兜底链路写起来不复杂但能避免很多业务投诉。比如用户A给用户B发了一条重要消息B中途手机没电关机了等B上线时如果没有离线消息补推这条消息就凭空消失了对用户来说是不可接受的。5. 一点实践经验与后续扩展空间这套Spring Boot Netty WebSocket的结构在我参与过的几个实际项目中反复使用过整体稳定性很不错。单机支撑上万连接没什么问题消息延迟基本在毫秒级心跳检测也保证了僵尸连接能被及时清理。代码里最核心的其实就是三个部分Pipeline的装配顺序、连接管理器的数据结构、消息路由的逻辑。这三个部分设计好剩下的都是填充性的业务代码。如果你后续要做分布式部署需要改造的地方集中在连接管理器上把本地的ConcurrentHashMap换成Redis存储或者引入一个轻量级的推送中间件来维护连接信息和在线状态。单体阶段用本地Map完全可以撑住不必过早引入分布式组件。聊天记录的持久化也值得单独规划。我先用异步消息队列配合批量写库的方式把消息记录写入MySQL高峰期不会拖慢在线消息的实时推送。如果消息量大到MySQL吃力的程度可以考虑按时间分库分表或者迁移到合适的存储引擎这个留给后续再展开。最后再分享一个实际排查问题的建议WebSocket的问题往往不是看代码能看出来的一定要在测试环境上做压测和长时间挂机验证。我曾经遇到过一个问题连接数稳定在3000左右时一切正常但过了一个周末之后内存占用慢慢上升最后触发了告警。排查来排查去就是心跳检测的空闲时间设置得太长加上离线消息表没做TTL清理导致连接关闭后一些资源没被及时回收。所以上线前一定要模拟长时间运行、大量掉线重连、心跳超时这些场景把稳定性验证做透比多写几个功能重要得多。