换一台设备登录,消息为什么倒着长出来
「回声」的用户可以在手机、平板和网页之间切换。某天客服收到一类投诉:在手机上读完的对话,打开网页后最后三条消息又变成了未读;往下翻,中间还夹着一条两小时前已经删除的消息。用户把截图发过来,时间线是乱的。
开发同学第一反应是客户端缓存没清。清掉缓存重进,消息确实恢复了,但过一会儿又乱。抓包看到的现象更奇怪:客户端本地已经有 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 切换演练与用户无感 。
常见错误与边界
- 用写入时间戳做同步游标。节点时钟漂移会让边界重复或漏消息,时间戳也无法判断空洞。
- 用数据库自增主键当会话序号。分表后不连续,跨库还可能重复,无法表达会话内顺序。
- 补拉结果直接追加,不按序号去重。服务端的一次重复返回,会变成用户可见的重复消息。
- 让远端版本覆盖本地已展示内容。补拉一次,界面就回退一次,用户会以为消息被篡改。
- 补洞不设上界。长期离线客户端可能一次拉取几十万条,拖垮两端。
- 把删除当成数据丢失。用户主动删除会制造空洞,补洞前要先和删除集合求差。
- 推送服务把“补拉有返回”当成“有新消息”。重复返回会触发重复通知,必须比较已读位。
- 假设序号连续。事务回滚和风控拦截都会留下缺口,补洞只能按实际缺失判断。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。