提案架构总结:重客户端直写链路(会话节点合并 + 候选持久化介质)¶
日期: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 闭合为前提)¶
- 部件可能更少:单聊若不采用外部任务介质,可少 broker 与独立 fanout 进程;相应的 CommitIntent、恢复者、可见性门和迁移机制会进入合并节点。
- 延迟有降低假设:可能省去日志和进程间路径,但必须在相同 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)。
- 幂等可集中在存储:消息/用户记录可按固化坐标同值覆盖;但首次坐标分配与 ACK 丢失重试仍
需要持久
CommitIntent,不能简化为“主键即全部幂等”。 - fencing 仅在极强前提下才可能省略:必须证明顺序分配、
client_message_id首次映射、成员 快照/授权和可见性门都由带 epoch/CAS 的线性化存储执行,且数据面拒绝旧 owner。只要节点仍持有 这些正确性状态或可在租约过期后写入,就必须保留等价 fencing;“键幂等 + 双写等 ACK”不足以防脑裂。
6. 代价与 P0 未决项¶
- 排序与同步尚未定案。时间戳不能作增量下界,跨节点 HLC 也非会话全序;可保留
conversation_seq、改为服务端覆盖证明,或另设计游标/REBUILD,三者的组合与成本尚未比较。 - “时间戳早于已拉下界”会永久漏且无人察觉。具体场景:B 已拉取并持久化
after_timestamp=1000;一条消息先取得服务端时间戳 990,却因双写重试、节点切换或用户存储 延迟到 B 完成本次拉取后才可见。B 后续只请求timestamp > 1000,该记录 990 永远不会返回; 若协议没有连续 seq、covered_through或显式 REBUILD 证明,客户端和服务端都没有信号知道漏了。 单节点单调时钟只能防本节点倒退,不能解决“先取坐标、后延迟可见”或跨节点全序问题。 - 群恢复未定义:冻结成员快照、任务介质确认、进度、恢复者、重复/陈旧 owner 和重放保留期 必须先形成可验收契约。
- 会话权威与未读未定义:不能在 ACK 路径同步为每个成员求值,也不能退化为无恢复源的通知; 必须定义会话集合、已读水位、投影/未读重建的持久权威和输入流。
- 7 天之后的恢复未定义:用户存储按用户还是会话组织、过期设备如何得到会话集合/历史/控制事件、
CURSOR_*与 REBUILD 如何迁移,均未定案。不得以“换设备全量拉”代替协议。 - 成员快照未定义:无 Outbox 时在何处原子冻结
membership_version、其保留期如何覆盖重试与 历史授权,尚无答案。 - 弹性和副作用仍存在:targetId 路由、状态迁移、重推/通知去重以及多租户配额/削峰均须单独设计。
- 未实现、未实测:以上均为待验证设计,不得与现行的已实现链路作容量或发布等级比较。
7. 进入原型前的 P0 决策¶
- 写 ADR:单聊
CommitIntent、坐标分配、双写可见性门和恢复者;明确 ACK 丢失、节点崩溃和 跨节点重试向量。 - 写 ADR:群
GroupCommitIntent、发送时成员快照、确定性任务/进度、ACK 谓词和恢复/GC/保留证明。 - 写 ADR:会话集合、已读/未读、控制事件与 7 天过期后的权威恢复和 REBUILD 协议。
- 对 Redis Stream、本地 WAL 与 Redpanda 按 §4 能力矩阵做故障实验;不通过者不得承载 ACK 后任务。
- 以上闭合后,再以相同语义运行单聊/小群原型的延迟、覆盖率、崩溃和迁移测试。
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 前提又成为必答项。