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 服务分配 | 引入新的全局关键路径依赖,与"复用日志有序性、零额外协调"冲突 |
复评条件¶
- 任一 MailboxShard 的单 epoch 事件数达到 2^48 的 10%。
- 任一 MailboxShard 的
shard_epoch达到 1000,说明 E1~E4 中某类被误触发。 mailbox_seq_regression_count在生产环境出现任何非零值。- 分发日志切换为不提供分区有序 offset 的实现。
- 分片分裂从"罕见操作"变为"常规扩容手段",需评估
ShardSplitBoundary保留期与客户端两段 拉取逻辑是否需要长期化。