消息顺序性:会话有序、因果序与乱序处理
「今天开会改到下午三点」和「算了还是按原时间」,这两条消息如果颠倒显示,用户会做出完全相反的判断。但 IM 从不承诺全局有序——跨会话的两条消息本来就没有先后关系,真正需要保证的是同一会话内有序,加上少数场景下的因果序(causal order)。
乱序从哪来
| 来源 | 说明 |
|---|---|
| 多网关接入 | 同一用户的两条上行经过不同网关实例,到达逻辑层的时间可能颠倒 |
| 逻辑层并发落库 | 同会话的两条消息被两个线程并行处理,完成顺序与分配顺序不一致 |
| 异步链路 | 落库与下发经过消息队列,分区与重试会打乱到达顺序 |
| 重发与重投 | 前面一条消息重发,反而在后一条之后到达 |
| 多端并发 | 用户两台设备同时发送,客户端本地序号毫无意义 |
结论很直接:顺序不能靠「到达顺序」,必须靠一个显式的、单调递增的序号。这个序号就是 seq。
定序:每个会话一个定序者
保证会话有序的最小方案是「单写者(single writer)」:同一个会话的 seq 由同一个实例分配,分配顺序即权威顺序。
路由规则:shard = hash(conv_id) % N → conv_id 相同的请求永远落到同一实例
消息A seq=101 消息B seq=102 消息C seq=103 ← 定序者分配的顺序即最终顺序
| | |
+---- 落库(uk_conv_seq 唯一)+---- 下行(带各自 seq)
网络与下行的到达顺序可能是 103 → 101 → 102,客户端按 seq 重排后展示 101, 102, 103
一旦 seq 确定,后面所有环节都不再需要关心顺序:落库带上 seq 由唯一键兜底,下行带上 seq 由客户端重排。定序点放在最前面,链路后面就能全部并行化,这是 IM 顺序设计里最值钱的一句话。
客户端重排:水位 + 缓冲
接收端维护一个「已连续交付的最大 seq」(水位,watermark)和一个缺口缓冲区:
type Reorder struct {
mu sync.Mutex
convID string
watermark int64 // 已连续交付的最大 seq
buf map[int64]*Downlink
}
// Push 按 seq 重排,返回本次可连续交付的消息;缺口留在缓冲区等待补拉。
func (r *Reorder) Push(m *Downlink) []*Downlink {
r.mu.Lock()
defer r.mu.Unlock()
if m.Seq <= r.watermark {
return nil // 重复或过期消息,直接丢弃
}
r.buf[m.Seq] = m
var out []*Downlink
for {
next, ok := r.buf[r.watermark+1]
if !ok {
break // 出现空洞,停止交付,等待补拉
}
delete(r.buf, r.watermark+1)
r.watermark++
out = append(out, next)
}
return out
}
处理规则与 seq 和当前水位的关系:
| 收到的 seq | 判定 | 动作 |
|---|---|---|
seq <= watermark | 重复投递或迟到残留 | 丢弃,不回退水位(水位绝不后退) |
seq == watermark+1 | 正常连续 | 交付并推进水位,再尝试冲刷缓冲区 |
seq > watermark+1 | 出现空洞 | 入缓冲,启动补拉 |
空洞检测与补拉
客户端发现空洞后向服务端请求补齐区间:
// PullReq 客户端补齐空洞的请求;服务端只做按 seq 区间的读取。
type PullReq struct {
ConvID string `json:"conv_id"`
FromSeq int64 `json:"from_seq"`
ToSeq int64 `json:"to_seq"`
}
补拉必须做三件事,否则本身会变成故障源:
- 去抖合并(debounce):空洞往往连续出现,把 200ms 内的多个
PullReq合并成一个区间请求,避免一次断网恢复产生几百个请求。 - 单会话并发限 1:同一会话同时只允许一个补拉在途,其余请求等待其结果并合并。
- 容忍不存在:
seq可能因为「分配了但落库失败」而永久空缺(见消息 ID 与序列号一章),服务端必须明确返回「该区间的 101、102 缺失」,客户端据此跳过并推进水位。若不返回这个标记,客户端会永久卡在等待 102 上,整个会话此后的消息全部不显示——这是线上最容易踩的顺序性事故。
补拉的存储模型与位点设计(服务端如何按区间高效读取)属于离线消息与拉取位点的范围,这里只约定客户端的检测与请求合并规则。
因果序
会话有序解决不了跨会话的顺序。比如 A 在会话 1 里把文件发给了 B,又在会话 2 里说「文件已发」,这两条消息在各自会话内都有序,跨会话无法比较先后。可行做法是用引用显式表达因果,而不是用时间戳猜测:
| 手段 | 做法 | 用途 |
|---|---|---|
| 引用回复(reply / quote) | 消息体带 reply_to_msg_id | 让人看到「在回哪条」,不依赖到达顺序 |
| 合并转发 / 话题(thread) | 用 root_msg_id 把一族消息绑成树 | 群聊里把讨论聚合 |
| 业务幂等 + 状态机 | 顺序不敏感的处理(如「已支付」只能从「未支付」流转过来) | 让乱序无害 |
不要用时间戳排序全局消息:客户端时钟可被用户修改,服务端多个实例的墙上时钟(wall clock)也有毫秒级偏差,同毫秒内的两条消息根本无法定序。时间戳只用展示。
排序依据取舍
| 排序依据 | 可否用于排序 | 原因 |
|---|---|---|
| 客户端时间戳 | 否 | 可篡改、多端不一致 |
server_at(服务端落库时间) | 仅展示 | 同毫秒无法区分,跨实例有偏差 |
msg_id(雪花 ID) | 否 | 同毫秒内乱序,跨会话更无意义 |
会话 seq | 是 | 由定序者单调分配,会话内唯一且递增 |
方案对比
| 方案 | 顺序保证 | 吞吐 | 主要代价 |
|---|---|---|---|
| 全链路串行处理(同会话排队、单线程消费) | 强 | 低 | 队头阻塞:一条慢消息卡住整个会话 |
| 定序者分配 seq + 客户端重排 | 会话内强有序 | 高 | 客户端需要缓冲与补拉,逻辑略复杂 |
| 状态机条件更新 | 业务语义有序 | 高 | 只适用于状态流转类消息 |
失败场景
| 场景 | 现象 | 处理 |
|---|---|---|
| 空洞长期不补 | 会话消息停更,用户以为软件坏了 | 补拉超时兜底:退避重试并上报告警,超过阈值则整段重拉 |
| 补拉风暴 | 断网恢复后请求雪崩、服务端被压垮 | 去抖合并 + 单会话并发限 1 + 服务端限流 |
| 缓冲区无上限 | 客户端内存暴涨 | 缓冲上限(如 500 条),超限则丢弃缓冲并从水位整段重拉 |
| 重复 seq 在库中冲突 | 写入失败、消息发不出去 | uk_conv_seq 唯一键报错即告警,说明定序者出现分裂 |
| 群聊扩散后成员端顺序不同 | 各成员看到的顺序不一致 | 扩散过程只搬运带 seq 的消息,任何环节都不重新分配序号(扩散策略见群聊章节) |
小结:IM 的顺序性只承诺会话内有序,做法是「每个会话一个定序者分配单调递增的 seq」——定序点放在链路最前端,之后的落库、下发全部可以并行。接收端用水位加缓冲区重排,seq 落在水位之下丢弃、连续则交付、跳跃则入缓冲并触发去抖后的区间补拉,并且必须能处理服务端返回的「该 seq 不存在」,否则会永久卡住会话。跨会话只做因果序(引用回复、话题聚合、幂等状态机),时间戳与雪花 ID 都不可用于排序;群聊扩散过程中只搬运序号、绝不重新分配。