默认
打赏 发表评论 1
想开发IM:买成品怕坑?租第3方怕贵?找开源自已撸?别走弯路了... 找站长给点建议
深度剖析IM消息乱序的根本原因及优化方法
阅读(343) | 评论(1) 收藏1 淘帖2 赞1
扫一扫关注!

本文作者拉丁哥,即时通讯网有修订和重新排版。


1、引言


本文深度剖析即时通讯IM消息乱序的问题,梳理IM客户端时间戳不可信、多端并发、群消息扇出重试三大核心陷阱,拆解微信、钉钉、企业微信的时序设计方案,并从锚点设计、多端去交错、幂等兜底、可观测性等维度,给出IM项目消息时序性保证的落地优化策略。

深度剖析IM消息乱序的根本原因及优化方法_cover-opti.png

2、消息息乱序问题的由来


2.1概述


凌晨一点,运营甩来一张截图:群里两条消息显示顺序是 A → B,但 A 的语义明显在回复 B —— 时间穿越了。

翻消息表,按 msgSeq 排能对上业务因果,按 ctime 排却整个倒过来。这就是"消息时序错乱"最常见的样子。

很多人第一反应是"加个时间戳排序不就行了",但用谁的时间戳?

  • 1)客户端的会被用户改、会跨时区、会被 NTP 校准;
  • 2)服务端多个网关实例本地时钟也对不齐。

你以为在解决一个排序问题,碰到的其实是分布式共识问题 —— 一群机器要对"哪条消息在前"达成一致,而手上没有一只共同的表。

在中等规模 toB IM 项目,"消息时序"的坑集中在三处:

  • 1)客户端时钟不可信 —— 用户改时间、跨时区、NTP 漂移、睡眠唤醒;
  • 2)多端并发发送   —— 同一发送方在手机和 PC 同时发,谁先到服务端不确定;
  • 3)扇出与重试乱序 —— 群消息扇出顺序不定、MQ 重投把"旧消息"塞回队尾。

三个陷阱单独看都不致命,叠加起来"对方收到的顺序"就和"发送方以为的顺序"对不齐了。

IM 最伤信任的体验问题不是"丢消息",是"顺序错" —— 丢一条可以补,顺序错了整段对话读不通。

2.2消息时序在 IM 链路中的位置


你和好友发了三条:

你:「吃饭了吗」--1
好友:「刚吃完」--2
你:「吃啥呢」----3


正常顺序是 1→2→3,但对方手机上可能显示成 3→1→2 —— 三条消息走不同节点、不同网络路径,到达时间各异,这是分布式系统的常态。

所以"消息顺序"不是一个比大小的问题,是若干个分布式参与者对"事件先后"的共识问题。

深度剖析IM消息乱序的根本原因及优化方法_1.png

上图是消息时序的"锚点"位置。序号服务是唯一可信的时序基准,客户端时间只能作辅助。

在工程实践过程中,这是三个问题:锚点放哪一层、冲突时听谁的、乱序到达怎么办(接收端收到 [seq=5, seq=3, seq=4] 怎么呈现)。后面三个陷阱正好对应这三问。

2.3时序错乱的三种故障形态


对时序问题视而不见,下面三种故障会反复出现,每一种都精准打到用户信任:
深度剖析IM消息乱序的根本原因及优化方法_2.png

单看每条都"还能用",但共同杀伤力是同一句话:"这 IM 怎么连消息顺序都搞不清楚" —— 用户一旦这么想,再多功能也救不回来。

3、消息时序的三个可验证设计目标


先把"对方收到的顺序正确"翻译成三个能写进测试用例的目标:

  • 1)同一会话内强保序:A 给 B 连发 3 条,B 端看到的必须是 A 发出的顺序 —— 硬约束。
  • 2)跨会话弱保序:A 给 B 发一条、又给 C 发一条,两条之间不需要全局保序 —— 强行做全局有序是为假需求买单。
  • 3)多端最终一致:同一接收方在手机和 PC 上看到的同一会话顺序必须一致,即使中间出现过临时乱序。

第三点要留意:分布式系统没办法保证所有端在任意时刻顺序都一样,能保证的是收敛 —— 等消息到齐后各端排序一致。把目标定成"任意时刻都一样",会掉进永远填不平的坑。

4、陷阱1:不可信的客户端时间戳


新人做消息排序,第一反应几乎都一样:消息体不是带了 ctime(client time)吗,拿它 sort 一下。

1)测试机上跑得好好的,上线后用户跨时区出差本地时间跳 8 小时,消息全跑到聊天列表最顶;
2)iOS 用户为薅签到把时间调到去年;
3)安卓深度睡眠唤醒后被 NTP 强校准 1~2 秒,同一秒内几条消息次序乱掉。

根因一句话:客户端时钟不在服务端控制范围内。 拿它做排序主键,等于把决定权交给一个随时漂移的对抗性环境。

锚点挪到服务端要先想清楚用什么字段。stime(服务端时间戳)和 msgSeq(会话内单调序号)各管一段。

主流 toB IM 两个都打:stime 用于跨会话排序,msgSeq 用于会话内强保序。只用 stime 不够 —— 服务端多实例间本地时钟也有几毫秒到几十毫秒漂移,这是陷阱二的伏笔。

打锚点在序号服务里大致如下:
// 序号服务给消息打锚点on_conversation_topic_consume(msg):    
lock =redis_lock("conv:seq:"+ msg.conversation_id, timeout=5s)
// 会话粒度的锁
try:        
msg.msgSeq =next_sequence(msg.conversation_id)// 单调递增        
msg.stime  =server_now_ms()
finally:        
lock.release()publish_to_router(msg)

msgSeq 怎么生成又是一道选择题,三种主流实现各有取舍:
深度剖析IM消息乱序的根本原因及优化方法_3.png

没有最优解,只有演进路径:早期用 Redis INCR,单会话写入压力变大后切号段。客户端 ctime 也不是彻底没用 —— 它能和 clientMsgId 一起做去重辅助 key,边界是参与去重可以、排序不行。

5、陷阱2:多端并发的时序裁决


把锚点收到服务端,陷阱一就解决了?没那么快。

用户 A 同时登录 PC 和手机,PC 上敲完一句话按回车,几乎同一瞬间手机误触发出一条语音,两条几乎同时到达服务端 —— 哪条先?

直觉是在网关接收那一刻打时间戳谁小谁先。

但 PC 走 WiFi、手机走 4G,两条消息很可能落到两个不同的网关实例上,实例间本地时钟差几毫秒到几十毫秒,打的本地时间根本没有可比性;退回去用客户端时间戳,陷阱一又在等着。多端并发场景里"谁先发"本身就不成立 —— 没有任何一只表能权威回答它。

既然没有共同的表,那就只设一只表。

把"打时序锚点"收敛到一个会话级单点(通常就是序号服务):

  • 1)网关只做转发不打时序;
  • 2)同一会话消息用 HASH(conversationId) 路由到同一处理点;
  • 3)序号服务内部用锁串行化分配保证 msgSeq 严格单调。就像电影院检票,观众从不同门进场但座位按票号定。

深度剖析IM消息乱序的根本原因及优化方法_4.png

上图是多端并发发送的串行化路径。MQ 的 HASH 分区让"同会话进同 partition",序号服务的会话锁负责按序分配。

这里藏着一个反直觉的取舍:这两条消息谁的msgSeq更小本质上是不确定的,取决于受网络延迟影响的 MQ 入队顺序。但这没关系:只要顺序定下来全系统都承认它,体验就是一致的 —— 一致比符合直觉更重要。

6、陷阱3:群消息扇出后的副本乱序


单聊的多端问题解决了,群聊还有一道更隐蔽的坎。

群里 A 连发「1」「2」「3」,多数成员收到的就是 1→2→3,但翻日志会发现某几个成员收到的可能是 1→3→2 —— 只在弱网、高负载下偶发,最难排查。

写扩散模型下,一条群消息到服务端会被拆成 N-1 个成员副本(扇出账单在本系列 01 篇算过),这 N-1 个副本命运各自独立 —— 走不同 partition、被不同消费线程处理、经过不同网络路径。

一条副本失败重投被丢到队尾,而 seq=6、7 早就先到接收方 —— 单成员视角下的乱序就这么产生。

解法分两层:

第一层:服务端保证"同会话、同 partition、同消费线程"。 用 HASH(conversationId) 做 partition key 配合顺序消费。MQ 的三档顺序保证里,全局顺序焊死吞吐、无序拿不到保序,中小 toB IM 几乎都选分区顺序 —— 把同一会话的消息绑到同一 partition 拿到会话级有序。

第二层:接收端做兜底重排。 MQ 只能做到"同会话内基本有序",重投和网关延迟仍会让接收端 seq 出现短时空洞。

以上“第二层”中客户端要做最后一道 buffer:
深度剖析IM消息乱序的根本原因及优化方法_5.png

核心是短时等待 + 超时拉取:等待覆盖 MQ 的短暂乱序,超时则主动拉历史,避免一条消息永久丢失把后面全部显示堵死。重试还有个前提是幂等 —— 接收方靠 (conversationId, msgSeq) 识别"重试副本"而非"新消息",已处理过的直接 ACK 丢弃。

7、端到端时序保障链路


把三个陷阱的对策串成一张端到端时序图:
深度剖析IM消息乱序的根本原因及优化方法_6.png

上图是消息时序的端到端骨架。

陷阱在哪解决就标在哪一段:

  • 1)网关不打时序,解决陷阱一;
  • 2)同会话串行分配 seq 解决陷阱二;
  • 3)分区有序加客户端兜底重排,解决陷阱三。

8、大厂如何实现消息顺序的


挑四家有公开技术资料的国内厂商。它们都不信客户端时间戳,分歧在锚点的粒度和形态。

8.1某信的用户级 seq 与 seqsvr


某信的序列号生成器 seqsvr 每天万亿级调用(参见《微信的海量IM聊天消息序列号生成实践》),核心性质是:每个用户拥有独立的 64 位 sequence 空间,分配出去的序列号稳定递增、不回退。

支撑高并发的关键是预分配中间层 —— 内存里存当前 cur_seq 和上限 max_seq,越过 max_seq 才按步长(1 万)提升上限并持久化,把硬盘 IO 从 10^7 QPS 压到 10^3 QPS。代价是重启后第一次分配会跳一个步长空洞,但只要求递增不要求连续,无害。这个 seq 还兼作数据版本号。

深度剖析IM消息乱序的根本原因及优化方法_7.png

8.2某钉的用户维度位点与同步队列


某钉的即时消息服务 DTIM 走用户维度(参见《深度揭秘钉钉即时消息服务DTIM的技术设计》),锚点叫位点(PTS,Point To Sequence)。

要推给某用户的消息、已读事件、会话增删改入库时原子地分配一个按用户维度单调递增的位点;服务端为每个用户维护一个 FIFO 同步队列按位点推送,客户端 ACK 后服务端记下该设备已推到的最大位点续推。

这把"消息时序"和"多端同步"统一成按位点消费一条 FIFO 流。

注意:位点是用户维度而非会话维度。

深度剖析IM消息乱序的根本原因及优化方法_8.png

8.3企某信的用户消息流模型


企某信的模型一句话:每个用户一条独立消息流,同一条消息的多份副本分存在每个接收者的流里,流内每条消息有单调递增的 seq。(参见《企业微信的IM架构设计揭秘》)

它同样自建 SeqSvr 保证 seq 单调不回退。

万人群是值得一提的取舍 —— 企某信公开分析过"把超大群做成一条流、成员都来同步"的扩散读方案,因多条流各自维护 seq、客户端卸载重装不知有哪些流等难点放弃扩散读,仍走扩散写并以群 id 限制并发度。也就是说,它在所有会话类型上不引入第二套锚点。

深度剖析IM消息乱序的根本原因及优化方法_9.png

8.4三家锚点方案的横向对比


深度剖析IM消息乱序的根本原因及优化方法_10.png

共性很扎实:四家都不拿客户端时间戳做主排序,都把锚点收敛到"用户/收件箱"粒度。分歧只在锚点用序号还是时间戳、按用户还是按会话维度 —— 没有标准答案,取决于群规模、多端形态和团队能扛多重的工程复杂度。

9、保证消息时序的优化方法


中小规模 toB IM 想把"消息顺序"做扎实,从来不是某一处改个时间戳能解决的。

9.1锚点位置与字段设计的优先级


先决定锚点放哪一层,再决定字段怎么设计。

常见反例是早期用 ctime 排序,跑起来才发现不可信,回头往服务端加 stime + msgSeq —— 这时库里已躺着几百万条没有 msgSeq 的历史消息,迁移成本翻倍。

判断标准很简单:只要业务里有"消息列表展示",从 day 1 就该让服务端打 msgSeq。

生成方式上号段模式是性能和一致性平衡得最好的。早期可先用 Redis INCR + 会话锁,单会话写入到每秒上百条就该切号段。切换唯一红线是号段不能回退 —— 常见坑是重启后号段游标丢了,从库里重读到上个号段的过期上限,新 seq 比重启前发出去的还小。Camellia 等成熟号段库对此有现成处理。

9.2多端发送的时序去交错


多端发送的乱序,某钉"用户维度位点 + 同步队列"值得借鉴。

中小项目负担不起整套同步队列,但可借走内核思路 —— 让客户端把不同端的本地操作收敛到同一个会话视角。

落地是:客户端收到回执时带回服务端打的 msgSeq,多端再通过同步通道互相通知,手机端拿到权威 seq 就能把 PC 端消息插进对的位置。

隐藏陷阱是:"已发送"状态在多端之间也要一致 —— 解法是客户端本地维护 clientMsgId 状态机,pending / sent / synced 三态分别在本地确认、收到服务端 ack、收到多端同步通知时切换。

9.3重投乱序与幂等去重


陷阱三那个"重投扰乱序"是个隐蔽杀手。

根本解法一句话:把"消息身份"和"消息序号"绑死,同一个 (conversationId, msgSeq) 在任何环节只代表同一条逻辑消息。

工程上三件事缺一不可:所有消费者用消息身份做幂等判重;死信队列带"时序断点告警",接收方发现 seq 不连续且持续超 N 分钟单独告警——它往往意味着某条副本永久丢失;接收端 buffer 超时时间可配置,弱网用户需更长窗口。

9.4接收端的排序兜底


再完善的服务端时序保证,到客户端只要排序逻辑写错一行就立刻清零。接收端是用户体感的最后一道防线,最值得做的是"按 msgSeq 重排 + 空洞主动拉"。

这里有三个细节容易被疏忽:

  • 1)buffer 不能无限大,否则恶意客户端永不出队就能吃光内存;
  • 2)超时要分级,首条空洞短等待、仍空洞升级更长窗口、再不行 fallback 到"完整拉最近 N 条";
  • 3)拉历史接口要按 seq 范围而非时间范围 —— 时间范围在跨时区下又会把 ctime 请回来。

9.5时序故障的可观测体系


时序故障最难的不是修,是发现 —— 一条乱序从用户感知到研发定位可能横跨好几小时。

三个最值得加的指标:

  • 1)接收端 seq 空洞率:不连续次数 / 总消息数,超万分之一就回查;
  • 2)序号分配 P99 延迟:锁等待直接转化成消息延迟,同时监控锁超时率;
  • 3)MQ 重投率分桶:重投率上升是乱序的前置信号,通常比实际乱序早几分钟。

三个搭起来,时序类 P0 的定位能从"翻日志半小时"压到"看大盘几分钟"。

消息顺序这件事的工程哲学,是让所有人都同意一个并不完美但确定的顺序,而不是去追那个永远抓不到的"真实顺序"。

10、参考资料


[1] 什么是IM聊天系统的可靠性?
[2] 什么是IM聊天系统的消息时序一致性?
[3] 微信的海量IM聊天消息序列号生成实践(算法原理篇)
[4] 马蜂窝旅游网的IM系统架构演进之路
[5] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等
[6] 从新手到专家:如何设计一套亿级消息量的分布式IM系统
[7] 企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等
[8] 融云技术分享:全面揭秘亿级IM消息的可靠投递机制
[9] 阿里IM技术分享(四):闲鱼亿级IM消息系统的可靠投递优化实践
[10] 阿里IM技术分享(八):深度解密钉钉即时消息服务DTIM的技术设计
[11] 基于实践:一套百万消息量小规模IM系统技术要点总结
[12] 一套分布式IM即时通讯系统的技术选型和架构设计
[13] 转转平台IM系统架构设计与实践(一):整体架构设计
[14] 移动端弱网优化专题(一):通俗易懂,理解移动网络的“弱”和“慢”
[15] 移动端弱网优化专题(二):史上最全移动弱网络优化方法总结
[16] Web端即时通讯实践干货:如何让你的WebSocket断网重连更快速?
[17] 从客户端的角度来谈谈移动端IM的消息可靠性和送达机制
[18] IM消息送达保证机制实现(一):保证在线实时消息的可靠投递
[19] 移动端IM中大规模群消息的推送如何保证效率、实时性?
[20] 如何保证IM实时消息的“时序性”与“一致性”?
[21] 一个低成本确保IM消息时序的方法探讨
[22] 即时通讯IM离线消息该怎么存?全面盘点主流存储方案
[23] IM千人群聊该不该用消息写扩散?详细算算成本账

即时通讯网 - 即时通讯开发者社区! 来源: - 即时通讯开发者社区!

标签:IM架构
上一篇:IM全文检索技术(五):vivo敏感词匹配系统的技术实践

本帖已收录至以下技术专辑

推荐方案
评论 1
站长、你用ai辅助的、哈哈辛苦了
签名: im从业10年以上,欢迎切磋![url=http://www.52im.net/static/image/smiley/default/handshake.gif]http://www.52im.net/static/image/smiley/default/handshake
打赏楼主 ×
使用微信打赏! 使用支付宝打赏!

返回顶部