消息顺序性:会话有序、因果序与乱序处理

「今天开会改到下午三点」和「算了还是按原时间」,这两条消息如果颠倒显示,用户会做出完全相反的判断。但 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"`
}

补拉必须做三件事,否则本身会变成故障源:

  1. 去抖合并(debounce):空洞往往连续出现,把 200ms 内的多个 PullReq 合并成一个区间请求,避免一次断网恢复产生几百个请求。
  2. 单会话并发限 1:同一会话同时只允许一个补拉在途,其余请求等待其结果并合并。
  3. 容忍不存在: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 都不可用于排序;群聊扩散过程中只搬运序号、绝不重新分配。

笔记加载中…