后端系统工程 07:会话内排序与断线增量同步
后端系统工程 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 | |
| 字段 | 含义 |
|---|---|
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 | |
关键在第三个分支:收到一个不连续的事件时,客户端保存它,但不推进游标。游标只在两种情况下前进:一个序号恰好是 cursor+1 的事件,或者一页同步结果(它的 next_after 由服务端保证覆盖了整个范围)。
5.2 同步
客户端同步一个会话时循环请求,直到 has_more=false:
1 | |
推送和同步可能并发执行,二者都在 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 | |
多取一行用于判断 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 | |
缺口检测有一个盲区:会话中最后一条消息的推送丢失时,后面没有新推送到达,客户端无从得知存在缺口。第 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 保证帧按发送顺序到达;乱序来自推送与同步两条路径的交错,以及跨连接(重连前后)的推送。
- 分页实验的页数和交错方式取决于调度,结论只依赖断言。
下一篇把会话扩展到群:成员数增加时推送怎样扇出,成员加入与退出怎样影响可见范围,以及推送模式与提示加拉取模式的取舍。
参考资料
- PostgreSQL 16 文档,13.2.2 Repeatable Read Isolation Level(事务看到的是第一条非事务控制语句开始时的快照)。https://www.postgresql.org/docs/16/transaction-iso.html
- PostgreSQL 16 文档,7.6 LIMIT and OFFSET(OFFSET 跳过的行仍需在服务端计算,大的 OFFSET 可能低效)。https://www.postgresql.org/docs/16/queries-limit.html
- PostgreSQL 16 文档,9.17 Sequence Manipulation Functions(序列值在回滚后不归还)。https://www.postgresql.org/docs/16/functions-sequence.html
- RFC 6455,§5.4 Fragmentation(消息分片必须按发送方的发送顺序交付给接收方)。https://www.rfc-editor.org/rfc/rfc6455
- RFC 9293,Transmission Control Protocol(TCP 向应用按序交付字节流)。https://www.rfc-editor.org/rfc/rfc9293






