消息显示已发送,为什么对方手机里还是空的
IM 消息系统要解决的不是“把一条消息推过去”,而是让发送端、接收端和服务器对同一段对话的顺序达成一致。服务器为每条消息分配会话内递增的 seq,持久化之后才回 ACK;客户端带着自己的同步游标拉增量,推送只是提示,拉取才是账本。最常见的误区是把“服务端已接收”当成“对方已收到”,或者指望端到端 exactly-once,实际上只能做到至少一次投递加幂等去重。读完你能说清会话序号、设备游标、已读水位三者的分工,也能判断消息丢失、重复、乱序分别该在哪一层修。
是什么
IM 消息系统不是“客户端 A 把一段字符串送到客户端 B”这么一条线,而是一套让多个端对同一段对话达成一致的记账系统。一句话直觉:每个会话是一本只能往后追加的账本,服务器是唯一的记账员,每条消息落账时领到一个会话内递增的序号(seq);在线推送只是提醒“账本有新页”,真正对账靠客户端拿着自己的游标去拉。
它要同时满足四件事:消息不丢、不重、不乱,以及多端看到同一份历史。常见场景包括单聊、群聊、离线消息、消息漫游、多设备登录、已读回执和撤回。这些功能看起来彼此独立,底层其实是同一个问题:当网络会断、进程会崩、推送会丢时,怎样让所有端最终收敛到同一串消息、同一个顺序、同一个已读位置。
核心原理
先看写路径。发送端为每条消息生成一个 client_msg_id(本地 UUID 就够),经长连接送到接入层;接入层只负责维护连接、心跳、鉴权和转发,不碰业务。逻辑层收到后做三件事:按 client_msg_id 查重、为会话分配 seq、把消息写进存储。事务提交成功,才把带 msg_id 和 seq 的 ACK 回给发送端。这个顺序不能反——先 ACK 后落库,进程一崩,客户端以为发成功了,消息却不存在,这是最严重的一类丢消息。
分配 seq 是整套设计的支点。会话内必须有一个所有端都认的先后,而客户端时钟不可信:两台手机差几秒、用户手动改时间、同一毫秒内两条消息,都会让“按时间排”出现歧义。于是把排序权收归服务器:同一会话的写请求串行经过一个序列化点——按 conv_id 路由到固定分片、单线程 actor,或者带行锁的计数行——谁先拿到 seq,谁就排在前面。不同会话之间不需要全局有序,也最好不要:一个严格全局递增的序列很容易变成写热点,而“张三和李四的私聊”与“产品群”之间本来就没有先后可言。
这里要区分两种序号,面试里很容易混。会话 seq 决定一条消息在某个会话里的位置,用来排序、分页、去重;同步 seq 是给设备游标用的版本号,可以把新消息、已读、撤回、会话创建等变更编成一条流。微信公开分享过的 seqsvr 按用户维度维护 64 位递增 sequence,客户端带着已同步的最大值来拉增量——它回答的是“这个用户还有哪些变更没同步”,不等于“这条消息在会话里排第几”。两套序号职责不同,可以合,但不能互相替代。
读路径同样分两层。接收端在线时,逻辑层从路由表查到它连在哪个接入节点,把消息推过去;至少一台在线设备回了接收 ACK,服务端才把状态标成“已送达”。但推送不可靠:手机进电梯、系统杀后台、长连接被 NAT 超时踢掉,消息都可能没到。所以每个设备还要维护同步游标,上线或重连时拉取所有大于游标的变更,按 seq 应用,再把游标推进到服务端返回的水位。推是提示,拉是账本:推送负责低延迟,拉取负责最终一致。只推不拉,一次断线就可能永久缺一条消息;只拉不推,就退化成轮询。
多设备的关键也在这里:同步游标必须按设备存,不能按用户存。手机和平板各自知道自己看到哪一页,互不覆盖;已读位置则按用户存,取多端的最大值——一台设备读到第 100 条,另一台不该再提示 1 到 99 条未读。已读不是给每条消息打一个布尔值,而是一条单调不减的水位线;这样即使回执乱序到达,取 max 也不会回退。
存储上,消息表以 (conv_id, seq) 为主键,按 conv_id 分片,一个会话的数据落在同一分片,翻历史就是一次局部范围扫描。分页用 seq < cursor ORDER BY seq DESC LIMIT n,不要用 offset:offset 越大越慢,而且新消息插入会让页边界漂移,出现重复或漏项。热数据留在近期消息表,冷数据归档,按需回源。
下面这段客户端同步循环把三件事串起来:按游标拉、按 seq 幂等落库、以服务端水位判断是否追平。
def sync(stream_id, cursor, limit=200):
while True:
resp = api.pull(stream_id, after_seq=cursor, limit=limit)
if not resp["messages"]:
break
for m in sorted(resp["messages"], key=lambda x: x["seq"]):
if m["seq"] <= cursor: # 重叠区间重发,按 seq 幂等跳过
continue
db.upsert("message", m) # 唯一键 (conv_id, seq)
cursor = m["seq"]
if cursor >= resp["latest_seq"]: # 到水位即停,不假设 seq 连号
break
db.save_cursor(stream_id, cursor) # 游标落后最多一次重拉
return cursor
注意终止条件是 cursor >= latest_seq,不是“这批不足 limit 条”。号段预分配会烧掉一些号,事务失败也会留下空洞,seq 本来就可能不连续;客户端若看到 7 后面是 9 就认定 8 丢了,会永远等一条不存在的消息。判断有没有追平,只看服务端返回的水位;判断有没有重复,只看唯一键。允许跳号、禁止回退和重复,是序列号生成器的两条底线。
最后看“不重”。发送端超时重试、ACK 在回程丢失、推送重发,都会让同一条消息到达多次。端到端的 exactly-once 投递做不到:服务端提交成功但 ACK 丢了,发送端只能重试;接收端处理完但接收 ACK 丢了,服务端也只能再发。工程上的答案是至少一次投递加幂等:发送侧用 (sender_id, client_msg_id) 唯一索引,重复请求直接返回第一次的 msg_id 和 seq;接收侧用 (conv_id, seq) 唯一索引,重复投递走 upsert。两次“至少一次”叠起来,效果上就是一次。
常见考点与误区
- 误区一:把服务端 ACK 当成对方已收到。 IM 至少有三层确认:服务端持久化 ACK、接收端送达 ACK、用户已读回执。第一层只证明“账记下了”,不证明“人看到了”。把三者混成一个 success,离线、免打扰、多设备场景全会解释错。
- 误区二:追求端到端 exactly-once。 跨网络、跨进程没有原子提交,ACK 丢失后发送方必须重试,重复是设计的一部分。正确说法是“至少一次投递 + 幂等消费 = 效果上一次”,而不是“消息绝不重复”。
- 误区三:用时间戳排序。 客户端时钟可改、可漂移,同一毫秒还会并列;时间适合展示,排序必须用服务端分配的会话 seq。多设备同时发消息时,顺序由服务端到达顺序决定,不是谁先按发送键。
- 误区四:所有会话共用一个全局自增 ID 当顺序。 全局序列是写热点,跨会话排序也没有业务意义。正确做法是按 conv_id 分片、会话内单点序列化;全局唯一 msg_id 可以有,但它不负责会话内的先后。
- 误区五:推送成功就不用拉取。 长连接会断、系统会杀后台、推送会丢;设备重连后必须从游标补拉。只靠推送的系统,用户换一次网络或离线一夜就会缺一段消息,而且缺了以后没有机制能发现。
- 误区六:撤回就是把数据库里的行删掉。 删除只影响后续读取,已经同步到本地的端不会消失;正确做法是追加一条带新 seq 的撤回事件,让所有端按同一顺序应用。编辑同理,改历史不如追加新版本。
实践建议
- 序列号用号段预分配。 服务启动时从持久化存储领一段(比如十万个),内存里发完再领下一段;崩溃只浪费一段号,不会回退。对 seq 做启动校验和持续监控,发现回退立即告警并停止分配,不要带病运行。
- 客户端本地库给 (conv_id, seq) 建唯一索引,写入一律 upsert;游标和消息放在同一个本地事务里,先落消息再推游标。发送侧每条消息固定一个 client_msg_id,超时重试用同一个 ID,不要每次重试都新生成。
- 存储按 conv_id 分片,主键 (conv_id, seq),历史分页用 seq 游标;热表只留最近一段时间的消息,冷数据归档。不要用 offset 分页,也不要在消息表上跑 COUNT 算未读,未读数用最大 seq 和已读水位相减估算。
- 推送只当通知。 可以带轻量 payload 降低延迟,但重连后一律走 sync 补洞;心跳间隔按移动网络的 NAT 超时调,重连用指数退避加抖动,避免机房故障恢复时所有客户端同时重连打垮接入层。
- 群聊按规模分流。 小群写扩散到成员收件箱,大群读扩散或按活跃度合并;但无论哪种,群内顺序都由群会话的 seq 决定,扩散策略只改投递成本,不该改正确性。
- 把可观测性做在会话维度。 记录每个会话的最大 seq、各设备游标、ACK 延迟、重复率和补拉次数;发现某设备游标长期落后或重复率突升时,先查连接与存储,不要先怀疑客户端。
一句话总结:IM 的可靠性不是保证每条消息都送到,而是服务器只追加、客户端带游标对账、重复交给幂等消化。