ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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线程负责Accept,Worker线程负责读写,两者分离。而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?token=xxx,Netty收到握手请求时先解析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?token=eyJhbGciOiJIUzI1NiJ9...。服务端在握手阶段解析这个Token,验签、查过期时间、解析用户ID。校验通过就放行这个连接,校验失败就返回401并关闭连接。

我建议鉴权逻辑独立成一个Handler,不在业务Handler里混着做。原因是为了复用。将来如果要做系统通知服务,可能也要走WebSocket长连接,这时候直接复用同一个鉴权Handler就行,业务逻辑各写各的。

再说一个细节:Token失效的问题。JWT本身是有过期时间的,但是用户如果一直在聊天,Token过期了总不能把人踢下线吧?所以这里一般有两层策略。

第一层,握手时校验JWT的有效性,过期的一律拒之门外。第二层,连接建立之后,定期用Redis续期Session的TTL。也就是说,鉴权Handler通过JWT解析出userId之后,会把这个用户ID写入Redis,Key是login:token:{userId},Value是ChannelID,TTL设置成半小时。心跳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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.86.Final</version> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.19.2</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.alibaba.fastjson2</groupId> <artifactId>fastjson2</artifactId> <version>2.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 ApplicationListener<ApplicationReadyEvent>, 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 ChannelInitializer<SocketChannel> { @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 ConcurrentHashMap<String, Channel> userChannelMap = new ConcurrentHashMap<>(); private final ConcurrentHashMap<Channel, 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的值就要改成CopyOnWriteArrayList<Channel>。

第二点是并发安全。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 SimpleChannelInboundHandler<ChatMessage> { 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(); List<String> 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秒发送一个心跳消息(type=4),服务端收到后重置空闲时间。如果连续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清理,导致连接关闭后一些资源没被及时回收。所以上线前一定要模拟长时间运行、大量掉线重连、心跳超时这些场景,把稳定性验证做透,比多写几个功能重要得多。

返回列表