在接手企业IM即时通讯这个需求之前我一直以为SpringBoot里加个WebSocket就是调个API的事真正动手做才发现从“能连上”到“能稳定支持群聊、提醒、消息回执”之间隔着一条巨大的鸿沟。这篇文章把我在开发一个包含群聊、提醒、消息回执的轻量级企业IM系统时的完整思考、架构选型、代码实现和踩坑记录整理出来。项目技术栈锁定SpringBoot WebSocket STOMP协议全程围绕真实业务需求展开适合已经掌握SpringBoot基础、想往企业级通信方向深入的同学参考。开头先说说这套组合到底解决了什么问题。原生WebSocket只解决“双向长连接”的问题它本身不规定消息格式、不定义路由规则、也没有心跳和订阅机制。STOMPSimple Text Oriented Messaging Protocol是建立在WebSocket之上的消息协议它把这些缺失的规范补齐了——用类似“发送消息帧、订阅目的地、服务端推送”这种简洁的文本帧格式把一条条消息路由到对应的订阅者。SpringBoot把这两者整合得非常顺手加上spring-boot-starter-websocket依赖几乎是无缝衔接。再加上SendTo、SendToUser这些注解和SimpMessagingTemplate足以支撑群聊、点对点私聊、强提醒这类功能。它不像Netty那样偏底层要自己搞定一切但对大多数业务系统来说完全够用而且相当稳。1. 内容整体设计与思路拆解1.1 为什么不是“裸用WebSocket”而是套一层STOMP当时团队里有人提出直接用原生WebSocket写Spring的WebSocketHandler接口看起来也不复杂配上TextWebSocketHandler就能收发文本消息。但稍微深入想了一下就发现坑不少原生WebSocket没有“主题”概念每一条消息都要自己做路由分发前端也要手动维护连接、手动判断消息类型连接管理、心跳、重连这些都要从零手写。本质上原生WebSocket相当于一条裸管道你往里倒什么、怎么倒、怎么分全得自己定义还容易各写各的导致代码风格完全不一样。STOMP就是在这个管道上加了一整套轻量级的“托运规则”。发送方要指定destination目标地址服务端能根据这个地址做转发客户端可以主动订阅某个地址实时接收推送消息本身就带类型、头信息和正文结构。这种模式跟消息队列非常像但又是即时通信。用生活化的比喻来说WebSocket是电话线路STOMP是电话里的语言规范——大家都说同一种语言听的人才能听懂对方在讲什么。更关键的是Spring对STOMP有深度的原生支持几乎不需要再封装底层通信逻辑注意力能全部集中在业务上。既然定了STOMP服务端实现层的第一选择就是spring-boot-starter-websocket。这个starter依赖其实是把Spring MVC里的WebSocket支持和STOMP协议支持打包好了类名都叫WebSocketMessageBrokerConfigurer配置它来注册STOMP端点、配置消息代理和订阅前缀。这个配置类核心就两个方法registerStompEndpoints用于注册前端连接的地址configureMessageBroker用于设定消息的前缀路由规则。1.2 三个核心需求背后的技术拆解先说群聊。群聊表面上看起来就是“A发消息群里所有人都能收到”实现时涉及到一个关键问题——消息要不要先进数据库再推给在线用户。如果只追求实时性直接推用户不在线就收不到如果先存库再推又面临推送失败怎么办的补偿问题。最终我的设计是“存储和推送双轨并行”把消息先持久化到MySQL保证数据不丢再通过STOMP的广播地址实时推给在线用户不在线的人也能在下次登录时拉到历史消息。存储、推送各司其职不互相拖累。然后是提醒。这块比看上去复杂它不只是文本里带一个“小明”你需要在发送时就识别出被的人是谁还要在服务端把提醒处理成独立的业务记录。做的时候要区分两类提醒站内信式的离线提醒小红点、通知列表、未读消息数和WebSocket的实时推送提醒。前者靠数据库表记录后者靠STOMP的点对点推送。两条链路缺一不可。如果只做实时推送那离线用户永远看不到有人过他如果只存数据库那在线用户又不会实时弹出提醒。这两个手段必须同时做并且要保证它们走的是同一套数据源。消息回执是这三个里最有挑战性的。它的本质是“某个用户看到了某条消息”的状态同步。要实现它第一件事是明确数据模型消息表、会话表、用户消息状态表各自承担什么职责量级怎么预估。第二件事是解决已读状态的推送时机是每次读一条推一条还是批量累计后统一推前端如果每读一条消息就推送一条回执服务端压力很大如果批量推前端UI的实时性又会下降。我的做法是在前端做一个已读回执的“合并上报”——用户停留在某个会话里3秒以上把这个会话里所有未读消息标记为已读一次性上报给服务端服务端再广播给该会话的其他成员把一个一个的小回执合并成一条复杂度瞬间就降下来了。1.3 为什么选择STOMP的心跳机制而不自己写WebSocket本身确实有Ping/Pong帧但前端浏览器里的WebSocket API偏偏不开放直接发送Ping帧的能力只能被动接收。这意味着真要实现长连接的保持还是得在应用层自己做心跳对多数团队来说就是自己写定时器。STOMP协议干脆把心跳定义到了协议层客户端和服务端在CONNECT帧里通过heart-beat头协商心跳间隔之后双方按照协商的结果定时发送一个空行EOL或合法帧来保持连接活跃。Spring的WebSocketStompClient自带心跳支持服务端也内置了心跳协商机制配置完成后几乎不用写多余的代码。代理层面心跳断了Spring会自动触发连接关闭和清理逻辑大大减少了写“僵尸连接”清理代码的工作量。2. 核心细节解析与实操要点2.1 项目依赖与基础配置从依赖入手pom.xml里核心要加的是spring-boot-starter-websocket。如果你在做安全认证还要考虑spring-security整合的问题但初版建议先把安全和WebSocket解耦单独拎出来做。项目用的是SpringBoot 2.7.x版本JDK 8或11都行。还有一个容易踩的坑是SpringBoot版本和Spring框架版本之间的API差异比如3.x之后很多类改了包名或废弃了旧方法网上很多教程是2.x的写法照搬到3.x会直接编译报错。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependencyRedis在这里不是装饰品它用于在线用户状态管理和未读消息计数。如果部署在集群环境Redis还是广播消息的关键一环这个后面会专门说。数据库用MySQL即可表结构设计是重点。配置文件里核心的不是端口而是STOMP的消息前缀和端点路径规划。我将应用内消息代理Simple Messaging Broker的“目的地前缀”设为/app表示客户端发送到服务端的消息都会经过这里“订阅前缀”设为/topic和/queue分别表示广播订阅和点对点订阅服务端点路径设为/ws-im表示前端连接WebSocket时的入口。这三个路径设计看似简单实际上决定了后续代码风格的统一性最好一开始就规划好。server: port: 8080 spring: application: name: im-server redis: host: 127.0.0.1 port: 63792.2 WebSocketConfig配置类与STOMP端点注册WebSocket配置类要继承WebSocketMessageBrokerConfigurer并实现configureMessageBroker和registerStompEndpoints两个方法。注册端点这里有个细节setAllowedOriginPatterns。SpringBoot 2.7之后的版本对跨域限制很严如果前端和你的服务端不在同一域名下用setAllowedOrigins()经常会被浏览器拦下而setAllowedOriginPatterns()则能正确匹配任意来源。因为生产环境前端大概率部署在独立域名所以必须用后者。Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws-im) .setAllowedOriginPatterns(*) .withSockJS(); } Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker(/topic, /queue); registry.setApplicationDestinationPrefixes(/app); registry.setUserDestinationPrefix(/user); } }这个代码里最容易被忽略的是.sockJS()。SockJS是一个浏览器端降级方案它让不支持WebSocket的老旧浏览器自动回退到HTTP长轮询或Server-Sent Events。虽然现在主流浏览器基本都支持WebSocket但企业内部用户偶尔会用到比较老的内网浏览器保留SockJS基本是零成本的保险丝。前端对应要引入sockjs-client库后面我会给出完整的前端代码。configureMessageBroker里的setUserDestinationPrefix(/user)也值得细说。它在点对点通信中扮演独特角色。当你给某个具体用户推送消息时可以这样写convertAndSendToUser(userId, /queue/reply, payload)此时真正推送的目的地会被自动转换成/user/{userId}/queue/reply。这种转换规则就是serveUserDestinationPrefix决定的前端订阅时也用/user/queue/reply这个地址来接收。基于这套机制提醒的异步推送、私聊消息的实时送达就都能落到确切的个人头上。2.3 用户身份与WebSocket Session绑定WebSocket连接建立时前端会传一个token服务端需要解析token并把它和WebSocket的Session关联起来。很多新手喜欢在消息处理方法里解析参数判断是谁发的但正确的做法是利用ChannelInterceptor在握手阶段就完成身份绑定。在Spring的STOMP协议栈中CONNECT帧到达时可以拦截Connection。在preSend里获取StompHeaderAccessor取出token并解析用户然后把用户信息放进atttributes。后续消息处理时再从headerAccessor的sessionAttributes里取出用户信息整个过程是线程安全的。Component public class UserChannelInterceptor implements ChannelInterceptor { Override public Message? preSend(Message? message, MessageChannel channel) { StompHeaderAccessor accessor MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); if (accessor ! null StompCommand.CONNECT.equals(accessor.getCommand())) { String token accessor.getFirstNativeHeader(Authorization); if (token ! null) { Integer userId parseUserIdFromToken(token); if (userId ! null) { accessor.setUser(new StompPrincipal(String.valueOf(userId))); } } } return message; } }StompPrincipal实现了java.security.Principal接口只有getName方法。这个Principal天然就是用户身份的来源SendToUser和convertAndSendToUser底层也都是通过它来识别“发给哪个用户”。如果不做绑定而改用param传参那一条消息就能冒充其他人发出去安全直接就崩了。这里我把解析token的逻辑只写了个注释位置生产环境建议接入Spring Security的认证过滤器或直接用JWT工具类解析。2.4 消息结构体的设计消息体设计直接影响前后端联调效率。直白说消息不仅要携带内容文本还需要带上发送者信息、消息类型、接收范围、时间戳、客户端生成的消息ID等元数据。我常用的DTO结构是这个样子的。{ type: CHAT, conversationId: 1001, senderId: 1, senderName: 张工, content: 大家好这个需求我看过了, timestamp: 1689058800000, clientMsgId: uuid-xxxxx, mentionedUserIds: [2, 3] }clientMsgId是前端生成的唯一标识这个字段极其有用。IM系统最容易出现的就是消息重复——断网重连导致消息重发、服务端重试导致重复入库。前端拿到clientMsgId可以做幂等去重服务端也能用它做唯一索引来辅助防重。我的做法是在消息表中对client_msg_id加唯一索引一旦检测到重复就拒绝写入并返回“重复消息”状态码保证一个客户端消息只落库一次。3. 实操过程与核心环节实现3.1 数据库表结构设计与理由实现三个核心功能至少需要四张表用户表、会话表、会话成员表、消息表。如果要做提醒和已读回执消息表和会话成员表还需要额外加字段或关联表。完整建表语句如下。CREATE TABLE im_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) UNIQUE NOT NULL, display_name VARCHAR(64) NOT NULL, avatar_url VARCHAR(255), status TINYINT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE im_conversation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128), type TINYINT COMMENT 1-单聊 2-群聊, owner_id BIGINT, last_message VARCHAR(512), last_message_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE im_conversation_member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, conversation_id BIGINT NOT NULL, user_id BIGINT NOT NULL, unread_count INT DEFAULT 0, last_read_message_id BIGINT DEFAULT 0, muted TINYINT DEFAULT 0, UNIQUE KEY uk_conversation_user (conversation_id, user_id) ); CREATE TABLE im_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, conversation_id BIGINT NOT NULL, sender_id BIGINT NOT NULL, msg_type TINYINT COMMENT 1-文本 2-图片 3-文件 4-系统, content TEXT, client_msg_id VARCHAR(64) UNIQUE, mentioned_user_ids VARCHAR(255) COMMENT 冗余字段逗号分隔, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_conversation_id_time (conversation_id, created_at) );im_conversation_member表是整个系统的关键它同时承担了三个职责一是记录某个用户在某个会话里的未读数二是记录某个用户在该会话中的已读位置三是存储会话提醒设置。unread_count的更新发生在两个时机用户不在线或者在线但没打开会话时收到新消息则增加计数会话被打开且用户停留时清理对应会话的未读。last_read_message_id用于计算回执的拉取差量它能告诉我哪些消息是这个用户没读过的。消息表里特意冗余了mentioned_user_ids字段这个字段存储本消息了哪些用户用逗号分隔而不是搞一张关联表。原因很简单提醒我们要关心的是“这条提醒是否已读”是否了谁本来就是消息的一部分拆成关联表反而增加查询负担而且IM消息的通常不需要事后做复杂的维表分析。如果在做更多关联数据统计分析再考虑拆表。3.2 群聊消息的完整处理链路服务端接收群聊消息的入口是一个Controller类。前端把消息POST到/app/chat/send这个STOMP目的地实际上就是发到服务端服务端做四步操作保存消息到数据库、更新会话列表里的最后一条消息摘要、通过convertAndSend广播给/topic/chat/{conversationId}、处理提醒和未读计数。其中广播给群聊的两个动作有先后——先存库再广播顺序绝对不能反。如果先广播后存库一旦存库失败数据库临时故障在线用户看到了消息、离线用户又拉不到数据就永久不一致了。MessageMapping(/chat/send) public void handleGroupChat(Payload ChatMessage chatMessage, SimpMessageHeaderAccessor accessor) { Integer senderId ((StompPrincipal) accessor.getUser()).getNameAsInt(); chatMessage.setSenderId(senderId); chatMessage.setTimestamp(System.currentTimeMillis()); Long messageId messageService.saveMessage(chatMessage); chatMessage.setId(messageId); conversationService.updateLastMessage(chatMessage.getConversationId(), chatMessage.getContent()); if (chatMessage.getMentionedUserIds() ! null !chatMessage.getMentionedUserIds().isEmpty()) { mentionService.processMention(chatMessage); } messagingTemplate.convertAndSend(/topic/chat/ chatMessage.getConversationId(), chatMessage); }MessageMapping是Spring处理STOMP客户端消息的核心注解。客户端发送到/app/chat/send的消息最终会映射到handleGroupChat方法。这里有个程序员容易迷惑的地方方法返回void因为实际是通过messagingTemplate手动推送的而不是利用SendTo注解自动转发。两者都能实现广播但手动推送更灵活因为可以在推送前做任意前置业务操作比如这里就做了存库和提醒处理。群里核心逻辑里要留意消息广播时谁应该收到。如果群成员里有用户已经把所有消息都设置成免打扰广播前需要查一下该成员的免打扰状态但也不要每次广播都全群扫描一遍成员表。我的方案是群成员状态改变时做缓存标记广播时只筛选出需要推送的成员列表。小程序和App端推送触达是企业IM必不可少的需求但这里先只在实时推送层面处理推送服务单独拆出去做。3.3 私聊与点对点消息的STOMP实现虽然标题主打群聊但提醒本质上是点对点推送私聊场景在实际企业IM里也绕不开。私聊的双人会话就是一个小群带宽上的瓶颈远小于群聊但实现上有自己的独特问题私聊时消息应该只发送给会话里的两个人不能广播到全局。使用convertAndSendToUser可以严格做到按用户隔离推送。MessageMapping(/chat.private) public void handlePrivateChat(Payload ChatMessage chatMessage, Principal principal) { Long conversationId chatMessage.getConversationId(); Integer senderId Integer.parseInt(principal.getName()); chatMessage.setSenderId(senderId); messageService.saveMessage(chatMessage); conversationService.updateLastMessage(conversationId, chatMessage.getContent()); Integer receiverId chatMessage.getReceiverId(); messagingTemplate.convertAndSendToUser(String.valueOf(receiverId), /queue/private, chatMessage); messagingTemplate.convertAndSendToUser(String.valueOf(senderId), /queue/private, chatMessage); }注意convertAndSendToUser的目标地址前缀前端订阅时需要订阅/user/queue/private。此时用户自身的身份标识已经由Principal对象提供不需要前端再传senderId防止伪造。为了保险起见服务端收到消息后还是要校验当前登录用户是否真的是会话成员不是成员直接抛出异常拒绝投递。这个坑我见过不止一次有的项目在私聊消息里带上receiverId但没有校验receiverId和会话成员的关系结果通过构造请求就能给任何人发私信。3.4 提醒的异步处理和离线补偿提醒的处理我拆成了两个模块实时推送和离线存储。实时推送是在广播群聊消息的同时对被的用户单独发一条STOMP点对点消息前端收到提醒消息后展示系统通知或者弹气泡。离线存储是在数据库里为每个用户维护一张提醒表记录“谁在某群聊里提到了我”当用户上线时拉取未读提醒。简化的提醒表结构可以复用im_message里的mentioned_user_ids字段但真要做好还是需要独立的提醒查询接口。离线补偿的核心在于会话列表页和提醒的高度重复。当用户上线后前端通常第一件事就是拉取会话列表然后发现某个会话未读数很高点进去才知道自己被了。这个体验不够明显尤其当未读数很多时。理想的产品设计是提醒单独列一栏有未读的要红色高亮提示。所以独立提醒表设计成下面这样。CREATE TABLE im_mention ( id BIGINT PRIMARY KEY AUTO_INCREMENT, message_id BIGINT NOT NULL, conversation_id BIGINT NOT NULL, mentioned_user_id BIGINT NOT NULL, read_flag TINYINT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_read (mentioned_user_id, read_flag) );每次有人发消息时如果带了mentionedUserIds服务端开启异步线程批量插入提醒记录同时通过convertAndSendToUser发送实时推送提醒。异步处理直接用Spring的Async注解配合线程池核心逻辑不要阻塞消息广播的主链路。生产环境如果消息量很大可以用MQ异步解耦但阿里的RocketMQ、RabbitMQ的引入会增加系统复杂度第一版完全可以用线程池做异步落库等消息量上来再考虑MQ。3.5 消息回执的实现思路与数据流转消息回执这种需求数值上分两种一种是群聊里进来新消息后其他成员是否看到了另一种是私聊里对方是否已读。本质上都是同一套机制用im_conversation_member表的last_read_message_id和im_message表里的消息id做差量比对。只要last_read_message_id 某条消息的id就说明这条消息已读。这是IM系统里常见的“水位线”方案跟数据库binlog同步的位点概念很类似。前端什么时候上报已读我做的方案是在会话页面里添加观察者机制当某个会话进入激活状态且停留时间超过3秒后前端拿到该会话当前最新的消息ID调用STOMP服务端接口上报“已读到这条消息”。服务端更新im_conversation_member表的last_read_message_id并把unread_count清零然后向这个会话的其他成员广播一条回执通知。MessageMapping(/chat.read) public void handleRead(Payload ReadReceipt receipt, Principal principal) { Integer userId Integer.parseInt(principal.getName()); conversationService.markRead(userId, receipt.getConversationId(), receipt.getLastReadMessageId()); ReadReceiptNotify notify new ReadReceiptNotify(); notify.setConversationId(receipt.getConversationId()); notify.setUserId(userId); notify.setLastReadMessageId(receipt.getLastReadMessageId()); messagingTemplate.convertAndSend(/topic/read/ receipt.getConversationId(), notify); }消息回执的难点在于已读状态更新后其他成员界面上的“已读1人”、“已读2人”要实时变化。比如群里五个人A发了消息B读了之后C的界面就要显示“B已读”。这依赖上述广播机制但广播给所有成员显然没考虑那些不在会话里的人。后来我做了优化广播回执前检查在线用户列表只给在线的会话成员推送不在线的等下次上线时拉取会话状态时自动同步。这种优化能省很多无效推送尤其当群成员很多时。我踩过的一个大坑是回执的乱序问题。前端上报已读是有可能并行到达的服务端如果只按收到的顺序更新水位线后到的低ID可能覆盖先到的高ID导致已读位置倒退。解决办法很简单更新时加一个判断只有新上报的ID大于当前last_read_message_id时才更新。UPDATE im_conversation_member SET last_read_message_id ?, unread_count 0 WHERE conversation_id ? AND user_id ? AND last_read_message_id ?这句SQL的where条件就是矛与盾的结合如果新水位线比旧的小UPDATE影响行数为0不会污染数据。3.6 前端关键代码与联调技巧即使后端逻辑完美前后端联调才是真正耗费心力的环节。我先给了一套基于stompjs和sockjs-client的前端基础代码协议栈顺序是先SockJS再重新包装成STOMP客户端。这里直接以现代浏览器的原生WebSocket连接方式为例因为用SockJS时浏览器地址会变得很绕不好直接分析。import SockJS from sockjs-client; import { Client } from stomp/stompjs; const client new Client({ webSocketFactory: () new SockJS(http://localhost:8080/ws-im), connectHeaders: { Authorization: Bearer localStorage.getItem(token) }, reconnectDelay: 5000, heartbeatIncoming: 4000, heartbeatOutgoing: 4000 }); client.onConnect () { // 订阅群聊频道 client.subscribe(/topic/chat/1001, (message) { console.log(收到群聊消息, JSON.parse(message.body)); }); // 订阅个人提醒 client.subscribe(/user/queue/private, (message) { console.log(收到私聊或提醒, JSON.parse(message.body)); }); }; client.activate();connectHeaders就是之前服务端UserChannelInterceptor里取token的Header设置heartbeatIncoming和heartbeatOutgoing各4000毫秒意思是客户端每4秒发一个心跳、服务端每4秒也发一个心跳。这个间隔不是随意定的太短会造成无效的网络包频繁冲击太长则不能及时发现死连接。实际运营经验来看3到5秒比较合适太长会让Nginx等代理层的空闲超时把连接回收掉。联调时初始阶段最容易遇到404问题症状是WebSocket连着连着突然跨域错误或者握手失败。遇到404先检查服务端端点路径是否一致——我见过前端连/ws、后端注册/ws-im、中间没有映射的情况也见过端口不一致导致所有请求打到别的服务上的情况。接着检查SpringBoot的日志如果看到“Failed to handshake”多半是allowedOriginPatterns没写对或者是Spring Security拦截掉了CONNECT帧。建议联调时后端先把Spring Security关了等所有消息通道走通后再开安全拦截器。4. 常见问题与排查技巧实录4.1 连上就断心跳之间的拉锯战接入之后第一个高频反馈是“刚连上就断了”或者“每隔几分钟就掉线重连”。这类问题排查时套路是有顺序的先看客户端日志有没有Reconnect字样再看服务端日志有没有Connection closed最后再看代理层日志有没有Idle timeout。前端环境里这个体验最明显WebSocket连接由浏览器发起Nginx接收后转发给后端。Nginx默认的proxy_read_timeout是60秒如果后端在一分钟内没有任何数据返回Nginx就会主动断开连接。而群聊不是每分钟都有消息的如果没有心跳机制60秒一到就被掐断。我的解决方式是在Nginx配置里同时增加proxy_read_timeout和proxy_send_timeout到600秒并开启WebSocket升级相关的Header。location /ws-im/ { proxy_pass http://localhost:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 600s; proxy_send_timeout 600s; }即使没有Nginx直连SpringBoot时也会因为装载了STOMP心跳而自动协商保持连接但生产环境基本不可能不用Nginx所以这一段是必备配置。还有一点要注意的是SpringBoot应用本身对WebSocket设置session超时时间。stomp session默认值是60秒还是10分钟会根据版本变化建议在WebSocketConfig里显式配置。registry.addEndpoint(/ws-im) .setAllowedOriginPatterns(*) .setHandshakeHandler(new DefaultHandshakeHandler()) .withSockJS() .setDisconnectDelay(30 * 1000);setDisconnectDelay只对SockJS生效控制SockJS连接僵死时的关闭延迟。了解就行它通常不是断线主因。真正的主因百分之八十是代理超时优先排查Nginx。4.2 广播风暴一条消息被群成员重复收到有次压测时发现500人的大群发一条消息数据库和消息中间件压力骤增频繁出现重复日志。排查出来是前端订阅姿势不对。前端把某个群聊的通知和提醒都订阅了一遍还可能在多个组件里重复创建STOMP连接。统计发现一条群聊消息被同一个用户接收了3次甚至5次群成员数一乘服务端压力就爆炸了。处理方法是前端规范化STOMP连接管理整个应用只维护一个全局STOMP客户端所有页面组件通过单例或状态管理共享不要每次进入会话页面都new一个Client。订阅方面只保留一个订阅回调地址其他组件用事件总线分发消息。服务端防止重复推送的逻辑也值得做广播前检查消息ID同一处理的同一个topic订阅最多产生一条消息。但真正根因还是前端这里提出来给做前端联调的同学排雷。4.3 在线用户状态显示不准确界面上看到的在线状态不靠谱一会儿在线一会儿离线白名单同事的体验是“他明明在电脑前面头像却是灰的”。即时通讯系统的在线状态本质上就是依赖心跳和会话的活跃度。实际排查发现很多人把浏览器切到后台Tab页时浏览器会暂停定时器执行心跳的发送也被暂停了。如果在后台停留时间超过服务端心跳检测阈值服务端会判定连接超时并关闭会话用户再切回来界面就显示离线了。这个很难从服务端根上解决用STOMP心跳本身就依赖浏览器JS的运行Tab在后台被冻结时JS根本没机会跑。客户端侧的替代方案是监听页面的visibilitychange事件页面变为可见时立刻手动调用client.deactivate()再activate()重新建立连接跟手机App退后台断网再回前台重连的逻辑一样。在线状态的最终判定建议设置宽松一些服务端10秒没有心跳才标记离线而前端60秒内有过交互都算在线。具体业务具体调整但别把超时设得太短企微钉钉这类系统的在线状态也不可能是实时秒级切换的很多都有几十秒的延迟容忍。4.4 水平扩展时消息广播如何不重复不丢失单机部署一切顺利系统要横向扩容压测时问题就来了两台应用实例都在运行用WebSocket连接的用户可能连着实例A也可能连着实例B。如果某个用户在群聊里发了一条消息这条消息只存到了数据库广播时却只调用了本机的convertAndSend那么连着实例B的用户完全收不到实时推送。这就是典型的“广播只在单机可见”的问题。要解决它依赖一个全局的消息通道。Spring的STOMP消息代理功能里启用外部Broker比如RabbitMQ能解决这个问题但配置复杂度直接上一个台阶。轻量级方案则是用RedisPubSub做消息转发每台应用实例都订阅同一个Redis频道当A实例收到群聊消息并落库后把消息发布到Redis同时所有实例都订阅这个Redis频道B实例收到后再把它转换成STOMP推送发给自己节点上在线的订阅者。实现上通过RedisMessageListenerContainer监听频道在onMessage里调用SimpMessagingTemplate.convertAndSend推给本机连接。Component public class RedisMessageSubscriber implements MessageListener { Autowired private SimpMessagingTemplate messagingTemplate; Override public void onMessage(Message message, byte[] pattern) { String payload new String(message.getBody(), StandardCharsets.UTF_8); RedisMessageDTO dto JSON.parseObject(payload, RedisMessageDTO.class); if (CHAT.equals(dto.getType())) { messagingTemplate.convertAndSend(/topic/chat/ dto.getConversationId(), dto.getData()); } } }单机模式下不需要Redis发布订阅因为本机的convertAndSend自己就能广播给本机连接但为了以后扩容我建议从一开始就统一走Redis分发这样扩容时代码零改动。代价仅仅是每条消息多一次Redis发布订阅的开销对企业IM系统来说完全可以接受。4.5 会话列表的SQL性能与未读数即时更新会话列表页是IM应用里访问频率最高的接口之一几乎每次App启动、每次进入首页都要拉取一次。初次实现时直接查im_conversation_member表再join im_conversation再做子查询算最后一条消息用户量一旦过万就明显变慢。MySQL分析后慢查询SQL集中在orderby和groupby会话列表接口平均耗时300多毫秒不可接受。优化策略是先把im_conversation表里的last_message和last_message_at冗余字段用起来。会话列表只需要查询当前用户所属的会话ID、名称、最后一条消息内容和时间不再需要实时去消息表里count和max。会话状态未读数、置顶、免打扰在im_conversation_member表里查再加上start index。SQL拆分后复杂度大幅下降。SELECT c.id, c.name, c.type, m.unread_count, m.last_read_message_id, c.last_message, c.last_message_at FROM im_conversation_member m INNER JOIN im_conversation c ON m.conversation_id c.id WHERE m.user_id ? ORDER BY c.last_message_at DESC LIMIT 20;这看起来平淡无奇但结合未读数的快速更新能解决用户最常吐槽的“我看过消息了角标还在”问题。前文消息回执里我们说已读上报时会更新水位线会话列表要实现即时刷新就必须在更新回执后把未读计数清零再通过Redis或直接通过会话列表接口套上时间戳下发。如果用户停留在会话列表页前端需要由STOMP主动推动一条“会话状态更新事件”来驱动刷新这也是我为什么把回执广播到/topic/read/{conversationId}的原因——这个topic可以触发前端各个页面的状态联动刷新。5. 离在线消息与消息补偿机制的补充5.1 离线消息拉取策略用户重新上线后除了订阅实时推送还需要拉取离线期间的消息。这是IM系统的基本要求。实现方式简单直接客户端登录成功后向服务端请求“增量消息拉取”接口。增量消息的判断基础就是im_conversation_member表里的last_read_message_id。用户在线期间实时推送的消息实时更新离线期间的消息则用这个ID去消息表拉取。public ListChatMessage pullOfflineMessages(Integer userId, Long conversationId) { ConversationMember member conversationMemberMapper.selectByUserAndConversation(userId, conversationId); if (member null) { return new ArrayList(); } return messageMapper.selectMessagesAfter(conversationId, member.getLastReadMessageId()); }批量拉取时按会话分组拉取不要一条一条请求。假如用户离线期间有10个会话各产生了20条消息一次拉取全部会话的全部增量消息HTTP接口返回一个MapconversationId, List 结构前端再分发给各路组件。这个设计从一开始就要搭好否则后面增加群人数和消息频率时会反复改接口结构。拉取完增量消息后前端要把最后一条消息的ID作为nextCursor存下来下次再拉取就直接增量。这跟翻页的思路类似但是状态是连续增长的。此时必须在服务端做幂等防止前端重复拉取同一条数据导致重复渲染。5.2 重连期间消息补偿与幂等WebSocket连接不是永远稳定的用户在地铁上、网络切换过程中都会触发断线重连。重连期间如果恰好有群聊消息被实时广播那这个用户就漏掉了。因此重连成功后前端需要做一次主动拉取消息的动作保证漏掉的消息被补偿回来。最简单有效的做法是每次STOMP客户端onConnect成功之后调用一次增量拉取接口。这个动作是幂等的服务端会根据last_read_message_id过滤不会重复返回已读消息。复杂点在于消息实时推送和增量拉取之间可能产生交叉重复——推送刚发出来还没到达前端增量拉取已经返回了包含这条消息的数据。所以前端要把每条消息里的clientMsgId维护成一个Set重复消息直接丢弃。这个去重机制前面消息结构体设计时提到过这里是它最重要的应用场景。没有这套去重断线重连一次就重复展示一遍用户体验极其糟糕。5.3 消息已读回执的批量SQL优化群里聊天每个人都读了消息后要给发送者或群成员推送“XX已读”状态如果每条已读都单独UPDATE一次高并发下数据库扛不住。实测一个200人群如果200人几乎同时上线并读取新消息数据库会瞬间产生几百上千条UPDATE。我把已读上报的SQL改成批量模式前端上报时不只上报一个lastReadMessageId而是上报一个“已读到某时间点之前的所有消息”时间戳服务端按会话把所有早于该时间戳的未读消息一次性标记已读。这个方法屡试不爽。更进一步用Redis的Hash结构缓存会话的已读水位线。例如key为conversation:read:{conversationId}field为userIdvalue为已读位置。更新时先写Redis再由定时任务异步刷新到MySQL。这样在线用户的已读状态变化非常快而数据库的压力能平摊到秒级批量提交。不过这个方案引进了Redis和MySQL的一致性维护问题需要接受Redis丢了可以从MySQL兜底重建。企业IM场景可以接受这个策略。6. 部署、安全以及上线前的注意事项6.1 从开发到生产Nginx、端口和内网穿透开发时localhost直连很省事但生产环境部署涉及到的网络拓扑比想象中复杂。最常见的部署形态是Nginx位于前端统一暴露443或者自定义端口WebSocket请求通过Nginx反向代理到内网的后端端口。Nginx必须开启Upgrade头还要处理WebSocket的长期存活问题。另一个坑是容器化部署时Docker的端口映射。docker run时只映射了8080端口但WebSocket连接是从Nginx容器转发到后端容器的端口如果没配对好也会出现连不上。我建议部署时先不用Docker网络直接用宿主机端口验证连通性通了再考虑容器编排。SpringBoot应用自己也可以嵌入一个WebSocket端点端口和HTTP端口相同。如果同时开启了Tomcat的HTTPS和HTTPWebSocket会强制走HTTPS升级此时浏览器连接也要用wss协议前缀。生产的WebSocket地址形态一般是wss://im.example.com/ws-imwss对应TLS加密和https一样需要证书。证书过期、域名不匹配都会导致握手失败这个问题非常容易出现在联调阶段建议上线前先检查证书链是否完整。6.2 认证与权限控制STOMP协议本身不提供认证能力认证的职责完全落在服务端。有两种做法第一种是连接时通过CONNECT帧的Header携带token用ChannelInterceptor解析第二种是先走HTTP登录接口获取token再携带token建立WebSocket连接。我说的更稳妥的做法是两者都做——HTTP登录先确认身份WebSocket连接时再校验token是否有效。注意不要在前端URL后面拼token那样token很容易被Nginx日志、浏览器历史记录甚至CDN链路记录下来几乎等于明文泄露。WebSocket连接建立后的权限控制也是需要提前设计的。比如用户A拉一个群如果直接向群里任何人mention或者调用私聊接口必须验证A确实是该会话的成员。我在MessageMapping方法里都调用了conversationMemberMapper.checkMembership如果返回空直接抛出异常拒绝处理。这个校验在第一个版本就能写不要拖到上线后补。6.3 连接数预估和线程池调优WebSocket是长连接不像HTTP那样按请求取消释放。单个Tomcat的线程模型在大量WebSocket连接下要重点考虑内存和线程占用。SpringBoot内嵌Tomcat时每个WebSocket连接会占用一个NIO连接但Tomcat的NIO线程池默认值是200。这200个线程是所有HTTP和WebSocket共享的如果同时有大量HTTP请求和大量在线WebSocket连接就会出现可用线程不足导致连接超时。我们压测后把Tomcat的maxThreads调到500但注意线程数也不是越大越好因为每个线程都有栈空间500个线程对内存的占用已经不小了要根据机器规格动态调整。更精细化的控制还有Servlet容器的异步请求超时设置。WebSocket的升级请求本身是异步处理如果异步超时设置过短可能导致数据还没传输完就被断开。这些参数建议在压测阶段就充分暴露而不是上线后让用户反馈。长连接服务必须做容量评估这个经验传递给初次搭IM的团队会非常有价值。6.4 发版与调试的杀手锏服务端发版升级时正在连接的WebSocket会话会被强制断开。如果客户端有自动重连机制回连会瞬间产生高负载。我后来养成一个习惯发版前先通过Redis标记服务“停服维护”前端检测到连接断开后弹出友好提示而不是疯狂自动重连轰炸服务端。维护结束后清掉标记用户点重连按钮即可。这就是优雅停机和主动降级的思路在IM这类高实时性的服务里必不可少。调试时可以启用WebSocket的底层日志。SpringBoot里设置logging.level.org.springframework.web.socketDEBUG前端在stompjs里设置client.debug (msg) console.log(msg)。能看到STOMP帧的内容对排查问题极其有帮助比如某个消息是否真的发到了/topic/chat/1001这个地址。线上环境不要打开debug日志日志量太大了但开发环境开着debug信息写代码能看透整个消息流转路径。最终经验总结如果你只是做Demo或内部工具这套方案已经绰绰有余。真要支撑上万人的企业IM还需要引入更完整的消息可靠性机制比如端到端加密、多端同步、消息搜索引擎等但核心的通信骨架已经足够稳了。我做这个项目时最大的感受是IM系统的复杂度不是高在通信协议本身而是高在产品需求和技术方案的夹缝里。你以为在写WebSocket其实在写会话列表的SQL优化你以为在调心跳参数其实在调Nginx超时时间。这种全链路的问题沉淀比任何八股文都值钱。最后一个小技巧送给大家STOMP的广播地址和订阅地址之间一定要保持严格一致前端订阅/topic/chat/1001后端广播/topic/chat/1002看起来只差一个数字定位起来可能要花一整天。建议前后端把destination地址统一维护在一个常量文件里每次修改同步进行。我对这套方案的最大信心来自它的简洁和通用——技术选型没有追新求异每个环节都是成熟方案的组合。如果你也想在企业系统里加IM能力从这套方案起步应该能少走不少弯路。