跳转至

ADR-0005 小会话正文内联与 §3 禁令 4 的收窄

状态

已接受(2026-08-13)。对应 docs/PLAN.md §3 禁令 4、§7.3、§18.3.1、§21.4、附录 A.4.1、附录 B.3、附录 B.5.1。

本 ADR 修改一条已锁定决策(§3 禁令 4 / CLAUDE.md「禁止为群成员复制完整消息正文」), 因此必须先于任何实现落地。

背景

§7.3 规定邮箱只存引用,正文由 MailboxNode 在读路径从 LRU 或 MessageStore join (附录 A.4.1)。这条设计消除了大群的 N 份正文复制,是 §10.2 成本模型成立的前提。

但 join 的成本结构在评审中被误述过。真实结构是:

join 成本 = O(批次内不同 conversation 数),不是 O(条目数)

同一会话的连续消息落在同一分区、同一 seq_bucket(§7.1),一次范围读全取回。

代入两种回填形态:

回填场景 条目数 范围读次数 单条 join 成本
1 个 5000 人大群的积压 500 1 0.002
20 个单聊各 5 条 100 20 0.2

大群的 join 最便宜,小会话的 join 最贵,相差两个数量级。

原因是两边被各自的成本逼到了相反的选择上:

  • 大群承受不起内联——正文复制 N 份,N 可达 10 万;
  • 小会话承受不起 join——每个会话一次跨分区往返,而典型用户的积压恰恰散布在几十个小会话里。

§3 禁令 4 的措辞「不为群成员复制完整消息正文」保护的是前者(10 万人群的 10 万份正文), 但字面覆盖了后者(13 人群的 13 份正文)。这是禁令写得比其保护意图更宽的第二次出现 (第一次是评审 001 发现 §3 禁令 3 把读扩散中间档一并误杀,已由 §3「见下方澄清」修补)。

决策

1. 引入正文内联,判据为乘积字节预算而非成员人数

inline_body = (N × body_size <= inline_body_budget_bytes)      默认 8 KiB
              AND (body_size <= inline_body_max_bytes)          默认 2 KiB
              AND (retention_class == default)

N = 该条消息的收件人数(即本条消息产生的 UserMailboxEntry 条数)
body_size = payload_or_ciphertext 的字节数(不含 media_metadata 的缩略图)

判定由 ConversationWriter 在提交时完成一次,结果写入 GroupDispatch.inline_body (§7.8),MailboxNode 据此决定物化时是否把正文写进条目,不得由各 MailboxNode 独立判定 ——否则同一条消息在不同分片上的内联结果不一致,破坏 §6.7 的确定性物化。

预算生效效果(自适应,不需要在"50 还是 100 人"上拍脑袋):

正文 100 B(短文本,占比最高)  → N <= 81    小群也内联
正文 600 B(典型)              → N <= 13
正文 2 KB(长文本 / 卡片)      → N <= 4     基本只有单聊

2. §3 禁令 4 收窄

原措辞:

不为群成员复制完整消息正文

新措辞:

不为超出 inline_body_budget_bytes 的会话复制完整消息正文

禁令的保护对象不变(大群),但不再误伤小会话。CLAUDE.md 同步修改。

3. 三条强制约束(缺一不可)

(a) 合规链:内联副本使 UserMailboxEntry 首次持有正文,§21.4 第 4 行原本声明 "它不含密文,无 dek_id,不参与加密擦除",该声明失效。内联条目必须携带 dek_id 并进入加密擦除链;且 retention_class != default(ephemeral_24h / compliance_hold) 一律不内联——否则 24 h 销毁要追 N 份散落在各用户邮箱、保留期长达 7~30 天的副本, 与 §21.3 的时间桶子密钥方案直接矛盾。

(b) pull_mailbox_max_bytes 重标定:条目从 110 B 涨到最大 2.1 KB, 500 条批次的字节量从 55 KB 涨到最坏 1 MB,会先于 max_items 触顶而缩小批次条数。 该参数必须在压测中按内联比例重新标定(附录 B.3 已标注)。

(c) 撤回 / 编辑的陈旧副本:内联副本在撤回后变陈旧。客户端按 mailbox_seq 顺序 应用 §13.4 的 CONTROL 事件后收敛到正确状态,功能等价;差别仅在读时 join 模型下 离线期间被撤回的消息拉下来即为"已撤回",内联模型下客户端会先渲染正文再撤掉。 UX 略差,不构成正确性问题,不需要额外机制。

理由

  1. join 成本是 O(会话数),内联恰好消除的就是"会话数多、每会话条目少"这一最坏形态。
  2. 乘积预算比人数阈值更贴合真实约束:真正的约束是字节,不是人头。一个 10 人群发 30 KB 卡片不该内联,一个 100 人群发 30 B 的"收到"应该内联。
  3. 存储代价可控。按 §25.0.2 的构成(条目占比:单聊 1.2% / 中群 15.7% / 大群 82%) 与 b=600 B、e=110 B(内联后 710 B,涨 6.45 倍):
阈值 100 人(覆盖全部中群):内联 16.9% 的条目 → 邮箱总量 +92%
                            目标档 1.85 PB → 3.55 PB
阈值 ≈13 人(8 KiB 预算):  内联 1.5~3% 的条目 → 邮箱总量 +8~16%
                            目标档 1.85 PB → 2.0~2.15 PB

100 人阈值要多花 1.7 PB 去省往返,且 82% 的条目仍来自大群、仍要走 join—— 付了双份存储却删不掉任何代码路径。8 KiB 预算把代价压到 10% 量级。

  1. 不改变任何契约核心的语义。内联只是给 A.4.1 的 body_included 增加一条为真的成因, 客户端解析路径不变(body_included=true 时正文随条目到达,本来就是既有分支)。

后果

正面

  • 消除小会话回填的跨分区往返,典型场景 20 次范围读降到 0。
  • body_included=false 的发生率下降(内联条目不受正文 LRU 命中率影响)。
  • 单聊路径不再依赖 MessageStore 读,降低登录风暴时对正文表的读放大。

负面

  • 邮箱驻留容量 +8~16%(按默认预算与目标档构成估算,需压测回填)。
  • UserMailboxEntry 进入加密擦除链,§21.4 第 4 行的删除方式复杂化。
  • 撤回后存在陈旧内联副本,直到客户端应用 CONTROL 事件(不影响正确性)。
  • 批次字节量上升,pull_mailbox_max_bytes 需重新标定。

替代方案及其否决理由

方案 否决理由
维持现状(全部读时 join) 小会话回填的往返次数正比于会话数,登录首屏延迟在会话多的重度用户上不可控
固定人数阈值(50 / 100 人) 与真实约束(字节)错位;且 100 人阈值使邮箱容量翻倍,收益仅覆盖 17% 的条目
全量内联 直接违反 §10.2 的成本模型,10 万人群一条消息复制 10 万份正文
只对单聊内联 收益的大头确实在单聊,但把"13 人小群"排除在外没有技术依据——它的 N×body 与单聊同量级
在 MailboxNode 侧按分片独立判定内联 同一消息在不同分片结果可能不一致,破坏 §6.7 的确定性物化与主备/漂移重放的同值覆盖

复评条件

出现以下任一情况时重开本 ADR:

  1. 压测显示内联使邮箱驻留容量增幅超过 25%(说明真实 body_size 分布与假设偏离);
  2. body_included=false 的发生率在内联开启后未下降(说明瓶颈不在 join);
  3. §21 的加密擦除演练显示内联副本的擦除时限无法满足 §21.4 第 4 行的 ≤ 24 h 目标;
  4. 引入 E2EE 后单聊密文体积显著大于明文(inline_body_max_bytes 需要重新取值);
  5. mailbox_write_policy=mention_only(ADR-0003)启用后,中群的条目构成发生变化。