做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当消息序号,这在单库时代勉强能用,一旦分库分表,不同库的自增ID会重复和交叉,而且数据库自增ID不暴露“当前分配到哪里”,客户端做缺口检测和补拉时很麻烦。
更好的做法是使用Redis按会话维度递增。比如对单聊会话,使用如下命令:
bash复制INCR im:session_seq:{sessionId}
这个操作是原子的,Redis单线程模型保证了同一个key的递增不会重复。每次写入消息时,先执行INCR拿到当前会话的新序号,再把消息内容、序号、发送时间一起落库。因为同一个会话的写请求已经通过哈希路由收敛到同一个处理分片,配合Redis的原子自增,序号就能严格递增。
如果对Redis的可用性有更高的要求,可以把序号持久化逻辑放到业务侧,维护一个“会话序号表”,每次取号时使用数据库行锁或乐观锁更新当前序号,示例:
sql复制UPDATE session_seq SET seq = seq + 1 WHERE session_id = ? RETURNING seq;
这种方式性能一般,只适合低并发场景。在集群规模较大时,我更推荐自建发号器:在单处理分片的内存里维护一个AtomicLong,每条消息先自增取号,后台异步把“已分配的最大序号”持久化到元数据库;重启时从持久化记录恢复,并向前跳跃一个安全偏移(例如偏移10000),避免因丢日志导致序号复用。这样做会有一些“空洞”,但消息序号不需要连续,只要单调递增就足够了。
这里面有个常见的误区:有人会想用分布式锁把所有消息的取号操作串行化,以保证全局顺序。我个人的建议是别这么干。分布式锁的本质是互斥,用它来串行化所有会话的消息写入,等于把分布式系统退化成单机,吞吐上不去,而且锁的获取释放本身也有开销。正确做法是把锁粒度缩小到会话维度,或者干脆不用锁,用原子自增。
2.3 多副本和重连场景下怎么防回退
单聊场景还有一个足够阴间的坑:主从切换。假设主节点内存里维护的当前序号是100,它分配了消息1到100,但还没把“当前水位”持久化;此时主节点挂了,从节点提升为主,从持久化记录里恢复到的最大序号可能是90,于是新消息从91开始分配。结果客户端已收到100,突然又冒出一个序号91的消息,就出现了“历史消息乱序回退”的诡异现象。
解决这个问题的关键是序号必须和安全水位绑定。每次消息真正落库成功后,才把“已提交水位”推进到该消息序号;新主节点选举后,必须保证分发的序号大于已提交水位。可以简单理解成Raft协议里commit index的思想:只有提交了的日志才算数,选主后不能重复分配已提交区间的序号。
处理节点挂了之后,客户端连到新节点怎么办?客户端侧需要重新做一次会话同步,从上次收到的最后一个序号开始拉取缺失消息。这里要注意,客户端不能用本地时间戳判断要不要补拉,因为每个端的时间可能有偏差,必须以服务端下发的last_server_seq为基准。重连时的补拉逻辑,我会在第四节展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
3. 群聊场景:群维度有序的设计与高并发挑战
群聊和单聊最大的区别,是消息的发送者可能有几十上百个,而且都在同一时刻往同一个“时间线”里写。如果照搬单聊的“会话内串行”,就需要把所有群成员的写入请求都收敛到一个处理单元里,这个单元会变成瓶颈。所以在群聊场景,除了“会话路由+递增序号”,还要额外考虑分片策略、批量取号和因果序。
3.1 写扩散与读扩散,如何影响有序性
先花点篇幅说下群聊的两种常见存储模型,因为有序性设计和存储模型强相关。
- 写扩散(扩散写):用户往群里发消息时,服务端在一条消息上分配群内序号,然后把这条消息复制写入每个群成员的时间线下。优点是每个成员的“收件箱”天然有序,读取时直接按自己的时间线拉取即可;缺点是群成员多的时候,写放大非常严重。
- 读扩散(拉取/时间线):消息只存一份在群消息表里,群成员需要拉取时再读取该群的全量消息或者增量消息。优点是写入成本低,适合大群;缺点是读取时要保证所有成员看到的排序一致,对群消息的序号要求更严格。
很多IM系统实际是混合模式:在线消息通过长连接实时推送(写扩散),离线消息按需拉取(读扩散),同时服务端统一维护一份群消息序号。无论哪种模式,有序性的基石都是群维度的一条递增序号。群消息表中每条消息必须有group_id + server_seq的唯一索引,所有成员最终拉取到的数据,都按照这个server_seq升序排列。
3.2 群消息序号的分配策略
有了群维度递增序号这个前提,下一步是分配策略。我们在线下压测时对比过三种方式,记录下优缺点供你参考。
最直接的方式是用Redis对每个群做INCR,命令就是上的单聊版换成群ID:
bash复制INCR im:group_seq:{groupId}
单群单key,天然有序,简单可靠。缺点是在超级大群(比如上万人的群),Redis单key的写入QPS会成为瓶颈。不过在绝大多数业务里,一个群每秒的消息量远达不到Redis单key的瓶颈,这个方法反而是性价比最高的。
如果单个群的消息量真的非常大,需要引入批量发号。设计思路是:发号器每次给这个群分配一段连续序号,比如当前批次的起始序号为1000,批次大小为100;群内消息先进入一个等待队列,再按顺序从批次号段里领取序号。这样可以减少对Redis的访问频率,但会引入一个复杂度:消息必须在队列里等待,直到拿到合适的序号后才能落库,这会让消息的“服务端确认时间”变长。我一般只在单群TPS超过5000时才会考虑这个方案,常规业务用不上。
第三种方式是使用消息队列的partition key,比如Kafka:
properties复制key = String.valueOf(groupId) # 使用groupId作为分区键
partitions = 12 # 根据流量估算分区数
Kafka保证同一个partition内的消息有序,所以把同一个群的消息都映射到同一个partition,再让消费者单线程处理,就能保证群内消息顺序处理。要注意消费者线程数必须为1,或者至少保证同一个groupId的消息路由到同一个处理线程,否则partition内有序、消费时并发处理又乱了。这个方法好处是利用了Kafka自带的顺序能力,坏处是当群数量远大于partition数量时,一个partition会混合多个群的消息,如果消费者单线程,吞吐量可能不够;如果消费者多线程,就要额外处理“同群消息单线程消费”的逻辑。
我的个人偏好是:对中小规模业务直接Redis INCR + 业务层串行写;对高吞吐需求,走Kafka分区有序 + 本地有界队列,同一个群的写请求通过hash进入同一个本地队列,由专属线程消费。这个组合在保证有序的同时,吞吐量更容易扩展。
3.3 因果序与全局顺序的取舍
群聊里“先A后B”的因果依赖比单聊更常见。比如有人发了一条文件消息,另一个人引用这条文件消息发了句“收到”,如果“收到”先被渲染出来,用户会觉得很奇怪。因果序的要求是:如果消息B在语义上依赖消息A,那么B的序号必须大于A,所有接收端都要看到A在B之前。
在服务端严格实现因果序,需要维护一个依赖关系,在分配序号时检查依赖消息是否已经有序号;如果还没有,B需要等待A完成分发。这个做法在群聊高并发下代价很高,因为等待会让吞吐量锐减,甚至可能造成死循环依赖。所以我的经验是,在IM场景里不要对消息本身做合并/缺失等待,而是把因果序下沉到客户端展示层。
具体做法是:客户端在引用回复时,往往会在消息体里带上被引用消息的ID和序号;如果收到一条引用消息,但被引用的消息还没到达,客户端先展示一个“该消息暂不可见”的占位,等被引用消息补拉到达后,再把引用关系渲染完成。这种思路牺牲了一点实时性,但避免了服务端复杂的依赖排序逻辑。
如果你想在服务端做轻量级因果序保证,另一个可行方案是“延迟判断”:当B消息进入处理分片时,检查其引用的A消息是否已经在本分片内完成序号分配;如果没有,就把B放入一个临时等待区,轮询或者等待通知,直到A完成后再给B分配后续序号。这个逻辑对性能有影响,但适合消息量不大、对引用依赖比较严格的业务,比如客服系统里“工单回复必须晚于原工单”。
关于“所有接收端看到完全一致的顺序”,我建议不要强求全局强一致,而是接受“最终一致”:服务端消息落库顺序是权威,其他端通过补拉和重排逐步收敛。要求所有端在任何时刻都显示相同顺序,技术上需要引入共识算法,成本极高,产品上用户也感知不到那么细微的差别。
4. 客户端兜底:重排、补拉与幂等去重
服务端做得再完善,到了客户端链路,网络抖动、多通道消息、重连同步都可能让消息“乱着到达”。所以客户端必须有兜底策略,否则服务端的有序性设计等于白做。
4.1 接收端重排缓冲区的实现思路
客户端收到消息后,不能拿到就立刻渲染,要先判断这条消息的序号和当前已处理序号是否存在缺口。我用一个TreeMap维护“等待重排的消息”,用lastRenderedSeq表示已经上屏的最大序号,伪代码如下:
java复制class ReorderBuffer {
private long lastRenderedSeq = 0L;
private final SortedMap<Long, Message> pending = new TreeMap<>();
void onReceive(Message msg) {
if (msg.getServerSeq() <= lastRenderedSeq) {
// 重复消息,走幂等去重逻辑
return;
}
if (msg.getServerSeq() > lastRenderedSeq + 1) {
// 出现缺失,先放入缓冲区
pending.put(msg.getServerSeq(), msg);
requestGapFill(lastRenderedSeq + 1, msg.getServerSeq() - 1);
} else {
// 正好连续,直接渲染
deliver(msg);
lastRenderedSeq = msg.getServerSeq();
flushPending();
}
}
private void flushPending() {
while (pending.containsKey(lastRenderedSeq + 1)) {
Message next = pending.remove(lastRenderedSeq + 1);
deliver(next);
lastRenderedSeq = next.getServerSeq();
}
}
}
这段代码看起来很直观,但在生产环境里要加两个细节。一是缓冲区的容量上限。如果某个缺口一直补不上,pending里堆积的消息可能把内存吃爆,需要设置阈值,比如最多缓存200条消息,超过后直接触发全量重新拉取。二是重排等待的时间窗口。正常网络抖动下,缺口几百毫秒就能补上;如果超过1秒还没补上,基本可以断定是服务端推送丢了,客户端要主动走补拉接口。
这里要强调一下:重排缓冲区使用的排序键,必须是服务端下发的server_seq,不要用消息里的客户端时间戳。有两个原因:不同设备的时间不同,时区还容易闹乌龙;即使是同一设备,用户发消息和接收消息的时间也和服务端落库时间不一致。时间戳只用来做展示,不应该参与排序。
4.2 缺口检测与补拉机制
重排缓冲区里发现缺失后,客户端需要主动补拉。补拉接口的设计很关键,我建议接口设计成基于序号的增量拉取:
text复制GET /api/v1/session/{sessionId}/messages?from_seq=100&to_seq=120
返回这个消息范围内所有消息,客户端拿到后再进入重排缓冲区,与实时推送的消息一起按顺序渲染。如果to_seq - from_seq太大,接口需要做分页或截断,避免一次返回太多数据把带宽打满。
补拉还有一个容易踩的坑:补拉请求本身可能和实时推送产生竞态。比如客户端发现缺口是从100到120,于是发起补拉请求,但在补拉响应回来之前,服务端正好又把100到120的消息通过长连接推了一遍。客户端必须对重复收到的消息做去重,不能因为重复就把消息又展示一遍。所以客户端要维护一个receivedMsgIds集合,以消息唯一ID为准,已经处理过的消息直接丢掉。
补拉接口还需要考虑服务端压力。异常情况下,比如服务端推送通道抖动,同一时刻可能有几千上万个客户端同时触发补拉,如果接口没有限流,会把服务端打崩。我习惯在客户端补拉时加入随机延迟,比如在[200ms, 800ms]之间随机等待一段时间,服务端再对同一会话的补拉请求做合并和缓存。
4.3 幂等去重和多端同步时的排序策略
幂等去重这件事,重要的不是“要不要做”,而是“在哪里做”。服务端在落库时应该对客户端生成的消息唯一ID建立唯一索引,防止同一条消息被客户端重试提交时写入多条。客户端在展示层,则要以消息唯一ID为维度做去重,因为重连、补拉、多通道都会导致同一条消息到达多次。
多端同步时还有一个容易被忽视的问题:手机和电脑同时在线,两个端收到消息的先后顺序不一定一致。比如一条群消息,手机端通过长连接实时收到并渲染了,电脑端因为离线和重连,可能先拉取到的是更新的消息,再补拉前面的旧消息。这种情况下,电脑端的重排缓冲区必须发挥作用,不能因为“补拉到的消息比已收到的消息序号小”就丢弃,而是要按照序号插入正确的位置。
我之前还见过一个比较头疼的案例:某个端上线的第一时间,先调用全量拉取接口,把会话所有历史消息一次性拉下来,然后在客户端按“时间戳字符串”做倒序排列,结果不同设备因为时间差异,看到的历史消息顺序不一样。后来改成按server_seq排序,问题立刻消失。所以不管哪个端,排序规则一旦定下来,就要在全端统一。
5. 高并发压测:怎么证明你的消息不乱
功能测试可以证明“正常情况下不乱”,但分布式系统的乱序问题往往只在异常场景下暴露。所以上线前一定要做针对性的压测和混沌实验,把故障注入进去,看系统能不能兜住。
5.1 构造乱序压测场景
我常用的压测手段是,在测试环境里模拟三类故障:
- 网络层抖动:在接入层和服务端之间加入随机延迟,比如某些请求延迟200ms,某些延迟2s,打乱服务端的接收顺序,验证服务端的序号分配是否只依赖接收顺序、客户端重排是否生效。
- 主从切换和进程重启:压测过程中直接kill掉正在处理消息的节点,观察客户端能否快速感知重连、服务端能否在恢复后继续分配更大的序号、消息序号是否有回退。
- 客户端断线重连:模拟客户端在收到一部分消息后断网,恢复连接后同时走“增量同步”和“实时推送”两个通道,验证客户端重排和去重逻辑。
每个场景都要跑至少10万条消息以上的量级,数据太少看不出来问题。
5.2 一个简单的验证脚本思路
我习惯写一个独立的校验服务,不依赖业务代码,专门验证消息顺序。核心逻辑是:启动一批mock客户端,每个客户端给同一个会话或同一个群发送一批消息,每条消息自带client_seq;服务端分配给每条消息server_seq后,校验服务从接收端收集所有收到的消息,检查是否存在“server_seq不是严格递增”的乱序。伪代码如下:
python复制def verify_order(received_messages):
last_seq = 0
disorder_events = []
for msg in sorted_by_receive_time(received_messages):
if msg.server_seq < last_seq:
disorder_events.append(msg)
last_seq = max(last_seq, msg.server_seq)
return disorder_events
注意这里不能用最终有序集合来检查,因为重排缓冲区最终都会排好序,看不出来问题。要看的是“消息到达校验服务的时间顺序”和“server_seq递增顺序”是否一致。如果大部分消息到达时已经乱序,但客户端缓冲区能兜住,那也没问题,因为最终用户看到的顺序是收敛的;如果乱序比例特别高,会导致重排缓冲压力大、延迟高,需要优化服务端的路由和序号分配。
5.3 需要关注的指标和观测手段
压测不能只关心“有没有乱序”,还要关注乱序发生时系统的表现。我会重点看四个指标:
- 服务端乱序率:单位时间内到达接收端的消息中,
server_seq出现逆序的比例。理想情况应该是0。 - 客户端重排率:进入重排缓冲区的消息占总消息的比例。这个值如果长期超过5%,说明服务端推送链路有扰动,需要排查。
- 补拉触发率:客户端触发缺口补拉的次数。如果每次网络抖动都会触发一次,而且补拉的数据量很大,要考虑是否应该适当增加推送重试机制。
- 消息最大乱序距离:乱序情况下,一条消息最多被拉开的序号范围。这个值决定了重排缓冲区需要设置多大,也影响补拉接口的查询范围。
有了这些指标,上线后可以通过日志和监控系统实时观察。比如某次发版后补拉触发率突然翻倍,就要重点看是不是路由规则变了、或者某个接入节点网络质量差。
6. 常见问题排查与避坑技巧
这里整理了一张我这几年的排查速查表,按“现象-可能原因-解决办法”排列,遇到问题时可以照着定位。
6.1 问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 单聊消息偶尔互换位置 | 接入层并行处理消息,或后端实例没有同一会话路由到单节点 | 同一会话一致性哈希路由,处理节点内单线程/串行队列 |
| 群聊不同成员看到的顺序不同 | 群消息没有群维度统一序号 | 群消息落库前使用Redis INCR分配群内序号,所有成员按序号拉取 |
| 主从切换后新消息序号突然变小 | 序号在恢复时从持久化旧水位开始分配 | 新主节点必须从“已提交水位”之后开始分配,必要时增加安全偏移 |
| 重连后收到大量重复消息 | 客户端没做幂等去重 | 以消息唯一ID为维度去重,或按server_seq去重 |
| 客户端重排后,历史消息位置仍不对 | 客户端用了本地时间或时间字符串排序 | 排序键统一改用服务端server_seq |
| 补拉接口频繁被打满 | 推送通道故障导致大量客户端同时补拉 | 客户端随机退避,服务端对补拉请求做合并与限流 |
| 一个群的消息在Kafka消费者侧乱序 | 同一个groupId的消息被路由到多个分区,或多线程消费 | 使用groupId作为分区键,消费者线程数设为1或按groupId哈希派发 |
| 某条引用回复显示在原文前面 | 服务端没有处理因果依赖 | 客户端做引用占位,或服务端检测依赖并延迟分配序号 |
6.2 避坑技巧
第一个坑:不要把数据库自增ID当消息序号。分库分表后自增ID不具备全局单调性,而且数据库自增ID天生没有“当前水位”语义,后面做主从恢复和补拉都会很痛苦。如果已用了自增ID,建议尽快增加一张序号表或独立发号器做过渡。
第二个坑:不要用客户端时间戳替代服务端序号排序。时间戳存在时区、设备差异、NTP漂移三个问题,在日志里看时间正常,在用户手机上看顺序就是反的。所有端统一按服务端下发顺序展示,时间只作为展示字段。
第三个坑:不要一上来就上分布式锁。网上很多面试题会问你“怎么用分布式锁保证消息有序”,实际生产用它串行化所有消息写入,等于把高并发架构做成单机吞吐。锁粒度要收敛到会话或群,能用原子自增和单队列解决就不要引入锁。
第四个坑:序号可以有空洞,但不能复用。消息被过滤、撤回、多端重复提交,都会导致序号跳变,这是正常的;一旦复用或回退,客户端会彻底混乱。所以持久化“已提交水位”和选主恢复机制,必须高于发号器的优先级。
第五个坑:不要忘了客户端展示层的最后一道防线。即便服务端逻辑完美,TCP重传、HTTP补拉、多端时间线并发更新,都会让消息乱序到达。重排缓冲区和幂等去重是IM客户端的“标配”,没有这些千万别上线。
6.3 一个小经验:时间显示问题也会引发“乱序”投诉
最后分享一个很有意思的案例。有段时间用户反馈“消息顺序看着不对”,我们排查了很久,最后发现不是消息乱序,而是有几个端上时间显示格式不统一:有人手机设置成了12小时制,上午和下午的时间显示让用户看错了先后。虽然这属于“看起来乱”,但也提醒我们,IM里任何与时间相关的展示都要格外小心,排序逻辑和展示逻辑要完全解耦。
我自己现在养成一个习惯:无论在哪个系统里设计消息流,都会先问三个问题——同一会话的写请求是否收敛到了同一处理单元?接收端拿到消息后能不能通过一个单调递增序号完成重排和补拉?主从切换或重启时,这个序号会不会回退?只要这三个问题的答案是肯定的,消息乱序问题基本就控制住了。
至于后续扩展,有序性验证和混沌测试可以再做深一点,比如引入网络拓扑故障模拟,或者对超大群做批量发号的压力验证,有机会我再单独整理。
