现行架构总结:日志驱动的提交-展开-物化链路¶
日期:2026-09-01;修订:2026-09-26(历史分层 ADR-0018、fanout 生产者 ADR-0017、 180 秒门禁实测、发布阻断清单更新) 性质:架构现状总结与优缺点自评,供与替代方案对比(见
arch_20260901_direct_write_proposal.md与arch_20260901_comparison.md)。 契约权威仍是docs/PLAN.md;本文性能数字均来自同一台开发机 (E5-2680 v4 14C/28T、62 GB、三星 PM9A1 消费级 NVMe、同机 Docker),不是目标硬件容量结论。
1. 一句话¶
消息先由 writer 在 Redis 里持久提交(CommitIntent 状态 + 近期正文/历史 + Redpanda Outbox) 即回 ACK,再由 fanout 异步展开为按分片分组的投递指令写入 dispatch 日志,最后由 mailbox 消费日志物化进每个收件人的邮箱;历史由 HistoryArchiver 独立消费 Outbox 异步批量归档到 ScyllaDB,超出 7 天热窗口且已归档的历史才从 Redis 裁剪。恢复分别依赖提交事实、 Outbox/dispatch 日志、邮箱 DispatchProgress/W、session checkpoint、归档水位与客户端 pending, 不是任何崩溃都只靠日志。
2. 链路与持久化点¶
A ──SEND_MESSAGE──> gateway(校验/限流/审核回调,会合哈希选 writer)
▼
writer:一次融合 Lua:幂等查 + 分配 conversation_seq + message_id(HLC)
+ ①写 Redis 热层(正文+历史索引,history_hot_window=7 d)
→ ②发布 Outbox(Redpanda,单分区,含完整 canonical MessageRecord)
→ 第二次 Lua:COMMITTED(commit hash 设 2 h 绝对过期)──SEND_ACK──> A
★ACK 不等展开、不等归档
├─▶ fanout:消费 Outbox → 冻结成员版本 → 按 MailboxShard 合并
│ → ③写 dispatch 日志(Redpanda,64 分区,分区号=MailboxShardId)
└─▶ archiver:独立消费组消费同一 Outbox → 批量普通 INSERT ⑤ScyllaDB
(先记录后索引,无 LWT,同值幂等)→ 全批确认后提交位点 → 发布归档水位
▼
mailbox:消费 dispatch → ④写收件人邮箱引用条目 → 推进 lane 水位 W
→ durable 后 PushBatch(尽力而为)
▼
B 的 gateway ──PUSH_EVENTS──> B;B 以 PULL_MAILBOX 拉正式数据并推进游标
(B 离线:条目在邮箱等着,登录后拉同一个邮箱;在线/离线不分叉)
session 服务独立消费同一条 dispatch 日志,维护会话列表投影;未读在列表读取时按
定义式计算,session 不写入未读计数。
writer 每 5 s 裁剪热层:只删「提交超过 7 天 ∧ 归档水位已覆盖」的历史,并抬高每会话
热层下界;读路径按下界拼接 Redis 与 ScyllaDB(`TieredMessageStore`)。
五个主要数据面物化层:①近期正文与历史(Redis 热窗口,7 天)、②消息已提交事实及 Outbox、③展开后的 dispatch 投递指令、④每人一条的轻量邮箱引用(约百字节,10 万人群 = 10 万条引用 + 1 份正文)、⑤永久历史(ScyllaDB 归档,全系统唯一一份长期正文)。 此外,恢复还依赖 CommitIntent/ClientDedup、DispatchProgress 与 64-lane 水位、session checkpoint、归档水位与热层下界等持久恢复事实;它们不是额外的正文副本。
3. 关键不变量(为什么是这个形状)¶
- SEND_ACK 只在 COMMITTED 之后:正文进热层且 Outbox 持久了才告诉 A「算数了」。 历史版本曾在可靠分发前回 ACK,审计定为发布阻断缺陷并已修复。
- 提交热路径只写 Redis(ADR-0018):同步写 ScyllaDB 实测每条约 6.5 次串行 LWT, 在 200 msg/s 时 ACK P50/P99 184/252 ms 即超 SLO;历史的持久归档改为异步批量, 数据源是 Outbox(多副本、可重放)而非 Redis AOF(崩溃可丢约 1 秒)。
- 裁剪只删「到期且已归档」:归档器停摆时归档水位停住,Redis 只涨不删——安全失败, 绝不以"到期"为由删除未归档的历史。
- 连续水位:现行
mailbox_seq直接取 dispatch 日志 partition offset,没有旧直写 的"分配时登记/完成时销账"两次往返。W 是持久化的 64-lane 状态;每个 record 的 全部 lane/chunk 条目完成且expected_previous连续后,才推进到当前 observed offset, 不能取最大值越过未物化区间。 - 确定性身份:冻结编码为
blake3("qim.group-dispatch.v1\0" || message_id_be16 || target_shard_be4)[0..16], 重放任意次收敛到同一结果;随机 UUID、文本拼接和原生整数布局都被禁止。 - 成员版本冻结:群消息按发送时刻的成员快照展开,在约定恢复窗口内重放也不受 此后加人踢人影响。
- 先产出、后提交位点:fanout 与 archiver 都必须先取得全部产出已持久的证据, 再推进自己的消费进度;顺序反了 = 崩溃后整批跳过。
- 投递日志边界已部分抽象:
OutboxPublisher/DispatchLogConsumer是 trait, 生产使用 Redpanda、测试可用内存实现;但 fanout 批处理边界、checkpoint 语义和生产装配仍 带 Redpanda 形态,不能据此宣称替换介质只需补一个 adapter。
4. 优点¶
- 恢复不依赖客户端在线,但受保留窗口约束。ACK 之后 A 卸载 app、B 暂不上线时,
只要 dispatch 日志和邮箱条目仍在保留窗口内,投递可由日志重放、邮箱离线拉取补完;
超出窗口会返回
CURSOR_EXPIRED,客户端按REBUILD重建,不能承诺固定"三个月"。 半成品(展开到一半崩溃)有 DispatchProgress/W 等持久恢复事实。当前 qsession 主要读取convs:{user}投影、read:{user}与 canonical history;会话集合的持久权威user_conversation_state(ScyllaDB)在会话首次进入集合时登记,checkpoint 越窗或投影全失 时按投影纪元惰性重建(ADR-0021)。越窗期间首次出现且之后无消息的会话仍无法恢复。 - 群聊 ACK 与展开解耦。千人群的 ACK 延迟与成员数无关;展开异步、 失败重试永不丢弃已提交消息。
- 可支持独立消费者。qsession 独立消费 dispatch 维护会话集合投影;archiver 独立消费 Outbox 归档历史;未读在读路径由 MessageStore 与 read state 定义式计算,并非另一份日志 投影。未来审计可拥有独立 checkpoint,不必进入 writer ACK 热路径。
- 削峰。突发流量堆在 broker 里,mailbox 与 archiver 按自己的节奏消化(分区级 暂停已实现:单个过载分片不再拖累其余 63 个)。
- 同步完整性有显式覆盖证明。
conversation_seq与个人 mailbox 序列都允许空洞;客户端 依靠MAILBOX_BATCH.covered_through_seq、64-lane W、签名游标与CURSOR_EXPIRED/REBUILD 判断覆盖边界,而不是用 seq 差值猜测是否漏消息。 - 历史分层后 Redis 内存有界。Redis 只承载 7 天热窗口(约 360 B/条)、30 天邮箱 (约 410 B/条)与 2 小时提交辅助状态(约 2.2 KB/条),永久历史在 ScyllaDB; 此前提交辅助 hash 无 TTL、依赖每分钟 256 条的回收,持续写入超过约 4 条/秒即无界累积, 已改为进入 COMMITTED 时设 2 小时绝对过期。
- 180 秒门禁在开发机稳定通过(见 §6):10,000 msg/s 连续 3/3、15,000 msg/s 连续 3/3、历史分层形态 10,000 msg/s 通过;T-HEAVY 首屏 P99 < 1 ms;功能 smoke 15/15; ScyllaDB 归档行数与提交数逐条对齐(1,800,040 = 1,800,040)。
5. 缺点(诚实清单)¶
- Redpanda 引入的复杂度是真实成本:librdkafka/CMake 构建链、readiness
与 topic 预检、位点语义特判(
Offset::Invalid)、分区暂停/恢复。 ADR-0012/0014/0016/0017 整卷都在给它付账。两跳日志的固定成本现已可测:10k msg/s 下 ACK P50 8 ms 而端到端 P50 73 ms,中间约 65 ms 是 fanout 批周期(每批约 30 ms,瓶颈在 librdkafka 客户端而非 broker)+ mailbox 物化 + 推送;1k msg/s 下为 ACK 4.9 / 端到端 43.8 ms。 - fanout 是结构性单点:Outbox 单分区、单进程串行批处理,批周期约 30 ms、批上限
512 条即约 15k msg/s 封顶(
QIM_FANOUT_BATCH_SIZE=1024可推到约 2 倍);根治是 ADR-0016 决策 2 的按提交桶分区,未实施。 - 弹性缺陷(多为自伤,非 Redpanda 固有):
- Redis 实例映射用
shard % 实例数,违反本项目自己的「禁物理节点取模」 禁令:实例数必须整除桶数(4 台加到 5 台启动拒绝)、翻倍迁移 50%、 无迁移工具(ADR-0013/0014 已记录,方向定为会合哈希 + 搬迁工具,未实施)。 - dispatch 分区数被冻结为
MAILBOX_SHARD_COUNT = 64,改动需 epoch 迁移。 GroupMembership未分片,固定落 core Redis 实例 0;非分片数据所在的默认实例最先饱和。- 设计目标的 lane 独立可见性尚未实现:当前一条 dispatch record 的全部 chunk 完成后 才统一推进 64 lane,空 lane 也不能提前越过;因此慢 lane/大群仍会卡住同 shard 的 其他 lane。这是当前调度/水位实现的自伤,不是 Redpanda 固有限制。
- 热路径成本:物化走 Redis Lua,成本归因先后四次推断被实测推翻 (教训:单线程热路径必须直接 perf 采样);15k 时每条消息耗 Redis CPU 约 0.71 ms (core 0.34 + mailbox 0.37),其中 Lua 与命令约 69%。Redis 实例数因此是容量参数: 10k 需 4+4、15k 需 8+8。曾有 24 处调用点每次现算 Lua 脚本 SHA-1(占 mailbox 三分之一 CPU),已改为进程内缓存。
- 历史分层新增的依赖与风险(ADR-0018):多一个服务(qim-archiver)与一个 Outbox
消费组;ScyllaDB 三节点必须部署且用带掉电保护的 SSD(消费级盘 fsync 6.5 ms 会把
任何 LWT 推到 20~30 ms);Redis AOF
everysec崩溃可让conversation_seq回退 ≤ 1 秒, 新消息复用已归档序号后归档的普通 INSERT 会静默覆盖——发布阻断,须在 writer 检测 回退并重新播种或 core Redis 用appendfsync always;升级前已在 Redis 但不在 Outbox 保留期内的历史需一次性回填。 - 运维面宽:六个服务(gateway/writer/fanout/mailbox/session/archiver)+ Redis 多实例
- Redpanda + ScyllaDB 三节点;每个 Redis 实例与 Redpanda 的 AOF/日志必须落在各自独立、 写延迟有保证的盘上(共享一个 ext4 journal 时一次慢提交会同时冻结所有实例,本机实测); 集成环境 17 个端口确定性选址存在 TOCTOU 竞态窗口(安全失败但拖慢迭代)。
- 发布阻断项未完(2026-09-26 更新):目标 lane 独立推进门禁、目标硬件 180 秒 + T-HEAVY
- 混沌门禁、升级前旧历史回填、生产常驻的增量 orphan 审计游标。已闭合:群成员可见区间与
UserConversationState会话集合兜底(ADR-0021)、fencing_epoch一期不适用(ADR-0020)、 历史保留 30 天可配(ADR-0019)、序号回退(appendfsync always,ADR-0018)、门禁 9 的 orphan 审计。 - packed-v2 不支持同分片滚动升级:升级必须先 fence 旧 owner(安全但停服)。
6. 数字备查¶
180 秒门禁(同机隔离环境,1200 连接,e2e 预热后;Redis save ""、AOF rewrite 门槛
8 GB、服务直连 Redis 容器 IP、Redpanda smp=4/4G):
| 形态 | 速率 | 到达率 | SEND_ACK P99 | 端到端 P99 | 备注 |
|---|---|---|---|---|---|
| 纯 Redis,4+4 实例 | 10,000 | 9,999.9+ ×3 | 16.4 / 17.0 / 19.5 ms | 103.4 / 104.2 / 107.9 ms | 连续 3/3;T-HEAVY 首屏 P99 0.7~0.8 ms |
| 纯 Redis,8+8 实例,批 1024 | 15,000 | 14,930~14,938 ×3 | 52.6 / 71.2 / 124.3 ms | 163.2 / 186.7 / 250.4 ms | 连续 3/3;整机 87~88% 忙 |
| 分层(Redis 热层 + Scylla 归档),4+4 | 10,000 | 9,999.96 | 16.8 ms | 107.8 ms | 归档 1,800,040/1,800,040,失败 0,水位滞后 479 ms |
对照与短压(非结论): 同步写 ScyllaDB 形态在 200 msg/s 时 ACK P50/P99 184/252 ms (每条约 6.5 次串行 LWT,消费级盘 fsync 6.5 ms);跳过 fsync 后 LWT 31.5→3.1 ms 但单核节点 CPU 在 200 msg/s 即饱和。4 核小主机 + 单 Redis 实例:2,000 msg/s 通过、2,500 勉强、3,000 崩溃,封顶是 Redis 单线程;4 vCPU(2 物理核 + 超线程)少约 25~35%。
历史 TCP 直连架构(writer→mailbox 直写、无日志)曾实测 15,000 msg/s / P99 116ms(180 秒),但其 ACK、持久化与恢复语义均不等价;现行链路已在同类开发机 以更严格的 ACK 谓词达到同一速率。该历史数字只是一条观察,不能作为当前链路或直写 提案的性能上限、容量结论或可靠性旁证。