做IM的同学应该都懂,用户一句“消息怎么乱了”能在排查群里引发多大动静。明明是A先发的消息,B后发的回复,到了对方手机里顺序反了,或者两个人在群里同时说话,不同成员看到的先后还不一样。这个问题在单机架构下不难控制,一旦上了分布式,客户端连的节点不一样、主从切换、网络重连、消息走异步通道,任何一个环节没收敛好,顺序就会被打散。我最早在分布式IM聊天系统里处理消息有序性时也踩了不少坑,后来梳理清楚几个关键点,才发现核心其实就三件事:单会话/单群内要有确定的逻辑序号,接收端要有能力重排和补拉,以及所有环节都不能用“时间”当排序依据。这篇就把我在生产环境里的实践方案、选型理由和排查经验一次说清。
1.1 先搞清楚什么才叫“消息有序”
很多人一上来就说“我要保证消息全局有序”,这话基本等于没说。分布式系统里,让所有消息在全网范围内排出一个唯一先后顺序,成本极高,而且绝大多数IM场景根本不需要。用户关心的有序性,落在产品层面其实就两条:
- 单聊场景:同一个会话里,我发出去的每条消息,在对端看到的顺序必须和我发送的顺序一致。
- 群聊场景:群内不同成员发出去的消息,所有接收端最终看到的排序结果要一致,至少因果上不能乱。
专业一点讲,就是需要保证全序或者因果序。单聊属于天然的因果序场景,A消息是B消息的前提,B必须在A后面出现;群聊场景稍微复杂,比如B引用了A的消息进行回复,那B必须排在A后面,两条互不相关的消息C和D,谁先谁后其实没有硬性要求,但为了体验一致,还是应该让所有人看到同一个版本。
真正要做的是:在一个会话或一个群的时间线维度内,给每条消息一个全局递增且不重复的逻辑序号,所有端按这个序号排序展示。网络传输可以乱序到达,但最终呈现必须按逻辑序号收敛。
1.2 乱序产生的三类典型场景
我在实际排查中把乱序问题分为三类,每类的根源和处理思路都不同,分享出来方便你对号入座。
第一类是接入层乱序。客户端和服务端之间不是只有一条长连接,可能因为网络切换、断线重连而换一个接入节点;即使不换节点,长连接上消息也是异步到达的,TCP只保证字节流顺序,不保证上层业务消息的处理顺序。如果服务端接入层收到消息后立即并行处理,那么“先发”的消息可能因为业务处理慢而比“后发”的消息更晚落库,顺序就反了。
第二类是存储/路由层乱序。消息处理节点有多副本,同一个会话的请求可能被负载均衡到不同实例;或者消息经过消息队列异步消费,同一个会话的消息被分发到多个分区、多个消费者并发处理,都可能打乱顺序。
第三类是客户端展示层乱序。服务端其实按顺序推下来了,但客户端多通道接收,比如实时推送走长连接、离线补拉走HTTP接口,两个通道的消息到达时间不同;又或者客户端本地用消息里的时间戳排序,而不同设备的时间有偏差,也会看起来“乱”。
还有一个很容易被忽略的隐藏场景:服务端主从切换、进程重启后,消息序号如果回退或者复用,接收端会看到旧消息“重新冒出来”,这也是乱序的一种表现,而且危害更大,因为会导致展示和数据错乱。
1.3 有序性的三个层次
想明白上面这些场景,可以把问题抽象成三个层次:
- 发送序:客户端在本地为每条消息生成一个递增的发送序号(client_seq)
- 接收序:服务端在落库或转发时为每条消息分配一个全局递增序号(server_seq)
- 因果序:消息之间存在引用/回复关系时需要额外满足的依赖顺序
任何一端都不能单独解决乱序问题。只靠客户端发送序,解决不了多端同步时各端本地序列不同的问题;只靠服务端接收序,解决不了客户端两条消息在本地显示时网络延迟乱序的感知问题;不处理因果序,群聊里引用回复会变得很诡异。所以正确姿势是:服务端分配单调递增序号作为唯一排序键,客户端在此基础上做重排、去重和补拉,因果序作为附加约束在服务端或客户端特殊处理。
2. 单聊场景:从连接层到服务端如何保证不乱
单聊消息严格来说只需要保证一对一会话内的顺序一致性,在分布式架构下,最核心的手段是让同一个会话的写请求收敛到一个确定的处理单元,并在该单元内为消息分配递增序号。
2.1 会话路由:把同一个会话固定到同一套节点
先看最基础的一层:接入层和后端业务层之间不能随便负载均衡。假设有两个后端实例,用户A的连续两条消息,一条被负载均衡打到实例1,一条打到实例2,两个实例各自给消息分配序号,先发的那条在实例2上可能因为处理慢,反而拿到更大的序号,顺序就乱了。
所以生产环境里,我会按 session_id(单聊的会话ID可以由两个用户ID做字典序拼接得到)做一致性哈希路由,保证同一个会话的请求永远落在同一个处理分片上。这个处理分片可能是一个独立实例,也可能是实例内部一个串行化的处理线程。简单说,就是把并发写变成单点写,只有单点才能给出确定的顺序。
一致性哈希选型的原因很明确:普通哈希取模在节点扩缩容时,大部分会话都会迁移,迁移期间缓存失效、路由抖动都容易引发问题;一致性哈希让只有少数会话受影响,配合虚拟节点还能缓解数据倾斜。不过要注意,一致性哈希解决的是路由稳定性问题,不解决某个分片过载的问题,所以单聊场景还要配合按会话维度的限流。
2.2 消息序号:会话维度的发号器设计
会话路由固定之后,核心问题变成了“怎么给消息分配一个递增且不重复的序号”。我见过不少团队直接拿数据库自增ID当消息序号,这在单库时代勉强能用,一旦分库分表,
