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 略差,不构成正确性问题,不需要额外机制。
理由¶
- join 成本是 O(会话数),内联恰好消除的就是"会话数多、每会话条目少"这一最坏形态。
- 乘积预算比人数阈值更贴合真实约束:真正的约束是字节,不是人头。一个 10 人群发 30 KB 卡片不该内联,一个 100 人群发 30 B 的"收到"应该内联。
- 存储代价可控。按 §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% 量级。
- 不改变任何契约核心的语义。内联只是给 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:
- 压测显示内联使邮箱驻留容量增幅超过 25%(说明真实
body_size分布与假设偏离); body_included=false的发生率在内联开启后未下降(说明瓶颈不在 join);- §21 的加密擦除演练显示内联副本的擦除时限无法满足 §21.4 第 4 行的 ≤ 24 h 目标;
- 引入 E2EE 后单聊密文体积显著大于明文(
inline_body_max_bytes需要重新取值); mailbox_write_policy=mention_only(ADR-0003)启用后,中群的条目构成发生变化。