《互联网产品的服务端架构·规模化与架构演进》10.2 消息同步与顺序

用户在手机和网页之间切换后,同一段对话出现了重复、空洞和倒序。本节把消息同步拆成序号分配、跨节点去重与补洞三件事,给出 Merge 与 Missing 两个可测试函数,复盘一次按客户端时间戳补拉导致已读消息重复推送的故障,并给出补拉游标的改造清单。

换一台设备登录,消息为什么倒着长出来

「回声」的用户可以在手机、平板和网页之间切换。某天客服收到一类投诉:在手机上读完的对话,打开网页后最后三条消息又变成了未读;往下翻,中间还夹着一条两小时前已经删除的消息。用户把截图发过来,时间线是乱的。

开发同学第一反应是客户端缓存没清。清掉缓存重进,消息确实恢复了,但过一会儿又乱。抓包看到的现象更奇怪:客户端本地已经有 101 到 120 号消息,服务端补拉却返回了 105、106、118 和一条没有序号的历史消息;客户端按返回顺序追加,于是 105 排到了 120 后面。

根因不在客户端。服务端合并消息时用了“写入时间”排序,而写入时间来自各节点的本地时钟。两台节点的时钟差了两百毫秒,跨节点转发的消息就会带着更早的时间戳落到后面。补拉接口又按时间范围查询,边界上的消息被重复返回;客户端没有去重,重复自然变成了“新消息”。

即时通讯的同步问题,最后都会落到同一个问题:怎么判断两条消息是不是同一条,以及怎么判断中间漏没漏。时间戳两个问题都回答不了,只有会话内单调递增的序号可以。

Seq 是同步的锚点,不是数据库自增的副产品

很多团队把消息序号等同于数据库自增主键。单库单表时它们确实很像,一旦分库、跨节点转发或做多端同步,差异就暴露了。数据库自增只保证单表内唯一且递增,不保证会话内连续,也不保证跨节点可比。

把几种常见标识放进同一张表:

标识唯一范围能否判断空洞能否稳定排序主要问题
数据库自增 ID单表不能,分表后不连续单表内可以跨库重复,无法表达会话顺序
写入时间戳无不能时钟漂移时会错序同一毫秒内无法定序,回拨会倒序
客户端生成序号单设备不能,多端各自计数仅本设备内可以多端冲突,离线重装会重置
会话内服务端 Seq单会话可以,按区间找缺口可以,单调递增需要中心化分配,跨机房要处理延迟

「回声」选的是最后一种:每个会话有一个逻辑上的序号分配器,消息落库前先拿到 Seq。分配器可以按会话分片,同一会话的分配请求落到同一个分片;跨机房时,由主机房分配,备机房只读。这样做的代价是跨地域发送多一次网络往返,换来的是全局可比较的顺序。

Seq 连续吗?理想情况下连续,工程上不承诺连续。事务回滚、分配后消息被风控拦截、批量导入留下空洞,都会让序号出现缺口。所以补洞逻辑不能假设“下一个序号一定存在”,只能问“我本地缺哪些序号”。这也意味着 Seq 的职责是排序和定位,不是计数。

去重靠什么

去重不能靠消息内容。两条内容相同的“好的”是完全合法的两条消息;也不能靠发送者加时间,同一用户在同一秒可以发多条。可靠的去重键是“会话 ID 加 Seq”,或者一个全局唯一的消息 ID。前者更省空间,后者更适合跨会话聚合。

「回声」用 Seq 做会话内去重,同时保留消息 ID 用于日志和审计。合并时以 Seq 为准,因为客户端补拉、服务端重放和跨节点转发都围绕它展开。消息 ID 只在排查“这条消息从哪来”时使用,不参与排序。

本地版本和服务端版本冲突怎么办

多端同步时,同一个 Seq 在本地和服务端可能内容不同。常见原因是本地做了乐观更新,比如先显示“发送中”,服务端落库后补上了真实消息 ID;也可能是历史数据修复导致同序号被重写。

冲突处理必须有一个固定规则,不能“后到的覆盖先到的”。「回声」的规则是本地优先:本地已经展示给用户的内容不因为一次补拉而回退。代价是如果服务端修复了错别字,客户端要等下一次全量刷新才能看到;收益是用户不会看到消息内容突然跳变。

这条规则只适用于展示层。审计、举报和风控读取的是服务端版本,不受本地优先影响。把“展示用哪份”和“事实以哪份为准”分开,才能既保证体验又不丢证据。

失败场景:按时间戳补拉把已读消息又推了一遍

事故发生在一次跨机房演练。主机房切到备机房后,部分客户端重连,补拉接口按“最后一条消息时间”查询增量。备机房的时钟比主机房慢一点,查询窗口的左边界被算早了,边界附近的消息被重复返回。

如果客户端有按 Seq 去重,重复返回最多浪费一点带宽。但当时的客户端把补拉结果直接追加到列表,没有检查本地是否已有相同序号。于是同一条消息出现了两次,已读位置被后一条覆盖,界面显示成未读。

更糟的是离线推送。推送服务看到“有新的补拉结果”,又给用户发了一轮离线通知。用户在地铁里连续收到三条重复提醒,点进去却是同一段对话。客服当天收到的投诉量是平时的六倍。

复盘时发现了三个独立问题。第一,补拉接口用时间做游标,时钟一漂移,边界就不可靠。第二,客户端合并只追加不去重,把服务端的重复放大成了用户可见的重复。第三,推送服务把“补拉有返回”当成“有新消息”,没有检查返回的序号是否大于用户已读位。

修复方案对应三条。补拉游标改成 Seq,请求带“本地最大 Seq”,服务端只返回更大的序号;如果本地序号不连续,再单独调用补洞接口。客户端合并统一走 Merge,按 Seq 去重排序,本地版本优先。推送服务只对“大于已读位的最大 Seq”发通知,重复返回不再触发推送。

还有一个容易被忽略的边界:用户可能主动删除本地消息,造成本地序号出现空洞。这时补洞接口会把已删除的消息又拉回来。解决办法不是让补洞忽略空洞,而是让客户端在删除时记录一个“本地已删除序号”集合,补洞结果先和这个集合求差集。删除是用户意图,不能当成数据丢失。

合并与补洞写成两个纯函数

配套代码 internal/immig/sync.go 把同步拆成两个互不依赖的函数:Merge 负责“把两批消息合成一批”,Missing 负责“告诉我缺哪些序号”。它们都不访问数据库、不读时钟、不发网络请求,因此可以用表驱动测试把边界固定下来。

// file: internal/immig/sync.go
// tier: L1
// verify: go test ./internal/immig/

package immig

import "sort"

// Message 是一条已落库的聊天消息。Seq 是会话内单调递增的序号,
// 由服务端在写入时分配;ID 是消息的业务标识,Body 仅用于演示负载。
type Message struct {
	Seq  int64
	ID   string
	Body string
}

// Merge 合并本地已缓存与服务端拉取到的两批消息,按 Seq 去重并升序排序。
// 同一个 Seq 出现两次时保留 local 的版本:本地版本是客户端已经展示过的,
// 切换节点时以它为准可以避免界面回退。返回新切片,不修改入参。
func Merge(local, remote []Message) []Message {
	bySeq := make(map[int64]Message, len(local)+len(remote))
	for _, m := range local {
		bySeq[m.Seq] = m
	}
	for _, m := range remote {
		if _, ok := bySeq[m.Seq]; ok {
			continue
		}
		bySeq[m.Seq] = m
	}

	out := make([]Message, 0, len(bySeq))
	for _, m := range bySeq {
		out = append(out, m)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Seq < out[j].Seq })
	return out
}

// Missing 返回 remote 中存在、local 中缺失的 Seq,升序去重。
// 它只回答“要补哪些洞”,不关心消息内容;补齐仍然走 Merge。
func Missing(local, remote []Message) []int64 {
	have := make(map[int64]struct{}, len(local))
	for _, m := range local {
		have[m.Seq] = struct{}{}
	}

	seen := make(map[int64]struct{}, len(remote))
	out := make([]int64, 0, len(remote))
	for _, m := range remote {
		if _, ok := have[m.Seq]; ok {
			continue
		}
		if _, ok := seen[m.Seq]; ok {
			continue
		}
		seen[m.Seq] = struct{}{}
		out = append(out, m.Seq)
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}

Merge 的实现有三个决定。

第一,用 map[int64]Message 按序号去重,再排序输出。排序放在最后,而不是边插入边维护有序切片,是因为消息批量通常不大,排序的常数成本比频繁插入低,代码也更容易读。如果单批达到十万条,可以换成堆或归并,但那是另一个量级的优化。

第二,本地版本优先。先写入 local,remote 遇到已存在的序号直接跳过。这个顺序不能反过来,否则一次补拉就会把本地已展示的内容覆盖掉。规则写进代码注释和测试,后来的人改动时才有依据。

第三,返回新切片,不修改入参。同步函数经常被多个 goroutine 同时调用,修改入参会让调用方难以推理。append([]Message(nil), ...) 或预分配新切片都可以,关键是入参只读。

Missing 只返回序号,不返回消息内容。这是刻意的:补洞的第一步是知道“缺什么”,第二步才是决定“怎么补”。有些场景只需要触发一次全量同步,不需要逐条拉取;有些场景要按缺口批量请求。把两个步骤分开,调用方可以按自己的网络条件选择策略。

为什么不用有序集合

Redis 的有序集合很适合做“按分数范围取消息”,但它解决的是存储和查询,不解决合并规则。客户端本地可能是 SQLite,服务端可能是 MySQL,两边都要实现同一套去重与排序语义。把规则收敛到一个小函数里,比依赖某个存储的特性更容易跨端一致。

补洞的边界

补洞请求应该带上下界,而不是“从某个序号一直拉到最新”。没有上界,一个长期离线的客户端可能拉取几十万条消息,把服务端和客户端一起拖垮。合理的做法是分批,每批有上限,客户端处理完一批再请求下一批,并记录进度。

如果缺口跨越了消息保留期,服务端已经删除了那批消息,补洞会永远补不上。这时应该返回一个明确的“已过期”标记,让客户端走全量同步或提示用户,而不是让客户端无限重试。

测试:去重、顺序、本地优先与补洞

同步逻辑的测试重点不是“正常情况能跑”,而是“重复、乱序、空洞、空输入”这些边界。下面五个用例覆盖了合并与补洞的主要分支。

// file: internal/immig/sync_test.go
// tier: L1
// verify: go test ./internal/immig/

package immig

import (
	"reflect"
	"testing"
)

func TestMergeDedupesAndSortsBySeq(t *testing.T) {
	local := []Message{
		{Seq: 3, ID: "m3", Body: "local three"},
		{Seq: 1, ID: "m1", Body: "local one"},
	}
	remote := []Message{
		{Seq: 2, ID: "m2", Body: "remote two"},
		{Seq: 3, ID: "m3", Body: "remote three"},
		{Seq: 4, ID: "m4", Body: "remote four"},
	}

	got := Merge(local, remote)
	want := []Message{
		{Seq: 1, ID: "m1", Body: "local one"},
		{Seq: 2, ID: "m2", Body: "remote two"},
		{Seq: 3, ID: "m3", Body: "local three"},
		{Seq: 4, ID: "m4", Body: "remote four"},
	}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Merge = %#v, want %#v", got, want)
	}
}

func TestMergeKeepsLocalVersionOnConflict(t *testing.T) {
	local := []Message{{Seq: 7, ID: "m7", Body: "shown locally"}}
	remote := []Message{{Seq: 7, ID: "m7", Body: "server copy"}}
	got := Merge(local, remote)
	if len(got) != 1 {
		t.Fatalf("Merge returned %d messages, want 1", len(got))
	}
	if got[0].Body != "shown locally" {
		t.Fatalf("Merge kept %q, want local version", got[0].Body)
	}
}

func TestMergeDoesNotMutateInputs(t *testing.T) {
	local := []Message{{Seq: 2, ID: "m2"}, {Seq: 1, ID: "m1"}}
	remote := []Message{{Seq: 3, ID: "m3"}}
	localCopy := append([]Message(nil), local...)
	remoteCopy := append([]Message(nil), remote...)

	Merge(local, remote)

	if !reflect.DeepEqual(local, localCopy) {
		t.Fatalf("local mutated: %#v", local)
	}
	if !reflect.DeepEqual(remote, remoteCopy) {
		t.Fatalf("remote mutated: %#v", remote)
	}
}

func TestMissingReturnsGapsSorted(t *testing.T) {
	local := []Message{{Seq: 1}, {Seq: 3}}
	remote := []Message{{Seq: 1}, {Seq: 2}, {Seq: 2}, {Seq: 3}, {Seq: 4}}

	got := Missing(local, remote)
	want := []int64{2, 4}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Missing = %v, want %v", got, want)
	}
}

func TestMissingHandlesEmptyLocalAndCoveredRemote(t *testing.T) {
	remote := []Message{{Seq: 9}, {Seq: 5}}
	if got := Missing(nil, remote); !reflect.DeepEqual(got, []int64{5, 9}) {
		t.Fatalf("Missing(nil, remote) = %v, want [5 9]", got)
	}

	local := []Message{{Seq: 5}, {Seq: 6}, {Seq: 9}}
	if got := Missing(local, remote); len(got) != 0 {
		t.Fatalf("Missing(covered) = %v, want empty", got)
	}
}

TestMergeDedupesAndSortsBySeq 同时验证了去重和排序:本地是 3、1,远端是 2、3、4,结果必须是 1、2、3、4,且 3 号保留本地内容。一个用例锁住两件事,失败时看差异就能定位是去重错了还是排序错了。

TestMergeKeepsLocalVersionOnConflict 单独锁住冲突规则。如果以后有人把 local 和 remote 的遍历顺序调换,这个用例会失败。冲突规则是产品决策,不是实现细节,值得单独一个测试。

TestMergeDoesNotMutateInputs 用副本比对入参。Go 的切片是引用语义,函数里一句 sort.Slice(local, ...) 就会改掉调用方的数据。这个测试能抓住“为了省一次分配而原地排序”的改动。

TestMissingReturnsGapsSorted 覆盖重复的远端序号。远端返回 2 两次,补洞结果只能有一个 2。TestMissingHandlesEmptyLocalAndCoveredRemote 覆盖两个极端:本地为空时返回全部序号,本地已覆盖时返回空。

测试没有覆盖超大缺口的分批逻辑。分批是调用方的职责,函数只回答“这一批缺什么”。把分批塞进 Missing 会让它依赖网络和配置,也会让返回值语义变得模糊。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/immig/ -run 'TestMerge|TestMissing' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestMergeDedupesAndSortsBySeq
--- PASS: TestMergeDedupesAndSortsBySeq (0.00s)
=== RUN   TestMergeKeepsLocalVersionOnConflict
--- PASS: TestMergeKeepsLocalVersionOnConflict (0.00s)
=== RUN   TestMergeDoesNotMutateInputs
--- PASS: TestMergeDoesNotMutateInputs (0.00s)
=== RUN   TestMissingReturnsGapsSorted
--- PASS: TestMissingReturnsGapsSorted (0.00s)
=== RUN   TestMissingHandlesEmptyLocalAndCoveredRemote
--- PASS: TestMissingHandlesEmptyLocalAndCoveredRemote (0.00s)
PASS
ok  	webapp/internal/immig	0.245s

五个用例全部通过,退出码为 0。-run 只筛了同步相关用例,路由与切换测试没有执行;需要全量确认时去掉 -run 再跑一次。

小结

消息同步的核心不是“把消息发过去”,而是让两端对“有哪些消息、顺序是什么、缺了哪些”达成一致。时间戳回答不了这三个问题,客户端序号也回答不了。会话内服务端分配的 Seq 是目前最稳的锚点:它单调、可比较、能定位缺口,代价是需要中心化分配。

Merge 和 Missing 把同步拆成“合并”和“找洞”两个纯函数,规则显式、边界可测。真正上线时还要处理保留期、删除集合、分批拉取和推送去重,但那些都是在这两个函数之上叠加策略,而不是替换它们。

下一节讨论切换演练:当路由和同步都正确,怎么证明一次真实的节点切换对用户没有影响。

阅读导航:上一节:10.1 会话归属与路由 · 下一节:10.3 切换演练与用户无感 。

常见错误与边界

  • 用写入时间戳做同步游标。节点时钟漂移会让边界重复或漏消息,时间戳也无法判断空洞。
  • 用数据库自增主键当会话序号。分表后不连续,跨库还可能重复,无法表达会话内顺序。
  • 补拉结果直接追加,不按序号去重。服务端的一次重复返回,会变成用户可见的重复消息。
  • 让远端版本覆盖本地已展示内容。补拉一次,界面就回退一次,用户会以为消息被篡改。
  • 补洞不设上界。长期离线客户端可能一次拉取几十万条,拖垮两端。
  • 把删除当成数据丢失。用户主动删除会制造空洞,补洞前要先和删除集合求差。
  • 推送服务把“补拉有返回”当成“有新消息”。重复返回会触发重复通知,必须比较已读位。
  • 假设序号连续。事务回滚和风控拦截都会留下缺口,补洞只能按实际缺失判断。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查