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 Map<Object, 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 路由是否有过期和心跳刷新
跨节点投递是否具备幂等处理
这些问题不一定影响演示效果,但会影响真实运行。
即时通讯源码真正要解决的是上线后的不确定性:弱网、重连、多端、群聊、高频消息、跨节点、历史同步、状态一致性。
如果底层链路没有设计好,后期遇到消息丢失、未读异常、多端不同步、群聊延迟时,排查成本会越来越高。
更多推荐



所有评论(0)