跳转至

ADR-0016:Outbox 按提交桶分区与冻结成员快照缓存

  • 状态:部分已接受(§决策 1 已实施,2026-09-25);§决策 2 为提议,待实施与实测
  • 日期:2026-09-25
  • 相关:ADR-0012(Fanout 不用 Kafka 事务)、ADR-0013(Redis 横向分片)、ADR-0014(实例数是容量参数)

背景

2026-09-25 按代码核对 FanoutCoordinator 的实际形状(crates/qim-fanout/src/lib.rs):

  • Outbox 是单分区 topic(QIM_OUTBOX_PARTITION 固定一个分区),只有一个 fanout 进程消费,run_batch_once 对批内 record 顺序规划。
  • 每条群消息 record 都调用一次 GroupMembership::snapshot_at,即 3 次 Redis 往返 (HGET owner、EXISTS、SMEMBERS),落在未分片的 core Redis 实例 0。
  • 单聊 record 不读成员,但同样串行经过同一个进程。

目标档峰值提交 34,722 msg/s(PLAN §25.0.3)下,这个形状有两个上限:单分区的 broker 侧顺序写与单消费者的顺序规划;以及每条群消息 3 次同步 Redis 往返。 写侧(writer)已按 conversation 桶分到多实例(ADR-0013),mailbox 已按 64 分区 并行;fanout 是链路上最后一个单点。

决策 1(已实施):(tenant, group, membership_version) 快照进程内缓存

成员快照按 membership_version 固化后不可变,群不可重建、版本单调递增,因此 命中永远等价于一次真实读取。RedisFrozenAudienceReader 增加 FIFO 有界缓存: 上限 4096 条快照、成员总数 2,000,000(约 32 MiB);单条超过总上限的快照不入缓存。

缓存只免去读取,不免去一致性证明:冻结的 member_count 与 target_shards 仍逐次核对;不一致仍返回 MembershipSnapshotUnavailable。淘汰错误只会多读一次。

活跃群的连续消息几乎全部命中同一版本,因此群消息的 fanout 规划从每条 3 次 Redis 往返降为 0 次。单聊路径不受影响。

决策 2(提议):Outbox 按提交桶分区,fanout 静态指派分区集合

  • Outbox topic 分区数固定为 outbox_partition_count,建集群后不可变(与 mailbox_shard_count 同类不可变项)。writer 以 commit_bucket(tenant, conversation) 对分区数取模选择分区;同一会话的消息落在同一分区,保持会话内顺序。fanout 不需要 跨会话全序:dispatch 的顺序保证由 mailbox 侧按 partition offset 构造 mailbox_seq 提供,与 Outbox 分区无关。
  • fanout 实例通过静态 assign() 持有互斥的分区集合(禁消费者组自动 rebalance, 与 dispatch 日志同一纪律);每个分区独立维护 source offset 与批次。readiness 必须 逐分区验证 topic/分区存在与位点可读。
  • 恢复语义不变:仍是"全部 dispatch durable 后再提交批末 source offset",只是按 分区各自执行;dispatch_id 仍是逻辑唯一性边界(ADR-0012)。
  • 迁移:新建多分区 topic 而非改旧 topic 分区数;writer 切换发布目标前,fanout 先把 旧单分区 topic 消费到高水位并提交;切换期间两个 topic 并行消费,重复由 dispatch_id + payload_digest 收敛。旧 topic 在保留期后删除。

实施前必须回答:分区数取值(起步建议与 mailbox_shard_count 同为 64,便于 一实例一分区或一实例多分区的静态划分);多 fanout 实例的指派来源(一期用配置, 阶段一由 ShardRegistry 下发);以及 qim_fanout_source_batch_size 等指标改为 按分区打标签(标签是分区号,低基数)。

后果

  • 决策 1 的代价是进程内存上限 32 MiB 与"缓存快照被 GC 后仍可用"这一更宽松的 可用性,两者都不改变正确性。
  • 决策 2 把 fanout 从单点变为可横向扩展的分区消费者,但引入一个新的不可变项 与一次 topic 迁移;在目标硬件 180 秒门禁证明单 fanout 不是瓶颈前,不应实施。

参考

  • crates/qim-fanout/src/lib.rs:FrozenSnapshotCache、RedisFrozenAudienceReader
  • docs/PLAN.md §17.1(FanoutCoordinator)、§25.0.3(目标档速率)