跳转至

提案架构总结:重客户端直写链路(会话节点合并 + 候选持久化介质)

日期:2026-09-01;修订:2026-09-26(补 §8:现行架构已吸收的部分与新的对照基线) 性质:讨论中提出的替代方案整理。未实现、未实测,且若干 P0 契约未决;本文的 「目标形态」不是已完成设计。对比见 arch_20260901_comparison.md,现行架构见 arch_20260901_current_log_driven_summary.md。

1. 一句话

单聊拟把 writer 与 mailbox 合并为会话节点;群聊拟把提交节点与异步展开节点合并或紧耦合。 客户端仍持久化待发消息并重发,但它不能替代服务端的提交恢复:服务端须以 (tenant_id, sender_id, client_message_id) 持久 CommitIntent,固化规范消息坐标和 后续恢复所需输入。展开/投递指令可抽象介质,但介质能力与恢复协议尚未选定。

2. 单聊目标链路与 ACK 谓词

1. A ──SEND_MESSAGE──> A 的 gateway
2. 按 conversation_id 路由到会话节点
3. 会话节点:
   3.1 以 (tenant, sender, client_message_id) 条件创建或读取 CommitIntent;
       首次成功时固化 canonical message_id、所选排序坐标及完整规范请求。
   3.2 幂等写历史。
   3.3 幂等写 A、B 的近 7 天用户存储。
   3.4 可选 LRU 只作缓存,不承载正确性。
   3.5 由可重试恢复者确认两份用户存储均已 durable 且读路径可见,才把
       CommitIntent 标为 COMPLETE。
4. 仅当 CommitIntent=COMPLETE 时 ACK ──> A
5. 查 B 在线则尽力推送;离线时 B 从用户存储拉取。

单聊 ACK 谓词是:

ACK == 同一 CommitIntent 的规范消息已固化
       ∧ A、B 两份用户存储均达到所选存储的 durable 确认
       ∧ 两者均已通过可读性门并标记 COMPLETE

3.2 与 3.3 是跨记录/跨分区双写,ACK 前双写不等于故障原子性:崩溃可留下一份 历史或单边用户记录。故 CommitIntent 必须在副作用前持久化完整恢复输入,恢复者必须能从 任一中间态按同一坐标续做;客户端重发只是触发/协助该恢复,不能作为唯一恢复源。存储主键 只能让已知同一 message_id 的写入收敛,ACK 丢失后的客户端并不知道该 ID,因此不能替代 (tenant, sender, client_message_id) 的提交事实,也不能宣称“零 dedup 表”。

失败模型:ACK 前失败或超时时,请求重试和恢复者都续做同一 CommitIntent;ACK 后不再依赖 客户端重发。推送、通知等副作用必须以首次完成的持久事实为条件,或明确允许重复。

3. 群聊目标链路与 ACK 谓词(P0 未完成)

群聊不能以“正文持久化一份”作为 ACK 条件。若要在不等待全员投递的情况下 ACK,目标谓词应为:

群聊 ACK == MessageRecord 已持久
           ∧ GroupCommitIntent 已持久(含规范消息坐标)
           ∧ 发送时的不可变 membership_version 已固化且可在恢复期读取
           ∧ 所有目标落点的确定性投递任务已被可恢复介质接收

这不等待每个成员的邮箱引用落盘,但要求任何 ACK 后的崩溃、假死、漂移或重复消费,都能由 独立恢复者依据 GroupCommitIntent、冻结成员版本、确定性任务 ID 和持久进度继续展开。 当前讨论没有定义这些事实、恢复者、进度提交顺序或介质确认,故群聊 ACK、成员可达性与 迁移安全仍是 P0 未决;客户端不会重发已 ACK 的群消息。

按成员用户落点分组与现行按 MailboxShard 合并 GroupDispatch 在算法形态上相似,但这不能证明 恢复语义相同:现行还依赖冻结快照、DispatchProgress、确定性 dispatch_id 与“先条目后进度”的 重放约束。

4. 候选持久化介质:先验能力矩阵,不是可直接选档

可以将投递任务的接口抽象出来,但 Redis Stream 和本地 WAL 目前只是候选,不能预先承诺 “无丢失”或“中间档”。任何候选至少须用能力矩阵和故障实验验证:

能力 必须回答的问题
提交原子性 CommitIntent、正文与任务接收如何原子化或由恢复者证明可续做?
节点/机房故障 宿主永久丢失、磁盘损坏、主从切换时,已 ACK 任务是否仍可读取?
进度与幂等 任务 ID、条目写入与进度的提交顺序是什么;重复任务如何收敛?
保留与重放 保留期是否覆盖最长恢复、成员快照与进度 GC 的证明?
消费隔离 会话列表、未读、审计是否需要独立 checkpoint、重放及背压隔离?
候选 当前可作的陈述 尚需证明
内存队列 明确允许进程故障丢失在途任务 不满足 ACK 后可恢复投递
本地 WAL 可在单机重启后尝试恢复 节点永久丢失、复制/迁移、保留、进度与独立消费者
Redis Stream 可作为待验证的外部任务介质 AOF/复制/故障切换确认、消费确认与重放、隔离消费者、保留与恢复边界
Redpanda 现行具备 durable delivery、可重放、多消费者/独立 checkpoint 的实现基础 仍须遵守现行的进度、快照、保留和 fencing 契约

现行 OutboxPublisher / DispatchLogConsumer 的 trait 是复用接口的起点,不表示“补一个中间档” 即可得到等价可靠性。

5. 可能收益(均以 P0 闭合为前提)

  1. 部件可能更少:单聊若不采用外部任务介质,可少 broker 与独立 fanout 进程;相应的 CommitIntent、恢复者、可见性门和迁移机制会进入合并节点。
  2. 延迟有降低假设:可能省去日志和进程间路径,但必须在相同 ACK 谓词、硬件、负载、时长和 故障注入下实测。旧 TCP 直连架构的 15,000 msg/s / P99 116ms 是不同链路的历史数据, 不得为本提案的性能或可靠性背书。要击败的现行基线(同一开发机,180 秒): 10k msg/s 时 SEND_ACK P50/P99 8/17 ms、端到端 P50/P99 73/108 ms;15k msg/s 时 端到端 P99 163~250 ms(见现行总结 §6)。
  3. 幂等可集中在存储:消息/用户记录可按固化坐标同值覆盖;但首次坐标分配与 ACK 丢失重试仍 需要持久 CommitIntent,不能简化为“主键即全部幂等”。
  4. fencing 仅在极强前提下才可能省略:必须证明顺序分配、client_message_id 首次映射、成员 快照/授权和可见性门都由带 epoch/CAS 的线性化存储执行,且数据面拒绝旧 owner。只要节点仍持有 这些正确性状态或可在租约过期后写入,就必须保留等价 fencing;“键幂等 + 双写等 ACK”不足以防脑裂。

6. 代价与 P0 未决项

  1. 排序与同步尚未定案。时间戳不能作增量下界,跨节点 HLC 也非会话全序;可保留 conversation_seq、改为服务端覆盖证明,或另设计游标/REBUILD,三者的组合与成本尚未比较。
  2. “时间戳早于已拉下界”会永久漏且无人察觉。具体场景:B 已拉取并持久化 after_timestamp=1000;一条消息先取得服务端时间戳 990,却因双写重试、节点切换或用户存储 延迟到 B 完成本次拉取后才可见。B 后续只请求 timestamp > 1000,该记录 990 永远不会返回; 若协议没有连续 seq、covered_through 或显式 REBUILD 证明,客户端和服务端都没有信号知道漏了。 单节点单调时钟只能防本节点倒退,不能解决“先取坐标、后延迟可见”或跨节点全序问题。
  3. 群恢复未定义:冻结成员快照、任务介质确认、进度、恢复者、重复/陈旧 owner 和重放保留期 必须先形成可验收契约。
  4. 会话权威与未读未定义:不能在 ACK 路径同步为每个成员求值,也不能退化为无恢复源的通知; 必须定义会话集合、已读水位、投影/未读重建的持久权威和输入流。
  5. 7 天之后的恢复未定义:用户存储按用户还是会话组织、过期设备如何得到会话集合/历史/控制事件、 CURSOR_* 与 REBUILD 如何迁移,均未定案。不得以“换设备全量拉”代替协议。
  6. 成员快照未定义:无 Outbox 时在何处原子冻结 membership_version、其保留期如何覆盖重试与 历史授权,尚无答案。
  7. 弹性和副作用仍存在:targetId 路由、状态迁移、重推/通知去重以及多租户配额/削峰均须单独设计。
  8. 未实现、未实测:以上均为待验证设计,不得与现行的已实现链路作容量或发布等级比较。

7. 进入原型前的 P0 决策

  1. 写 ADR:单聊 CommitIntent、坐标分配、双写可见性门和恢复者;明确 ACK 丢失、节点崩溃和 跨节点重试向量。
  2. 写 ADR:群 GroupCommitIntent、发送时成员快照、确定性任务/进度、ACK 谓词和恢复/GC/保留证明。
  3. 写 ADR:会话集合、已读/未读、控制事件与 7 天过期后的权威恢复和 REBUILD 协议。
  4. 对 Redis Stream、本地 WAL 与 Redpanda 按 §4 能力矩阵做故障实验;不通过者不得承载 ACK 后任务。
  5. 以上闭合后,再以相同语义运行单聊/小群原型的延迟、覆盖率、崩溃和迁移测试。

8. 2026-09-26 补记:现行架构已吸收的部分

  • 本文 §2 步骤 3.3「幂等写 A、B 的近 7 天用户存储」所依赖的「近期数据在热层、长期 数据在库」思路,已由 ADR-0018 在日志驱动侧落地:提交热路径只写 Redis(7 天 history_hot_window),HistoryArchiver 消费 Outbox 异步批量归档到 ScyllaDB。这消除了 现行链路里「每条消息同步写 ScyllaDB」的成本,但 ACK 谓词、Outbox、fanout 与邮箱物化 均未改变;它不是本提案的实现,也不改变 §6 的 P0 未决项。
  • 该落地同时给出了本提案 §6.5「7 天之后的恢复」必须回答的一个具体问题的现行答案: 超出热窗口的读取按每会话「热层下界」拼接归档层,裁剪只在归档水位覆盖后进行; 提案若采用用户级 7 天存储,仍需自行定义等价的下界、水位与拼接协议。
  • 现行同步写 ScyllaDB 的实测(每条约 6.5 次串行 LWT,200 msg/s 即 ACK P99 252 ms) 同样约束本提案 §2 的「3.2 幂等写历史 + 3.3 双写用户存储 + 3.5 可见性门」:若这些 写入落在 ScyllaDB 并用 LWT 保证首次固化,其 ACK 时延不会低于现行,除非把它们放进 单机线性化存储——此时 §5.4 的 fencing 前提又成为必答项。