后端系统工程 07:会话内排序与断线增量同步

本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收

第 06 篇让投递变成“至少一次”:推送可能重复,可能丢失后由同步补齐。这带来两个新问题。第一,消息以什么顺序显示?推送和同步是两条独立的路径,到达顺序可能与发送顺序不同。第二,客户端怎样记住“已经拿到了哪里”,使得断线重连后只拉取缺少的部分,既不重复也不遗漏?

本篇的答案围绕一个数展开:会话内序号 seq。服务端保证序号在已提交事件中连续,并且提交顺序与序号顺序一致;客户端维护一个只在连续范围上前进的游标;同步接口以游标为键分页。三者合起来,使得任意交错的推送、同步与新写入都不会让客户端漏掉或重复应用事件。

1. 需求与不变量

编号 不变量 承担者
O1 同一会话内,所有成员看到的事件顺序相同,即序号顺序 服务端(第 04 篇的会话行锁)
O2 序号为 n+1 的事件提交时,序号 ≤ n 的事件都已提交 服务端
O3 客户端游标 cursor 满足:本地已保存 (min_seq, cursor] 内全部可见事件 客户端
O4 以 after=cursor 发起的同步,逐页推进,最终到达 head,期间不重复、不跳过 服务端 + 客户端
O5 游标失效(超过 head、纪元变化、历史被清理)时,服务端返回明确的错误或标记,而不是静默返回错误数据 服务端

O2 不是 O1 的推论,需要单独说明。设想序号由数据库序列分配(第 04 篇方案 A):事务 T1 取得序号 10,事务 T2 取得序号 11,T2 先提交。此时一个读者同步,看到 11 但看不到 10,于是把游标推进到 11;之后 T1 提交,序号 10 出现,但读者的下一次同步从 after=11 开始,永远拿不到 10。O2 排除了这种情况:在本实现中,分配序号的事务持有会话行锁直到提交,下一个事务要等锁释放才能分配序号 n+1,因此读者看到 n+1 时,n 必然已经可见。

O3 中“可见”指落在调用者成员区间内(第 08 篇)。不可见的序号范围在游标推进时被跳过,O3 不要求客户端保存它们。

2. 规模假设

第 02 篇假设网关故障时 30% 的设备在 60 秒内重连,每台平均补拉 25 条。单页上限为 200 条(Store.MAX_PAGE),客户端默认每页 50 条,所以多数重连一页即可完成。长期离线的设备可能积压数千条,需要多页;分页过程中会话还在继续写入,这正是 O4 要处理的场景。

3. 接口

1
2
3
4
5
{"t":"sync","rid":"r9","conv":"c-g","after":120,"limit":50,"epoch":1}

{"t":"page","rid":"r9","conv":"c-g","epoch":1,"min_seq":1,"head":213,
"next_after":170,"has_more":true,"trimmed":false,
"events":[{"seq":121,…},…,{"seq":170,…}]}
字段 含义
after 客户端游标;返回 seq > after 的可见事件
epoch 可选;客户端记录的会话纪元。与服务端不同时返回 EPOCH_CHANGED
head 本页快照中的会话最大序号
next_after 客户端应用完本页后可以保存的游标值
has_more 是否还有下一页
trimmed after + 1 < min_seq:请求的起点已被保留策略清理(第 11 篇)

next_after 的取值规则:

  • has_more=true:本页最后一个事件的序号。
  • has_more=false:max(after, head)。

第二条规则让游标可以越过调用者不可见的序号范围。例如用户在序号 64 退群,之后会话继续写到 70;用户同步时,事件只返回到 64,但 next_after 为 70。如果 next_after 只取“最后一个返回事件的序号”,游标会停在 64,每次同步都重新扫描 65–70 却得不到任何事件。

4. 数据模型

服务端不需要新的表。序号、head、min_seq、epoch 都在第 04 篇的 conversations 与 events 中。

客户端维护每个会话的四项状态(examples/im/src/im/Client.java):

状态 含义
local[conv] 以序号为键的有序映射,保存已应用的事件
cursor[conv] 连续游标,满足 O3
pending[conv] 已收到但不连续的推送,等待缺口补齐
epochs[conv] 最近一次同步得到的纪元

5. 正常路径

5.1 推送到达

1
2
3
4
5
6
7
8
9
10
11
12
图 7-1  客户端对一条推送的处理分支(连续游标策略)
收到 event{seq}
│
├─ seq ≤ cursor ............ 重复:按序号覆盖写入(幂等),游标不动
│
├─ seq = cursor + 1 ........ 连续:写入,cursor = seq,
│ 再从 pending 中取出能接上的事件,继续推进
│ 持久化本地状态后发送 ack{cursor}
│
└─ seq > cursor + 1 ........ 缺口:放入 pending,计数 gapsDetected,
发起 sync(after=cursor);
同步完成后 drainPending,游标越过缺口

关键在第三个分支:收到一个不连续的事件时,客户端保存它,但不推进游标。游标只在两种情况下前进:一个序号恰好是 cursor+1 的事件,或者一页同步结果(它的 next_after 由服务端保证覆盖了整个范围)。

5.2 同步

客户端同步一个会话时循环请求,直到 has_more=false:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// examples/im/src/im/Client.java(节选)
while (true) {
long after = cursor.getOrDefault(conv, 0L);
Map<String, Object> page = call(Json.map("t", "sync", "conv", conv, "after", after, "limit", (long) limit, ...));
applyLock.lock();
try {
for (Map<String, Object> ev : events) applyEvent(conv, ev); // 按序号写入,幂等
long next = ((Number) page.get("next_after")).longValue();
if (next >= cursor.getOrDefault(conv, 0L)) cursor.put(conv, next);
drainPending(conv);
persist();
} finally { applyLock.unlock(); }
if (autoAck) safeAck(conv);
if (!Boolean.TRUE.equals(page.get("has_more"))) return applied;
}

推送和同步可能并发执行,二者都在 applyLock 下修改本地状态。游标只增不减(next >= cursor 时才更新),因此即使一次较早发起的同步在一次推送之后才返回,也不会把游标拉回去。

多个提示(hint)可能在短时间内到达,例如大群中连续多条消息(第 08 篇)。客户端对每个会话最多保持一个进行中的同步;同步期间到达的提示只设置一个“还需要再同步一轮”的标记,同步结束后若标记存在就再执行一轮。这样 N 个提示最多引起两轮同步,而不是 N 次。

5.3 服务端同步查询

服务端在一个 REPEATABLE READ 只读事务中完成整个同步:读取 head、min_seq、epoch,读取成员区间,再查询事件。PostgreSQL 的 REPEATABLE READ 在事务的第一条语句时获取快照,之后所有语句看到同一快照。这保证了 head 与 events 描述的是同一时刻,next_after = head 不会越过一个在快照之后才提交、但序号不大于 head 的事件;结合 O2,快照中可见的序号集合本身就是从 min_seq 到 head 的连续区间。

1
2
3
4
SELECT e.seq, e.kind, … FROM events e
WHERE e.conv_id=? AND e.seq>? AND e.seq<=? -- after < seq ≤ visibleEnd
AND EXISTS (成员区间覆盖 e.seq)
ORDER BY e.seq LIMIT ? -- limit+1,用于判断 has_more

多取一行用于判断 has_more,避免额外的计数查询。

6. 方案比较

6.1 排序依据

依据 问题
客户端时间戳 设备时钟不一致,可被篡改;同一毫秒内的消息无法排序
服务端接收时间 多个网关的时钟不同步;时间戳相同的消息无法排序;时间顺序不等于提交顺序
全局唯一标识(时间加机器号加计数) 只能排序,不能判断是否有遗漏
会话内连续序号 需要每会话串行化分配(第 04 篇已承担此成本)

只有连续序号能让客户端做缺口检测:看到 5 和 7,就知道缺 6。时间戳和全局标识只能告诉客户端“7 在 5 后面”,不能告诉它“中间还有东西”。

O1 的范围限定在单个会话内。跨会话的顺序(例如会话列表按最后一条消息排序)使用消息的服务端时间戳近似,属于显示层的启发式规则,系统不对它做保证,这在第 00 篇的排除项 X1 中写明。

6.2 游标策略

策略 游标更新规则 乱序推送时
连续游标 只越过连续范围 缓存不连续的事件,同步补缺口
最大已见序号 cursor = max(cursor, seq) 越过缺口,缺失的事件永远不会被同步返回

“最大已见序号”实现简单,并且在推送从不乱序、从不丢失时与连续游标表现相同,所以它的缺陷在正常测试中不会暴露。负对照 07-control-max-seen-skips 专门制造乱序来暴露它。

6.3 分页方式

方式 写法 并发写入时
偏移分页,从新到旧 ORDER BY seq DESC OFFSET k LIMIT n 新行插入到“第一页”之前,后续页整体后移,出现重复
偏移分页,从旧到新 ORDER BY seq OFFSET k LIMIT n 不受尾部插入影响,但清理头部会导致跳过;深分页需要扫描并丢弃 k 行
键集分页 WHERE seq > after ORDER BY seq LIMIT n 不受插入与清理影响;任意深度都是索引范围扫描

键集分页(keyset pagination)以上一页最后一个键作为下一页的起点,它的结果不依赖于“前面有多少行”,因此前面的插入或删除都不会使它重复或跳过。增量同步本质上就是一次从游标开始的键集分页。

7. 关键实现:缺口检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// examples/im/src/im/Client.java(节选)
void onPush(Map<String, Object> ev) {
long seq = …;
applyLock.lock();
try {
long cur = cursor.getOrDefault(conv, 0L);
if (seq <= cur) {
applyEvent(conv, ev); // 重复,幂等
} else if (seq == cur + 1 || policy == CursorPolicy.MAX_SEEN) {
applyEvent(conv, ev);
cursor.put(conv, policy == CursorPolicy.MAX_SEEN ? Math.max(cur, seq) : seq);
drainPending(conv);
} else {
pending.computeIfAbsent(conv, k -> new TreeMap<>()).put(seq, ev);
gapsDetected.incrementAndGet();
gap = true;
}
persist();
} finally { applyLock.unlock(); }
if (gap && autoSync) safeSync(conv); else if (autoAck) safeAck(conv);
}

缺口检测有一个盲区:会话中最后一条消息的推送丢失时,后面没有新推送到达,客户端无从得知存在缺口。第 06 篇的重投扫描覆盖了这个盲区:服务端发现该设备的送达游标落后于 head,就发一个提示。

7.1 发送方自身的顺序

同一连接上的请求由网关的读线程逐个处理(Gateway.readLoop),因此同一连接上先到达的发送请求先获得会话行锁,序号更小。但重连会打破这一点:设备在连接 1 上发送了 A,回复丢失;在连接 2 上先发送新消息 B,再重发 A,那么 B 的序号小于 A。为保持发送方视角的顺序,客户端在重连后必须先重发持久发件箱中的消息,再发送新消息。这是客户端的义务,测试客户端提供 resendPending 供调用方在重连后首先执行。

8. 验证

运行方式:真实 PostgreSQL 16 与网关 JVM,测试客户端在测试进程内;乱序通过在客户端直接注入推送事件(onPushForTest)制造,即“推送 n+2 已到达、n+1 的推送尚未到达”。结果见 evidence/im-07/result.json。

类型 检查 预期 观测
正常 07-concurrent-total-order 3 个用户经网关并发发送 150 条,150 个不同序号,会话日志无空洞 150 个序号,空洞 0
边界 07-paging-under-writes 每页 20 条从 0 开始分页,同时另一线程追加 60 条;最终本地事件数等于 head,无重复 80 页,持有 213 条,head 213,重复 0
负对照 07-control-offset-paging 30 行数据,从新到旧偏移分页,每页之间插入 3 行 3 页中出现 6 个重复行
失败 07-out-of-order-contiguous 先注入 n+2:检测到 1 个缺口,同步补齐,游标到达 n+2 base 213,a=214,b=215,游标 215,持有 214
负对照 07-control-max-seen-skips 最大已见策略:游标直接跳到 n+2,不持有 n+1 a=216,b=217,游标 217,不持有 216
负对照 07-control-max-seen-permanent 再做一次完整同步后,216 仍然缺失 仍缺失
边界 07-cursor-ahead-and-epoch after 大于 head → CURSOR_AHEAD;纪元加一后用旧纪元同步 → EPOCH_CHANGED 两者均符合

分页实验中 80 页的构成:初始约 153 个事件(3 个 join 与 150 条消息),加上分页期间追加的 60 条;每页 20 条,但写入速度与分页速度交错,部分页只拿到一两条新事件。页数取决于调度,不是确定值,断言只针对“无重复、无遗漏、到达 head”。

偏移对照的 6 个重复来自:读取第 2 页之前插入了 3 行,第 2 页的前 3 行就是第 1 页的后 3 行;读取第 3 页之前又插入 3 行,累计偏移 6 行,第 3 页的前 6 行重复。对照使用的是负对照专用表 seq_events,不经过网关。

最大已见对照中的“永久缺失”是这个策略最严重的后果:之后的同步都从 after=217 开始,服务端正确地不再返回 216。缺失的消息没有任何机制可以补回,除非客户端把游标清零、重新同步全部历史。

9. 故障与恢复

情形 服务端回复 客户端动作
推送乱序 — 缓存,同步补缺口
推送重复 — 按序号幂等覆盖
同步与推送并发 — 二者在本地锁下合并,游标只增
分页期间有新写入 has_more 与 next_after 照常 继续分页,最后一页 next_after=head
游标超过 head(例如服务端从较旧的备份恢复,或客户端状态来自另一个环境) CURSOR_AHEAD 丢弃该会话的本地游标,从可信位置(0 或 min_seq-1)重新同步
纪元变化 EPOCH_CHANGED 清空该会话本地状态,按新纪元重新同步
起点已被清理 正常返回,trimmed=true 提示用户“更早的历史已过期”,从 min_seq 开始保存(第 11 篇)

CURSOR_AHEAD 与 EPOCH_CHANGED 的区别在于服务端是否知道日志被重建了。纪元是服务端主动声明“旧序号不再有意义”;CURSOR_AHEAD 是服务端被动发现“你的游标在我这里不存在”。后者只能发现游标超前的情况:如果服务端恢复到较旧的状态后又写入了新消息,使得 head 重新超过客户端游标,同一个序号在两边可能对应不同的事件,CURSOR_AHEAD 无法发现。因此任何导致日志回退的运维操作(例如从备份恢复)都必须同时增加纪元。本批中纪元只由实验直接修改,没有实现配套的恢复流程。

10. 限制

  • O2 依赖“分配序号的事务持有会话行锁直到提交”。若将来改为批量预分配序号(例如为了降低锁争用),O2 将不再成立,客户端需要处理“序号可见但前面仍有未提交事件”的情况,例如引入服务端返回的“已稳定水位”。
  • 乱序是在客户端注入的,没有在网络层制造真实的乱序。在单个 WebSocket 连接上,TCP 保证帧按发送顺序到达;乱序来自推送与同步两条路径的交错,以及跨连接(重连前后)的推送。
  • 分页实验的页数和交错方式取决于调度,结论只依赖断言。

下一篇把会话扩展到群:成员数增加时推送怎样扇出,成员加入与退出怎样影响可见范围,以及推送模式与提示加拉取模式的取舍。

参考资料