跳转至

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 收尾的一次串行往返。

验收

  1. 3 节点 RF3 契约门禁(tests/scylla_contract.rs::finish_dispatch_contract 与 duplicate_observation_contract,已通过):manifest 未写全/final 非 canonical 零推进、前序不一致与 单 lane 推进拒绝、按页链式收尾一次写入、链断裂 WatermarkGap 且前沿不动、前沿与身份行 WRITETIME、 身份行 TTL、同 ID 异 digest DispatchConflict、低时间戳迟到写入不回退、墙钟时间戳写入读取 CorruptEntry、更高 epoch 前沿拒绝、重复 observation 复用 canonical 序号且无需重写条目、GC 恒为 0。 保留期段沿用原门禁(过期游标、proof 缺失 fail closed、TTL 只提升 gap 不提升 trim)。
  2. 单节点 4 核 tmpfs 阶梯 200 → 2,000 → 10,000 msg/s,e2e 16/16、投递 100%;页上限 64 与 1 对照(上表)。
  3. QIM_MAILBOX_FINISH_PAGE_MAX 只能调低存储声明的页上限,用于对照与排障。