Fanout Outbox 保留期位点缺口诊断¶
Bug 摘要¶
Fanout 使用消费者组已提交位点读取 Outbox,但启动时配置了
auto.offset.reset=earliest。当 fanout 停机超过 Outbox topic 的保留期,已提交位点会落在
broker low watermark 之前;librdkafka 会静默从 earliest 重置,跳过已经过期但 writer 已标记
COMMITTED 的 Outbox 记录。对应消息没有 dispatch、邮箱条目或后续补偿,收件人永久看不到
该消息。
已验证假设¶
- 假设:fanout 使用可静默重置的 stored offset。
- 证据:
qim-commit-log/src/lib.rs给 BaseConsumer 设置auto.offset.reset=earliest,并以Offset::Stored静态 assign Outbox 分区。 - 结果:确认。
- 假设:启动时会校验 stored offset 是否位于 broker 保留范围。
- 证据:同一文件对 mailbox dispatch 消费者已有
validate_dispatch_start_offsets,但RdkafkaCommitLog::connect未读取 Outbox low/high watermark 或 committed offset;qim-fanout/src/main.rsconnect 后直接进入消费循环。 - 结果:否定。
- 假设:writer 或 fanout 后续链路能补偿已过期的 Outbox 记录。
- 证据:writer 的提交状态在 Outbox durable publish 后进入
COMMITTED;fanout 只能从 Outbox 消费后事务性发布 dispatch。没有按 canonical record 扫描并补发 dispatch 的恢复器。 - 结果:否定。
- 假设:部署保留期足以覆盖可恢复停机窗口。
- 证据:
docs/03-mailbox-and-group-fanout.md明定 Redis 与分发日志保留期必须联合校验, 但当前 fanout 启动配置不要求或校验 Outbox 保留期与恢复窗口。 - 结果:否定。
根因¶
- 直接原因:Kafka consumer 的 offset-reset 策略把“已提交位点已被 broker 删除”当作可恢复 的 earliest 定位,而不是数据完整性失败。
- 根本原因:fanout 启动边界没有把 Outbox 的 group offset、broker watermarks 与部署保留期 联合证明;缺失 committed offset 与过期 committed offset 也没有区分。
最小修复¶
- 将 fanout consumer 的
auto.offset.reset设为error,禁止 broker 自行改写位置; - 在 fanout 启动前显式校验 Outbox topic、固定分区、broker low/high watermark 和该 consumer
group 的 committed offset;已提交 offset 必须满足
[low, high]; - 首次全新 group 仅在
low == 0时允许从 offset 0 bootstrap;low > 0时缺失 offset 不能 证明没有丢失历史,必须拒绝启动; - 强制部署提供
QIM_OUTBOX_RECOVERY_WINDOW_MS,并在启动期通过 Kafka AdminDescribeConfigs读取 Outbox topic 的实际retention.ms;该配置必须存在、可解析、 不能是-1(无限/语义歧义),且必须不少于恢复窗口。Admin 请求或该资源被 ACL 拒绝时 fail closed。QIM_OUTBOX_RETENTION_MS不再作为应用内重复声明,部署编排可保留它仅用于 创建/校验 broker topic 配置; - 在通过校验后以已证明安全的 offset 静态 assign;不得先以
Storedassign 再交给 broker 的 reset 策略决定起点。
影响文件¶
crates/qim-commit-log/src/lib.rscrates/qim-fanout/src/main.rsdocs/designs/diagnosis_20260823_fanout_retention_offset_gap.md
回归风险¶
中等且有意为之。此前会静默跳过消息的过期 offset、未设置保留期证明的 fanout 部署将改为 启动失败;这是防止持久消息无声丢失所必需。已在运行且位点合法的 consumer 会从相同的下一条 offset 继续;首次空 topic(low=high=0)的 bootstrap 保持可用。