ADR-0001 邮箱存储分阶段选型¶
状态¶
已接受(2026-08-13)。对应 docs/PLAN.md §7.0、§18.1、§26.6。
后续覆盖(ADR-0007):一期已改为 Redis 7.0.0+ standalone,ScyllaDB 成为下一阶段, 自研 LSM 仍为再下一阶段。因此本文“阶段一直接使用 ScyllaDB”与“Redis 不可用”的原始 取舍不再描述当前起步档;保留的是
MailboxStore抽象、Scylla → LSM 的后续路径与 兼容迁移原则。当前档位以 ADR-0007 和docs/PLAN.md§18.1 为准。
背景¶
UserMailboxEntry(§7.3)是全系统写入量最大的结构:一条 10 万人群消息产生 10 万条轻量引用,
邮箱写入是 §10.2 成本模型中唯一的 O(N) 项。它的访问模式非常窄:
AppendBatch 按 (tenant_id, user_id) 批量追加,键有序
RangeScan 按 (tenant_id, user_id) 前缀 + mailbox_seq 区间扫描,返回条数小
TruncateBefore 按时间窗口批量裁剪
Watermark 按 lane 读写连续物化水位
v1 直接把 Pebble/RocksDB 写成默认选型。问题在于:Pebble(Go 生态)/ RocksDB(Rust 生态)都只是本地 LSM 引擎, 要把它变成生产可用的邮箱存储,还必须自研复制、主备接管、fencing、检查点、重分片、 一致性校验、备份恢复——等于自研一套分布式有状态存储。评审 001 的 D-2 判定这部分成本 被严重低估:它会成为项目最大的一次性投入,且在容量尚未实测(附录 B.7 全部待回填)的 阶段无法证明这笔投入是必要的。
另一方面,如果一开始就锁死 ScyllaDB,则在真正到达大群峰值时会缺少退路:邮箱的访问模式 完全不需要 ScyllaDB 提供的宽行、二级索引、跨分区能力,却要付出它的写放大与协调开销。
决策¶
- 定义
MailboxStore抽象接口,只暴露上述四个操作,MailboxNode只依赖该接口, 不依赖任何具体存储的特性。 - 阶段一使用 ScyllaDB 实现:
PRIMARY KEY ((tenant_id, user_id), mailbox_seq, event_ordinal, event_id)
compaction = TWCS,window = 1 day
consistency = LOCAL_QUORUM,RF = 3
TTL = mailbox_retention_days(默认 30 天)
- 阶段二切换为自研 LSM 实现(Go 生态用 Pebble,Rust 生态用 RocksDB),切换触发条件为以下四条判据任意一条成立 (判据来自 §18.1,专题文档不得另立阈值):
J1 峰值邮箱写入 > 150 万条/秒
J2 邮箱写入 P99 > 20 ms
J3 邮箱层成本 > 全系统总成本的 25%
J4 大群跨节点展开成本 > 节点本地展开成本的 3 倍
- 切换按 §27.2.3 的四阶段迁移执行:双写 → 影子读(差异率 < 0.01%,观察 ≥ 24 小时) → 切主读 → 停旧写;灰度维度为 单 lane → 单 MailboxShard → 5% 分片 → 全量。
- 两种实现必须通过同一套一致性测试(§26.1、§26.3.3、§26.6.2、§26.6.3), 包括"范围查询访问键数精确等于命中条数"和"主备物化结果逐字节相同"。
理由¶
- 接口窄是本决策成立的前提:四个操作没有一个需要事务、二级索引或跨分区查询, 因此两种实现之间的语义差距可以被完全测试覆盖。
- ScyllaDB 已经是
MessageRecord、ConversationHead的选型,阶段一复用它意味着 零新增运维面,团队可以把精力放在分发、水位、投影这些真正的核心逻辑上。 - TWCS + TTL 天然匹配"按时间窗口裁剪"的裁剪语义(§18.3),不需要自己写 compaction 策略。
- 四条判据都是可测量的量,不是"觉得慢了就换"。J3 与 J4 尤其重要:它们说明切换的 理由是经济性与架构对齐,而不是单纯的性能崇拜。
- 先跑起来再优化,可以让附录 B.7 的待实测参数(
entry_ondisk_bytes、lsm_write_amp、lsm_space_amp、per_node_entry_budget)用真实流量回填,而不是用理论值做采购决策。
后果(正面 / 负面)¶
正面
- 阶段一的工程量从"自研分布式存储"降为"写一层薄适配",首个可用版本的时间显著提前。
MailboxStore接口本身成为回归测试的稳定边界,两种实现可以长期并存做对照验证。- 容量参数用真实数据回填,避免了在没有数据的情况下做不可逆的技术承诺。
- 若四条判据长期不触发,则永远不需要自研——这本身就是一个合法结局。
负面
- ScyllaDB 的写放大高于本地 LSM,阶段一的邮箱层单位成本会偏高;这是有意接受的短期代价。
- 大群展开时
MailboxNode需要跨网络写 ScyllaDB,而不是本地 WriteBatch, 单次 dispatch 的延迟更高(对应判据 J4)。 - 需要长期维护两套实现的一致性测试,增加约 15% 的测试维护成本。
- ScyllaDB 的 TTL 删除会产生墓碑;必须靠 TWCS window 对齐裁剪窗口来避免墓碑扫描
(§26.1.1 断言
tombstones_scanned == 0,这条断言在阶段一尤其关键)。
替代方案及其否决理由¶
| 方案 | 否决理由 |
|---|---|
| 一开始就自研 LSM 实现(Pebble/RocksDB) | 需要自研复制、接管、fencing、检查点、重分片与备份恢复,是项目中最大的一次性投入;在容量参数全部未实测的阶段无法证明其必要性 |
| 永久使用 ScyllaDB,不留切换路径 | 邮箱访问模式不需要 ScyllaDB 的任何高级能力,却要付出其写放大与协调开销;到达大群峰值时无退路 |
| 用 Redis / KeyDB 存邮箱 | 内存成本无法承载 mailbox_retention_days = 30 的数据量;且违反 §3 "不让 Redis 成为必经路径" |
| 用 Kafka 分区直接充当个人邮箱 | 客户端将被迫扫描公共日志过滤自己的消息,直接违反 §3 的第一条禁令 |
| 关系型数据库(分库分表) | 单表写入吞吐与在线 DDL 能力不足;分库分表的重分片复杂度不低于自研 LSM |
| 对象存储 + 索引文件 | 随机小读延迟不满足登录同步的 P99 目标(§26.2.3 要求 1 万条积压 ≤ 10 s) |
复评条件¶
出现以下任一情况时必须重开本 ADR:
- J1~J4 中任一判据在生产环境连续 7 天成立。
- 附录 B.7 的实测回填结果显示
entry_ondisk_bytes超过估算值 110 B 的 3 倍。 - ScyllaDB 侧出现无法通过配置解决的墓碑扫描或 compaction 放大问题
(表现为 §26.1.1 的
tombstones_scanned == 0断言持续失败)。 mailbox_retention_days被上调到 90 天以上,使邮箱层数据量增加一个数量级。mailbox_write_policy切换为mention_only(见ADR-0003),邮箱写入量下降一个 数量级,此时 J1/J3 可能长期不成立,应评估是否永久放弃阶段二。- 阶段一被迫对
user_mailbox_entry引入显式 DELETE(如合规删除无法用加密擦除覆盖): 这违反 §18.1.3 "禁止显式 DELETE 写入"的前提(TWCS 下范围墓碑既不能提前释放空间, 又会击穿tombstones_scanned == 0断言),必须重开本 ADR 重新评估压缩策略或存储实现。