WebSocket 宠友 IM即时通讯源码架构复盘,消息丢失、未读异常和多端不同步为什么总在上线后出现

📅 2026/7/3 7:49:02
WebSocket 宠友 IM即时通讯源码架构复盘,消息丢失、未读异常和多端不同步为什么总在上线后出现
IM即时通讯系统真正难的地方往往不是把消息显示在聊天窗口里而是让每一条消息在复杂环境下都能被正确处理。用户不会关心底层用了 WebSocket、Socket、Redis 还是 MySQL。用户只会感知到消息有没有及时到达未读数是不是准确换到 PC 端后聊天记录是否还在手机断网后重新打开会不会漏消息群聊里多人同时发言时顺序会不会乱。这些问题一旦进入真实场景就很难再用“补一个接口”解决。因为即时通讯系统不是一次性请求响应而是一条持续运行的实时链路。客户端连接可能随时断开消息可能重复发送用户可能多端同时在线群聊消息可能瞬间影响大量成员服务端节点也可能因为集群部署分散在不同机器上。在这种场景下源码结构如果只围绕页面功能组织而没有提前处理连接路由、消息确认、会话序号、读取位置、离线同步和状态事件后期就容易出现一类很棘手的问题表面上看是某个端显示异常实际根因却藏在消息链路中间。比如消息已经写入数据库但没有投递到目标连接PC 端已经读过消息但手机端未读数没有更新群消息已经发送成功但部分成员重新登录后没有补齐用户以为消息发送失败于是重复点击服务端却生成了两条记录。这类问题通常不是单个功能缺失而是 IM 底层链路没有形成闭环。所以分析 WebSocket 即时通讯源码时不能只看聊天页面、群聊入口、文件发送、红包消息、朋友圈这些表层能力更需要看底层是否能回答几个关键问题消息发出去后服务端有没有确认连接断开后客户端能不能补拉缺失消息用户多端在线时状态会不会互相覆盖群聊消息增多后写入压力会不会失控消息撤回、已读回执、收藏变更能不能同步到所有端故障现场一消息明明发出去了对方却说没收到这种问题最难排查因为它不一定是“消息真的丢了”。在即时通讯系统里一条消息至少会经过这几层客户端本地消息 WebSocket / Socket 长连接 服务端协议解析 业务权限校验 消息编号 消息落库 Redis 在线路由 目标端投递 ACK 确认 多端同步任何一层状态异常用户看到的结果都可能是“消息没收到”。比如客户端点击发送后页面先把消息展示出来但服务端没有返回 ACK。用户以为消息已经成功实际上服务端可能根本没有完成落库。再比如消息已经写入数据库但接收方连接路由过期实时推送失败。此时如果没有离线同步机制接收方就会感觉消息丢失。还可能是接收方其实收到了但客户端本地去重逻辑写错把服务端消息当成重复消息丢掉了。所以即时通讯源码要解决的不是“发消息接口能不能调用”而是要让每一条消息都能被追踪。比较稳妥的处理方式是发送中客户端本地生成 clientMsgId 已接收服务端返回 ACK 已入库服务端生成 serverMsgId 已投递接收端连接收到消息 已读接收端上报读取位置如果没有clientMsgId弱网重试容易重复。如果没有serverMsgId撤回、引用、收藏、转发、历史查询都会变得混乱。如果没有 ACK客户端永远不知道消息到底停在哪一步。技术诊断消息可靠性不是靠一次推送完成的即时通讯消息不能只依赖一次 WebSocket 推送。WebSocket 推送只是实时通道不等于可靠存储。可靠链路至少需要三件事第一发送端要有本地临时消息。用户点击发送后客户端可以先展示“发送中”但不能直接认为成功。第二服务端要有幂等判断。弱网下客户端可能重复发送同一条消息服务端需要通过senderId clientMsgId判断是否已经处理过。第三接收端要能补拉消息。实时推送失败时接收端重新上线后应该可以根据最后同步位置拉取缺失消息。消息表可以围绕这几个查询场景设计CREATE TABLE im_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, server_msg_id VARCHAR(64) NOT NULL, client_msg_id VARCHAR(64) DEFAULT NULL, conversation_id VARCHAR(64) NOT NULL, sender_id BIGINT NOT NULL, msg_seq BIGINT NOT NULL, msg_type VARCHAR(30) NOT NULL, content JSON NOT NULL, status TINYINT DEFAULT 0, send_time BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_server_msg_id (server_msg_id), UNIQUE KEY uk_conversation_seq (conversation_id, msg_seq), KEY idx_sender_client (sender_id, client_msg_id), KEY idx_conversation_time (conversation_id, send_time) );这里的重点不是字段数量而是索引方向。server_msg_id用来定位单条消息。conversation_id msg_seq用来保证会话内排序和分页。sender_id client_msg_id用来处理客户端重复发送。消息可靠性不是靠前端提示“发送成功”而是靠服务端可追踪、可补偿、可查询的完整链路。故障现场二未读数总是不准未读数问题通常会被误判成前端显示问题。实际上未读数是即时通讯系统里非常典型的状态一致性问题。常见错误做法是收到一条消息前端未读数加一打开会话前端未读数清零。这种方式在单端测试时没问题但多端登录后很快会失控。例如用户在 PC 端打开会话 手机端角标是否要同步减少 用户在 H5 页面读完消息 App 后台未读数是否还显示 用户退出群聊 之前的群未读数是否还计算 用户删除会话 新消息到来后会话是否重新出现这些逻辑如果只靠客户端本地维护会出现大量边界问题。更稳定的方式是维护“读取位置”而不是只维护一个数字。conversationId group_10086 userId 20001 lastReadSeq 1588 maxSeq 1600 unread maxSeq - lastReadSeq用户在任意端读到某个位置后服务端更新lastReadSeq再把已读事件同步到其他端。这样 PC、Android、iOS、H5 才能围绕同一个服务端状态展示未读数而不是各自算各自的。技术诊断未读数的核心是会话状态不是消息状态很多系统会把未读状态放在消息上例如给每条消息加一个 read 字段。但在群聊场景下这种方式很难扩展。一个群里有 500 个成员每个成员读到的位置都不同。如果每条消息都记录所有成员是否已读数据量和查询成本都会迅速增加。更合理的设计是会话表记录会话最新消息位置 会话成员表记录每个用户读到哪里 消息表只记录消息本身 Redis缓存高频未读计数或会话状态也就是说消息表回答“发生了什么”会话成员状态回答“某个用户读到了哪里”。这种拆分可以同时解决单聊、群聊、多端登录、断线重连后的未读数同步问题。故障现场三PC 有消息手机没有消息这种问题在多端即时通讯里很常见。原因通常不是手机端页面没写好而是服务端在线状态设计过于简单。如果在线状态只保存成userId - channelId那么用户在 PC 登录后可能会覆盖手机端连接。服务端再推送消息时只能找到最新的 channelId手机端自然收不到实时消息。多端即时通讯应该保存成userId 10001 android - im-node-01 / channel-a ios - im-node-02 / channel-b h5 - im-node-03 / channel-c pc - im-node-04 / channel-d这样服务端才能知道用户同时在哪些设备在线每个设备连接在哪个节点。Redis 在线路由可以按用户和设备维度维护Service public class ImRouteCache { private final StringRedisTemplate redisTemplate; public ImRouteCache(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public void bind(Long userId, String device, String nodeId, String channelId) { String key im:online: userId; String value nodeId : channelId; redisTemplate.opsForHash().put(key, device, value); redisTemplate.expire(key, Duration.ofMinutes(10)); } public MapObject, Object getOnlineDevices(Long userId) { return redisTemplate.opsForHash().entries(im:online: userId); } }普通聊天消息、撤回事件、已读回执、收藏变更、群资料更新都需要考虑是否同步到多个端。多端即时通讯不是“能在多个端登录”就够了重点是多个端看到的状态是否一致。技术诊断长连接管理要考虑节点而不是只考虑用户单机部署时服务端可以直接从内存里找到用户连接。但集群部署后用户可能连接在不同节点上。用户 A - im-node-01 用户 B - im-node-03 用户 C - im-node-02用户 A 给用户 B 发消息时im-node-01必须知道用户 B 在im-node-03再把消息转发过去。如果没有连接路由系统可能只能广播到所有节点。这种方式早期能用但节点多了以后会浪费资源也不利于排查。更合理的是发送节点查询 Redis 在线路由 判断目标用户所在节点 同节点直接投递 跨节点走内部转发 目标离线则等待同步集群环境下在线状态、节点 ID、连接 ID、设备类型、心跳时间都应该是可追踪的。否则一旦出现消息延迟或某端收不到消息很难判断到底是连接断了、路由过期了还是跨节点转发失败了。故障现场四群聊一多系统就开始慢群聊带来的压力不在“发出一条消息”而在“一条消息影响多少用户”。一个 10 人群和一个 1000 人群对后端压力完全不同。群聊里一条消息可能触发消息写入 群成员校验 在线成员推送 离线成员同步 未读数更新 成员提醒 多端状态刷新 历史消息查询如果每条群消息都为每个成员写一份完整记录群越大写入越重。更合理的设计是消息正文只写一份。会话内维护递增 msgSeq。群成员维护自己的 lastReadSeq。在线用户实时推送。离线用户上线后按 seq 补拉。成员单独生成提醒事件。这样做可以把消息内容和成员状态拆开避免群规模直接放大消息表。技术诊断群聊要优先控制写扩散群聊通常有两种思路写扩散和读扩散。写扩散是发送时就给每个成员写好数据。读扩散是消息只写一份用户读取时根据会话和成员状态计算。小群里写扩散比较直观但大群里写入压力会明显增加。即时通讯源码如果要支持更高并发需要尽量避免无限制写扩散。尤其是群消息、未读数、提醒、多端同步这些状态最好拆成不同层处理而不是全部写进一张消息表。故障现场五后期加业务时越来越不敢改很多即时通讯源码早期看起来完整但项目推进一段时间后会遇到另一类问题改不动。新增一种消息类型要改协议、改数据库、改前端渲染、改历史记录、改收藏、改转发。调整群规则要改消息发送、群成员校验、禁言逻辑、通知同步。接入文件存储要改消息体、上传接口、权限校验、下载逻辑。增加音视频会议要改通话状态、房间信令、成员事件、多端通知。问题不是功能不能写而是边界不清楚。即时通讯系统需要把几个层次拆开长连接层只处理连接、心跳、推送 协议层只处理命令和消息结构 业务层处理好友、群、权限、状态规则 存储层处理消息、会话、成员、流水 缓存层处理在线状态、路由、未读数 扩展层处理文件、音视频、内容流等独立能力这样后期新增消息类型、接入 PC 端、调整群聊规则、增加会议信令时不会牵一发而动全身。源码可维护性不取决于页面数量而取决于通信、业务、存储、缓存之间有没有清晰边界。上线前更应该检查这些技术点与其只检查聊天页面是否完整不如先检查这些问题消息是否有 clientMsgId 和 serverMsgId 服务端是否返回 ACK 重复发送是否会重复落库 消息是否按会话生成 msgSeq 历史消息是否能按 seq 分页 未读数是否基于读取位置 多端在线是否按 device 保存路由 WebSocket 断开后是否能补拉消息 群聊是否避免无限制写扩散 撤回是否通过状态事件同步 文件是否走独立上传链路 音视频是否只走 IM 信令 Redis 路由是否有过期和心跳刷新 跨节点投递是否具备幂等处理这些问题不一定影响演示效果但会影响真实运行。即时通讯源码真正要解决的是上线后的不确定性弱网、重连、多端、群聊、高频消息、跨节点、历史同步、状态一致性。如果底层链路没有设计好后期遇到消息丢失、未读异常、多端不同步、群聊延迟时排查成本会越来越高。https://chongyou.info/1/product/im.html宠友(IM即时通讯)app,支持语音、文件、图片、视频等多种类型消息的发送,安全可靠,交流轻松,私有化部署,快速开发极简部署支持群聊管理、语音视频通话...功能https://chongyou.info/1/product/im.html