读书笔记:大多数人焦虑的根源,不是"我没有答案",而是"我用所有人都在用的答案,期待一个非凡的结果"。
每个人都告诉你:好好工作、稳定成长、不要冒险、跟随主流。
然后每个人都得到了:平庸的工作、缓慢的成长、对冒险的恐惧、被主流裹挟的人生。
一、消息乱序问题的由来
1.1 消息时序在 IM 链路中的位置
1.2 时序错乱的三种故障形态
二、消息乱序的三个陷阱
2.1 消息时序的三个可验证设计目标
2.2 陷阱一:不可信的客户端时间戳
2.3 陷阱二:多端并发的时序裁决
2.4 陷阱三:群消息扇出后的副本乱序
2.5 端到端时序保障链路
三、大厂如何设计
3.1 某信的用户级 seq 与 seqsvr
3.2 某钉的用户维度位点与同步队列
3.3 企某信的用户消息流模型
3.4 融某云的时间戳后延裁决
3.5 四家锚点方案的横向对比
四、如何优化提升
4.1 锚点位置与字段设计的优先级
4.2 多端发送的时序去交错
4.3 重投乱序与幂等去重
4.4 接收端的排序兜底
4.5 时序故障的可观测体系
一、消息乱序问题的由来
凌晨一点,运营甩来一张截图:群里两条消息显示顺序是 A → B,但 A 的语义明显在回复 B —— 时间穿越了。
翻消息表,按 msgSeq 排能对上业务因果,按 ctime 排却整个倒过来。这就是"消息时序错乱"最常见的样子。
很多人第一反应是"加个时间戳排序不就行了"。但用谁的时间戳?
- 客户端的会被用户改、会跨时区、会被 NTP 校准;
- 服务端多个网关实例本地时钟也对不齐。
你以为在解决一个排序问题,碰到的其实是分布式共识问题 —— 一群机器要对"哪条消息在前"达成一致,而手上没有一只共同的表。在中等规模 toB IM 项目,"消息时序"的坑集中在三处:
① 客户端时钟不可信 —— 用户改时间、跨时区、NTP 漂移、睡眠唤醒
② 多端并发发送 —— 同一发送方在手机和 PC 同时发,谁先到服务端不确定
③ 扇出与重试乱序 —— 群消息扇出顺序不定、MQ 重投把"旧消息"塞回队尾
三个陷阱单独看都不致命,叠加起来"对方收到的顺序"就和"发送方以为的顺序"对不齐了。
IM 最伤信任的体验问题不是"丢消息",是"顺序错" —— 丢一条可以补,顺序错了整段对话读不通。
1.1 消息时序在 IM 链路中的位置
你和好友发了三条:
你:「吃饭了吗」--1
好友:「刚吃完」--2
你:「吃啥呢」----3
正常顺序是 1→2→3,但对方手机上可能显示成 3→1→2 —— 三条消息走不同节点、不同网络路径,到达时间各异,这是分布式系统的常态。所以"消息顺序"不是一个比大小的问题,是若干个分布式参与者对"事件先后"的共识问题。
图 1. 消息时序的"锚点"位置。序号服务是唯一可信的时序基准,客户端时间只能作辅助。
在工程实践过程中,这是三个问题:锚点放哪一层、冲突时听谁的、乱序到达怎么办(接收端收到 [seq=5, seq=3, seq=4] 怎么呈现)。后面三个陷阱正好对应这三问。
1.2 时序错乱的三种故障形态
对时序问题视而不见,下面三种故障会反复出现,每一种都精准打到用户信任:
故障形态
表现
根因
撤回先于消息显示
先看到「XX 撤回了一条消息」,过几秒才看到原消息
撤回事件比原消息先到,按 ctime 排倒过来
会话列表跳动
A 群跳到第一行,刷新一下又掉回去
不同设备给同一条消息打的时间戳不一样
多端历史不一致
PC 端看到 X 在 Y 之前,手机切回来 X 在 Y 之后
两端用不同字段排序,兜底逻辑不一致
单看每条都"还能用",但共同杀伤力是同一句话:"这 IM 怎么连消息顺序都搞不清楚" —— 用户一旦这么想,再多功能也救不回来。
二、消息乱序的三个陷阱
2.1 消息时序的三个可验证设计目标
先把"对方收到的顺序正确"翻译成三个能写进测试用例的目标:
- 同一会话内强保序:A 给 B 连发 3 条,B 端看到的必须是 A 发出的顺序 —— 硬约束。
- 跨会话弱保序:A 给 B 发一条、又给 C 发一条,两条之间不需要全局保序 —— 强行做全局有序是为假需求买单。
- 多端最终一致:同一接收方在手机和 PC 上看到的同一会话顺序必须一致,即使中间出现过临时乱序。
第三点要留意:分布式系统没办法保证所有端在任意时刻顺序都一样,能保证的是收敛 —— 等消息到齐后各端排序一致。把目标定成"任意时刻都一样",会掉进永远填不平的坑。
2.2 陷阱一:不可信的客户端时间戳
新人做消息排序,第一反应几乎都一样:消息体不是带了 ctime(client time)吗,拿它 sort 一下。
// 反例:用客户端 ctime 做主排序键
render_message_list(messages):
messages.sort_by(m -> m.ctime) // 客户端时间不可信
测试机上跑得好好的,上线后用户跨时区出差本地时间跳 8 小时,消息全跑到聊天列表最顶;iOS 用户为薅签到把时间调到去年;安卓深度睡眠唤醒后被 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 怎么生成又是一道选择题,三种主流实现各有取舍:
实现
优势
代价
Redis INCR + 会话锁
简单、强一致
Redis 是瓶颈,高并发下锁等待严重
号段模式(Camellia / Leaf)
性能好,本地缓存号段
服务重启可能浪费号段,但保证单调
Snowflake 类
完全本地生成
不保证会话内连续,要额外维护"会话维度有序"
没有最优解,只有演进路径:早期用 Redis INCR,单会话写入压力变大后切号段。客户端 ctime 也不是彻底没用 —— 它能和 clientMsgId 一起做去重辅助 key,边界是参与去重可以、排序不行。
2.3 陷阱二:多端并发的时序裁决
把锚点收到服务端,陷阱一就解决了?没那么快。用户 A 同时登录 PC 和手机,PC 上敲完一句话按回车,几乎同一瞬间手机误触发出一条语音,两条几乎同时到达服务端 —— 哪条先?
直觉是在网关接收那一刻打时间戳谁小谁先。但 PC 走 WiFi、手机走 4G,两条消息很可能落到两个不同的网关实例上,实例间本地时钟差几毫秒到几十毫秒,打的本地时间根本没有可比性;退回去用客户端时间戳,陷阱一又在等着。多端并发场景里"谁先发"本身就不成立 —— 没有任何一只表能权威回答它。
既然没有共同的表,那就只设一只表。 把"打时序锚点"收敛到一个会话级单点(通常就是序号服务):网关只做转发不打时序;同一会话消息用 HASH(conversationId) 路由到同一处理点;序号服务内部用锁串行化分配保证 msgSeq 严格单调。就像电影院检票,观众从不同门进场但座位按票号定。
图 2. 多端并发发送的串行化路径。MQ 的 HASH 分区让"同会话进同 partition",序号服务的会话锁负责按序分配。
这里藏着一个反直觉的取舍:这两条消息谁的 msgSeq 更小本质上是不确定的,取决于受网络延迟影响的 MQ 入队顺序。但这没关系:只要顺序定下来全系统都承认它,体验就是一致的 —— 一致比符合直觉更重要。
2.4 陷阱三:群消息扇出后的副本乱序
单聊的多端问题解决了,群聊还有一道更隐蔽的坎。群里 A 连发「1」「2」「3」,多数成员收到的就是 1→2→3,但翻日志会发现某几个成员收到的可能是 1→3→2 —— 只在弱网、高负载下偶发,最难排查。
写扩散模型下,一条群消息到服务端会被拆成 N-1 个成员副本(扇出账单在本系列 01 篇算过),这 N-1 个副本命运各自独立 —— 走不同 partition、被不同消费线程处理、经过不同网络路径。
// 反例:扇出失败简单重投,不考虑 seq 已经跳变
on_fanout_failure(member_copy):
requeue(member_copy) // 原 seq=5 丢到队尾,而 seq=6、7 已先到接收方 → 乱序
一条副本失败重投被丢到队尾,而 seq=6、7 早就先到接收方 —— 单成员视角下的乱序就这么产生。解法分两层:
第一层:服务端保证"同会话、同 partition、同消费线程"。 用 HASH(conversationId) 做 partition key 配合顺序消费。MQ 的三档顺序保证里,全局顺序焊死吞吐、无序拿不到保序,中小 toB IM 几乎都选分区顺序 —— 把同一会话的消息绑到同一 partition 拿到会话级有序。
第二层:接收端做兜底重排。 MQ 只能做到"同会话内基本有序",重投和网关延迟仍会让接收端 seq 出现短时空洞,客户端要做最后一道 buffer:
// 接收端:seq 连续性兜底
on_message_arrive(msg):
if msg.msgSeq == expected_seq:
deliver(msg); expected_seq += 1; try_flush_buffer()
else if msg.msgSeq > expected_seq:
buffer.put(msg.msgSeq, msg)
if waited_too_long(): // 超时兜底,避免空洞永久卡住
pull_missing_from_history() // 主动拉历史补空洞
else:
log_duplicate(msg) // seq 比期望小 → 重复,丢弃
核心是短时等待 + 超时拉取:等待覆盖 MQ 的短暂乱序,超时则主动拉历史,避免一条消息永久丢失把后面全部显示堵死。重试还有个前提是幂等 —— 接收方靠 (conversationId, msgSeq) 识别"重试副本"而非"新消息",已处理过的直接 ACK 丢弃。
2.5 端到端时序保障链路
把三个陷阱的对策串成一张端到端时序图:
图 3. 消息时序的端到端骨架。陷阱在哪解决就标在哪一段:
网关不打时序,解决陷阱一;
同会话串行分配 seq 解决陷阱二;
分区有序加客户端兜底重排,解决陷阱三。
三、大厂如何设计
挑四家有公开技术资料的国内厂商。它们都不信客户端时间戳,分歧在锚点的粒度和形态。
3.1 某信的用户级 seq 与 seqsvr
某信的序列号生成器 seqsvr 每天万亿级调用,核心性质是:每个用户拥有独立的 64 位 sequence 空间,分配出去的序列号稳定递增、不回退。支撑高并发的关键是预分配中间层 —— 内存里存当前 cur_seq 和上限 max_seq,越过 max_seq 才按步长(1 万)提升上限并持久化,把硬盘 IO 从 10^7 QPS 压到 10^3 QPS。代价是重启后第一次分配会跳一个步长空洞,但只要求递增不要求连续,无害。这个 seq 还兼作数据版本号。
优势
代价
用户级 seq 让多端增量同步极简;预分配把存储 IO 压低几个数量级
序号服务需单元化部署;群消息要给每成员各分一个 seq,资源消耗高
3.2 某钉的用户维度位点与同步队列
某钉的即时消息服务 DTIM 走用户维度,锚点叫位点(PTS,Point To Sequence)。要推给某用户的消息、已读事件、会话增删改入库时原子地分配一个按用户维度单调递增的位点;服务端为每个用户维护一个 FIFO 同步队列按位点推送,客户端 ACK 后服务端记下该设备已推到的最大位点续推。这把"消息时序"和"多端同步"统一成按位点消费一条 FIFO 流。注意位点是用户维度而非会话维度。
优势
代价
时序与多端同步合一,模型统一;每设备独立位点,断点续传清晰
用户维度单点位点是热点,需专门水平扩展;所有会话混在一条流,单会话范围查询要额外索引
3.3 企某信的用户消息流模型
企某信的模型一句话:每个用户一条独立消息流,同一条消息的多份副本分存在每个接收者的流里,流内每条消息有单调递增的 seq。它同样自建 SeqSvr 保证 seq 单调不回退。万人群是值得一提的取舍 —— 企某信公开分析过"把超大群做成一条流、成员都来同步"的扩散读方案,因多条流各自维护 seq、客户端卸载重装不知有哪些流等难点放弃扩散读,仍走扩散写并以群 id 限制并发度。也就是说,它在所有会话类型上不引入第二套锚点。
优势
代价
用户消息流模型简单清晰,客户端"最大 seq 增量拉"逻辑统一;所有会话类型共用一套锚点
扩散写下序号和存储成本随群规模上升;万人群扩散耗时长,需并发限制压峰、牺牲及时性
3.4 融某云的时间戳后延裁决
融某云的亿级 IM 走另一条路 —— 不靠单调序号,而靠时间戳加一套"冲突后延"裁决规则。它按 userId 把同一账号的消息归属到固定服务器,上行时依据"该用户最后一条消息的时间戳"确认当前时间戳 —— 若与已有的相同就往后延,直到不重复,下行时再做一次同样判断。处理后同一会话内时间戳严格有序。消息量大的场景用"直发 + 通知拉取",和前两家"按 seq 增量拉"殊途同归。
优势
代价
时间戳自带可读性,无需独立序号服务;同端固定路由让上行排序简单
"冲突后延"在高并发同会话下时间戳会持续漂移偏离真实时间;同账号固定路由削弱网关层无状态弹性
3.5 四家锚点方案的横向对比
维度
某信
某钉
企某信
融某云
时序锚点
用户级 64 位 seq
用户维度位点 PTS
用户消息流 seq
userId 维度时间戳
锚点形态
单调递增整数
单调递增整数
单调递增整数
时间戳 + 冲突后延
多端同步
按最大 seq 增量拉
每设备独立位点游标
按最大 seq 增量拉
按本地最新时间戳拉
超大群处理
写扩散 + 用户级 seq
位点流统一承载
扩散写 + 并发限制
直发 + 通知拉取
独立序号服务
是(seqsvr)
是(位点分配)
是(SeqSvr)
否(消息服务器内生成)
共性很扎实:四家都不拿客户端时间戳做主排序,都把锚点收敛到"用户/收件箱"粒度。分歧只在锚点用序号还是时间戳、按用户还是按会话维度 —— 没有标准答案,取决于群规模、多端形态和团队能扛多重的工程复杂度。
四、如何优化提升
中小规模 toB IM 想把"消息顺序"做扎实,从来不是某一处改个时间戳能解决的。
4.1 锚点位置优先于字段
先决定锚点放哪一层,再决定字段怎么设计。 常见反例是早期用 ctime 排序,跑起来才发现不可信,回头往服务端加 stime + msgSeq —— 这时库里已躺着几百万条没有 msgSeq 的历史消息,迁移成本翻倍。判断标准很简单:只要业务里有"消息列表展示",从 day 1 就该让服务端打 msgSeq。
生成方式上号段模式是性能和一致性平衡得最好的。早期可先用 Redis INCR + 会话锁,单会话写入到每秒上百条就该切号段。切换唯一红线是号段不能回退 —— 常见坑是重启后号段游标丢了,从库里重读到上个号段的过期上限,新 seq 比重启前发出去的还小。Camellia 等成熟号段库对此有现成处理。
4.2 多端发送去交错
多端发送的乱序,某钉"用户维度位点 + 同步队列"值得借鉴。中小项目负担不起整套同步队列,但可借走内核思路 —— 让客户端把不同端的本地操作收敛到同一个会话视角。落地是:客户端收到回执时带回服务端打的 msgSeq,多端再通过同步通道互相通知,手机端拿到权威 seq 就能把 PC 端消息插进对的位置。隐藏陷阱是**"已发送"状态在多端之间也要一致** —— 解法是客户端本地维护 clientMsgId 状态机,pending / sent / synced 三态分别在本地确认、收到服务端 ack、收到多端同步通知时切换。
4.3 重投乱序与幂等
陷阱三那个"重投扰乱序"是个隐蔽杀手。根本解法一句话:把"消息身份"和"消息序号"绑死,同一个 (conversationId, msgSeq) 在任何环节只代表同一条逻辑消息。工程上三件事缺一不可:所有消费者用消息身份做幂等判重;死信队列带"时序断点告警",接收方发现 seq 不连续且持续超 N 分钟单独告警——它往往意味着某条副本永久丢失;接收端 buffer 超时时间可配置,弱网用户需更长窗口。
4.4 接收端排序兜底
再完善的服务端时序保证,到客户端只要排序逻辑写错一行就立刻清零。 接收端是用户体感的最后一道防线,最值得做的是"按 msgSeq 重排 + 空洞主动拉"。这里有三个细节容易被疏忽:
- buffer 不能无限大,否则恶意客户端永不出队就能吃光内存;
- 超时要分级,首条空洞短等待、仍空洞升级更长窗口、再不行 fallback 到"完整拉最近 N 条";
- 拉历史接口要按 seq 范围而非时间范围 —— 时间范围在跨时区下又会把 ctime 请回来。
4.5 时序故障可观测
时序故障最难的不是修,是发现 —— 一条乱序从用户感知到研发定位可能横跨好几小时。三个最值得加的指标:
- 接收端 seq 空洞率(不连续次数 / 总消息数,超万分之一就回查);
- 序号分配 P99 延迟(锁等待直接转化成消息延迟,同时监控锁超时率);
- MQ 重投率分桶(重投率上升是乱序的前置信号,通常比实际乱序早几分钟)。
三个搭起来,时序类 P0 的定位能从"翻日志半小时"压到"看大盘几分钟"。
消息顺序这件事的工程哲学,是让所有人都同意一个并不完美但确定的顺序,而不是去追那个永远抓不到的"真实顺序"。