ADR-0022:Scylla 邮箱改为分片前沿水位、写时间戳单调写与按页推进¶
- 状态:已接受(2026-09-28,项目负责人:“全都做;ScyllaDB 能不能按页写,不要每次有数据就落盘”)
- 相关:ADR-0001(MailboxStore 三级演进)、ADR-0011(邮箱热路径表示)、ADR-0015(packed W v2)、
ADR-0018(分层历史)、
docs/designs/delta_20260823_scylla_current_store_contract.md(本 ADR 取代其中lane_dispatch/dispatch_observation/dispatch_chunk/lane_watermark/completed_dispatch与 GC 的部分)
背景¶
QIM_MAILBOX_STORE_BACKEND=scylla 实测(2026-09-27,本机单节点 Scylla 4 核、tmpfs、hybrid、单聊):
| 形态 | 发送速率 | 实际投递 | 端到端 P99 | Scylla CPU | 每条 dispatch 的 LWT |
|---|---|---|---|---|---|
| 原实现 | 20 msg/s | 约 2 msg/s | 52.9 s | 3.9 核 | 约 4,070 |
| 去冗余(本 ADR 的前置修复 A) | 20 msg/s | 20 msg/s | 140.8 ms | 1.0 核 | 约 130 |
| 去冗余 | 200 msg/s | 约 110 msg/s | 37 s | 3.7 核 | 约 130 |
单次 LWT 在 Scylla 侧约 220 µs CPU(Paxos 三个阶段各一次读写,system.paxos 的写入强制同步刷
commitlog,即“每次有数据就落盘”),普通读写约 30~50 µs。A 之后一条 record 仍要约 130 次 LWT:
- 每 lane 一行 W 与每 lane 一行 source(
lane_watermark、lane_dispatch):64 lane 各一次 source 插入、一次 W 条件更新。它们只为“逐 lane 重算连续 W”存在;而现行语义是 record 级统一推进(finish_dispatch待整条 record 完成后 64 lane 同到 observed;lane 独立可见性 M-1 尚未实现,仍是发布门禁)。 - 首写者胜与单调取最大(dispatch 身份、保留期 bucket 最大序号、元数据初始化)都用 LWT; 但每个 shard 在同一时刻只有一个 MailboxNode 物化,并发写只在脑裂时出现。
- 进度簿记(
dispatch_chunk、dispatch_observation、completed_dispatch)服务于可恢复的部分进度 与显式 GC;而条目写入是确定性幂等 upsert,W 未推进时整条重放同样正确。
Redis 邮箱已是“一条 Lua 提交 64 lane packed W”(ADR-0015),不受此影响。
决策¶
1. 分片前沿水位取代逐 lane W¶
每 shard 按 epoch 分行 mailbox_shard_frontier(shard, epoch, frontier_offset)(clustering epoch DESC),
读取取最高 epoch 行,前沿 = compose(epoch, frontier_offset),即已连续物化的最大 record 的
mailbox_seq。64 个 lane 的 W = W_floor = W_recomputed = 前沿;lane_watermarks 一次读取。
advance_watermark(s)/finish_dispatch 的 64 个 advance 必须同一 expected_previous 与 observed,
否则返回 Invariant("lane 独立推进未实现")——M-1 落地时以 per-lane packed 前沿(同 ADR-0015 编码)替换
本列,需另立 ADR。不再有 lane_dispatch source 行,也不再有“W 与 source 重算值”的读路径自检。
2. 单调列以写时间戳写入,热路径零 LWT¶
Scylla 按 cell 的写时间戳做 last-write-wins。凡语义为“单调取最大/首写者胜”的列,一律
USING TIMESTAMP 写入,由存储引擎而非 Paxos 保证单调:
| 列 | 主键含 epoch | 写时间戳 | 语义 |
|---|---|---|---|
mailbox_shard_frontier.frontier_offset |
是(clustering) | log offset | 同 epoch 内只进不退;读取取最高 epoch |
mailbox_retention_bucket_v2.bucket_max_seq |
是(clustering) | log offset | 每 (用户, 日桶, epoch) 的最大序号;读取取各 epoch 最大 |
mailbox_dispatch.{digest,manifest,canonical_offset} |
是(分区) | 2^48 − 1 − offset |
最早 observation 胜 |
时间戳只用 48 位 log offset(最大约 2.8×10^14 µs,即 1978 年),epoch 放进主键:Scylla 默认拒绝
超过“当前 + 3 天”的写时间戳(restrict_future_timestamp),含 epoch 高位的 mailbox_seq 原值在
epoch ≥ 7 时即越界(首版按原值实现,契约门禁当场拒绝)。同一分片的 Kafka offset 跨 epoch 仍单调,
所以同一用户的序号在单 owner 下按 offset 与按 mailbox_seq 排序一致。
这些表只由上述语句写入(不得混入服务端墙钟时间戳,否则墙钟值永久胜出),且禁用 TWCS
(它按写时间戳分窗)。读取前沿时校验 WRITETIME(frontier_offset) = frontier_offset,不符即
CorruptEntry;契约门禁另断言身份行的 WRITETIME 与 TTL。
- 正确性依据:客户端只看得到
≤ 前沿的条目,而前沿只在其覆盖的全部条目 durable 后写入; 任何 owner(含脑裂的旧 owner)只在自己的条目 durable 后写自己 epoch 的前沿行,LWW 保证该行不回退, 读者只取最高 epoch 行,旧 owner 的迟到写入不影响读者。 脑裂期间两 owner 可能为同一 record 写出两个 epoch 的条目,客户端按event_id/message_id去重, 与一期 Redis MailboxNode 无主备的至少一次语义相同。 - epoch 失败闭合:读到最高 epoch 行高于配置 epoch 即拒绝(配置回退会让读者一直看到更高 epoch 的旧前沿)。
- 脑裂下的保留期证明:bucket 证明按 epoch 分行,读取取各行最大,跨 epoch 的最大值不受 LWW 影响。
- dispatch 身份:键为
(shard, epoch, dispatch_id),epoch 轮转即新分区(同 Redis 的旧 epoch 视为 Fresh)。单 owner 下“先读、缺失才写”即首写者胜;读到同 ID 异 digest/manifest 返回DispatchConflict。弱化:脑裂下两 owner 同时写同 ID 的不同 payload 不会在写入时被发现, LWW 仍确定性收敛到最早 offset 的一份;该情形本身要求 fanout 为同 ID 产出不同 payload,由qimctl audit dispatch的一致性检查覆盖。
保留期元数据 mailbox_retention_meta 的 ledger 注册与到期巡检仍用 LWT(它们需要比较-交换
多个字段),但按 (用户, 日桶) 在进程内缓存“已注册”,每用户每天最多一次;已到期 bucket 在进入缓存
判定前即走到期分支,缓存条目不会越过其 bucket 的有效期。bucket 证明与条目并发写入:两者都在
frontier 推进前完成,崩溃时 frontier 未推进、整条重放补齐,写入先后不影响可见性。
3. dispatch 进度只保留身份,完成情况留在进程内¶
mailbox_dispatch行带 TTL 31 天(DISPATCH_IDENTITY_TTL_SECONDS),不做显式 GC;gc_completed_dispatches恒返回 0。前提是任何重复 observation 都在首次写入后 31 天内出现: 重复只来自 fanout 从已提交位点重放 Outbox,受 Outbox 保留期约束。qim-mailbox 以 Scylla 后端启动时 校验QIM_OUTBOX_RETENTION_MS + QIM_DISPATCH_RETENTION_MS < 31 天(设置了才校验,未设置按部署 文档默认 7 天 + 1 天)。- 删除
dispatch_chunk、dispatch_observation、completed_dispatch、lane_dispatch、lane_watermark表的读写。chunk 完成情况只记在进程内的在途 record 表:finish_dispatch仍校验“manifest 全部 chunk 已 写入”,进程重启后表为空,Existing 重放重写全部条目(确定性 upsert,结果相同)。record 收尾即出表。 dispatch_progress()的completed_chunks:在途取进程内表;frontier ≥ canonical即全部完成;否则为空。
4. 按页推进(回答“能不能按页写”)¶
新增 MailboxStore::finish_page_limit()(默认 1)与 finish_dispatch_page(默认逐条 finish_dispatch)。
Scylla 返回 64:shard worker 取到一条 record 后,把同一 FIFO 中已排队的连续 record 一并取出(至多
64 条,不等待凑页,低负载时页大小即 1、零附加延迟),并发写完全部条目,再以一次 frontier 写入推进
到页内最后一条的 observed offset。页内任一 record 失败即整页失败、frontier 不动,重放收敛。Redis
返回 1,行为不变。启动追赶沿用逐条收尾。
“每次有数据就落盘”的来源是 LWT:Paxos 状态写入强制同步刷盘。本 ADR 之后热路径只有普通写,
由节点 commitlog 组提交:默认 commitlog_sync: periodic(10 秒)不对多节点同时掉电持久;
承载邮箱 keyspace 的生产集群必须 commitlog_sync: batch(组提交窗口内合并 fsync),否则
frontier 可能越过同时掉电丢失的条目。集成环境沿用默认值并以 tmpfs 承载数据,测得的数字只说明
CPU 与往返,不构成持久性结论。
后果¶
- 每条单聊 record 的 Scylla 操作:dispatch 身份 1 读 + 1 写;每收件人条目 1 写 + bucket 证明 1 写; 每页 frontier 1 写;零 LWT(缓存未命中时的元数据注册除外)。
- 放弃逐 lane W 的独立推进能力(当前未使用)、分阶段 GC 与幽灵行恢复机制(改由 TTL 承担)。
- 旧 Scylla 邮箱表不迁移、不再读取:Scylla 邮箱后端无生产数据;已有开发 keyspace 需新建。
- 与 Redis 的语义差异:Redis 的 dispatch 身份冲突检测是原子的;Scylla 在脑裂下退化为审计发现。
实测(2026-09-28,本机单节点 Scylla 4 核、tmpfs、4+4 Redis 直连、hybrid、单聊,各 60 s 一次,非容量结论)¶
| 形态 | 速率 | 投递 | SEND_ACK P99 | 端到端 P99 | Scylla CPU | e2e |
|---|---|---|---|---|---|---|
| v1 原实现 | 20 | 约 2 msg/s | 7.5 ms | 52.9 s | 3.9 核 | — |
| A(去冗余,仍逐 lane LWT) | 20 | 100% | 8.6 ms | 140.8 ms | 1.0 核 | 16/16 |
| A | 200 | 约 110 msg/s | 7.5 ms | 37.0 s | 3.7 核 | — |
| v2(本 ADR) | 200 | 100% | 8.0 ms | 111.0 ms | 0.2 核 | 16/16 |
| v2 | 2,000 | 100% | 8.0 ms | 73.7 ms | 0.7 核 | 16/16 |
| v2,页上限 64(平均页 4.46) | 10,000 | 100% | 26.6 ms | 131.4 ms | 2.6 核 | 16/16 |
| v2,页上限 1 | 10,000 | 100% | 23.3 ms | 904.9 ms(SLO 失败) | 3.4 核 | 16/16 |
| 对照:Redis 邮箱 | 10,000 | 100% | 23.0 ms | 110.5 ms | —(邮箱 Redis 3.2 核) | 16/16 |
Scylla CPU 含 qim-archiver 的历史写入。v2 每条消息的 Scylla 操作:插入 4.10(其中归档 2)、更新
1.30、读 1.46、LWT 0;页上限 1 时更新 2.11(每条 dispatch 一次前沿写),按页推进把前沿写入摊到
每页一次,并去掉每条 record 收尾的一次串行往返。
验收¶
- 3 节点 RF3 契约门禁(
tests/scylla_contract.rs::finish_dispatch_contract与duplicate_observation_contract,已通过):manifest 未写全/final 非 canonical 零推进、前序不一致与 单 lane 推进拒绝、按页链式收尾一次写入、链断裂WatermarkGap且前沿不动、前沿与身份行WRITETIME、 身份行 TTL、同 ID 异 digestDispatchConflict、低时间戳迟到写入不回退、墙钟时间戳写入读取CorruptEntry、更高 epoch 前沿拒绝、重复 observation 复用 canonical 序号且无需重写条目、GC 恒为 0。 保留期段沿用原门禁(过期游标、proof 缺失 fail closed、TTL 只提升 gap 不提升 trim)。 - 单节点 4 核 tmpfs 阶梯 200 → 2,000 → 10,000 msg/s,e2e 16/16、投递 100%;页上限 64 与 1 对照(上表)。
QIM_MAILBOX_FINISH_PAGE_MAX只能调低存储声明的页上限,用于对照与排障。