跳转至

ADR-0004 mailbox_seq 采用复合序号

状态

已接受(2026-08-13)。对应 docs/PLAN.md §6.5、§6.8、§7.11、§26.6.5。

背景

v1 §6.3 写的是"mailbox_seq 推荐直接使用分区日志 offset"。评审 001 的 A-1 指出这条存在 静默回退风险:分发日志 topic 重建、分区重建、跨集群灾备切换、分片分裂之后,新日志的 offset 从 0 重新开始,而设备游标里的 last_applied_mailbox_seq 仍是旧的大值。服务端按 after_seq 做范围查询将查不到任何新事件,客户端要么永久收不到新消息,要么被迫全量重建。

裸 offset 的根本问题是:它是日志的局部坐标,而 mailbox_seq 需要的是逻辑分片的全局单调坐标。两者在正常运行时恰好相等,在任何一次结构性变更后就分叉。同时 MailboxCursor 是单分片结构、mailbox_seq 绑定单分片 offset,用户逻辑归属变更后游标不可换算(评审 E-1)。两个问题必须一并解决。

决策

1. 复合位布局

mailbox_seq : u64
  bits 63..48  shard_epoch  16   由 ShardRegistry 单调递增分配
  bits 47..0   log_offset   48   MailboxShard 分发日志的分区 offset

比较规则:作为 u64 整体无符号比较。
容量:48 位 offset 在单分片 100 万事件/秒下可用约 8.9 年;16 位 epoch 支持 65536 次变更。

shard_epoch 在高位,因此跨 epoch 天然单调递增,offset 从 0 重新开始也不会回退。

2. shard_epoch 必须递增的四类事件

E1  MailboxShard 主备接管
E2  逻辑分片分裂或合并
E3  分发日志 topic / 分区重建或截断后重建
E4  跨集群灾备切换

任一事件发生后,ShardRegistry 先递增 epoch,再允许写入;顺序不可颠倒。 边界记录进 EpochBoundary 与 ShardSplitBoundary(§7.11),两表进入检查点。

3. 日志与逻辑分片的映射约束

逻辑 MailboxShard 与日志分区 1:1 固定映射;生产者必须显式指定分区,禁止 key hash 分区器;扩容只能新增逻辑分片或做分片分裂,不做分区再哈希;生产者开启幂等后,MailboxNode 仍需按 dispatch_id 在保留窗口内去重(同一 dispatch 被重复追加会得到两个不同 offset)。

4. 游标 rebase 流程

客户端 AUTH 携带 cursor{shard_epoch = Ec, last_applied_mailbox_seq = Sc}

1) Sc < mailbox_trim_watermark
   -> ERROR{CURSOR_EXPIRED, trim_watermark, rebuild_required=true},走 §9.6 REBUILD
      代价:窗口外未读与提及不精确

2) Ec < 当前 epoch 且 EpochBoundary 可解析
   -> ERROR{CURSOR_REBASED, new_cursor, replay_from_seq}
      replay_from_seq = max(Sc, 该 epoch 的 start_mailbox_seq)
      客户端从该点重放并按 message_id 与三元组幂等去重
      代价:少量重复,无丢失

3) 命中 ShardSplitBoundary(逻辑归属迁移)
   -> 同样返回 CURSOR_REBASED,new_cursor 指向新分片与新 epoch
      游标迁移是"按边界表换发签名令牌",不是数值映射
      split_at_seq 之前的事件归旧分片,之后归新分片;先拉完旧区间再切新令牌

4) Ec == 当前 epoch  -> 正常同步,无需 rebase

5. 硬指标

mailbox_seq_regression_count 恒等于 0(§26.6.5)。任一次 > 0 即判定 epoch 递增规则或 rebase 流程有缺陷,发布阻断。

理由

  • epoch 放高位是最省事的单调化手段:无需任何换算,u64 直接比较,与 §6.1 要求的大端编码"字节序比较 == 数值比较"天然一致。
  • 48 位 offset 的 8.9 年余量远超任何日志保留期;真到上界时递增一次 epoch 即可。16 位 epoch 按每天一次结构性变更算可用 179 年。
  • 边界表让"少量重复"可界定:没有 prev_epoch_end_mailbox_seq,rebase 只能退化为全量重建;有了它,代价降为"重放一小段并去重"。
  • 换发令牌而非数值映射是必然选择:新旧分片是两套坐标,任何数值映射都是伪造。
  • mailbox_seq_regression_count == 0 把抽象的"不会静默回退"变成可在混沌测试中持续采集的布尔量,是本决策唯一可靠的验收锚点。

后果(正面 / 负面)

正面

  • 日志重建、主备接管、灾备切换、分片分裂后游标依然可比较、可推进,不再需要全网客户端重建。
  • 对上层完全透明:UserMailboxEntry 聚簇键、PULL_MAILBOX 区间语义、lane 水位逻辑均不变。
  • CURSOR_REBASED 与 CURSOR_EXPIRED 语义严格分离,两者的用户可见代价完全不同。
  • 边界表进检查点,恢复流程自包含。

负面

  • 单分片单 epoch 的事件上限降为 2^48,虽余量巨大,但这是必须写进文档的硬上界。
  • ShardRegistry 成为 epoch 分配的关键路径:递增必须持久化且严格先于写入,主备接管因此多一次强制同步写。
  • 客户端必须实现 rebase 分支与幂等去重,增加约 200 行端上逻辑与对应测试。
  • 分片分裂期间存在"旧区间 + 新区间"的两段拉取,同步逻辑比单段复杂;且禁止 key hash 分区器,生产者要自己维护分片到分区的映射。

替代方案及其否决理由

方案 否决理由
裸日志 offset(v1 方案) epoch 变更后静默回退、游标不可换算,即本 ADR 要解决的问题本身
每分片独立持久递增计数器 每条 dispatch 一次强一致递增,成为单分片写入串行瓶颈;相对复用日志有序性无任何收益
用时间戳作 mailbox_seq 时钟回拨造成回退;同毫秒多事件需额外去重位;违反 §6.10.2
(epoch, offset) 两个独立字段 聚簇键变两列,范围查询与前缀比较均需改写,协议与令牌各多带一字段;合成 u64 成本为零
epoch 放低位、offset 放高位 比较语义错误:offset 归零后整体值变小,回退问题依旧
每次 epoch 变更强制全网 REBUILD 数据代价过大;主备接管是常规操作,不应触发用户可感知的降级
全局单调 ID 服务分配 引入新的全局关键路径依赖,与"复用日志有序性、零额外协调"冲突

复评条件

  1. 任一 MailboxShard 的单 epoch 事件数达到 2^48 的 10%。
  2. 任一 MailboxShard 的 shard_epoch 达到 1000,说明 E1~E4 中某类被误触发。
  3. mailbox_seq_regression_count 在生产环境出现任何非零值。
  4. 分发日志切换为不提供分区有序 offset 的实现。
  5. 分片分裂从"罕见操作"变为"常规扩容手段",需评估 ShardSplitBoundary 保留期与客户端两段 拉取逻辑是否需要长期化。