跳转至

Fanout Outbox 保留期位点缺口诊断

Bug 摘要

Fanout 使用消费者组已提交位点读取 Outbox,但启动时配置了 auto.offset.reset=earliest。当 fanout 停机超过 Outbox topic 的保留期,已提交位点会落在 broker low watermark 之前;librdkafka 会静默从 earliest 重置,跳过已经过期但 writer 已标记 COMMITTED 的 Outbox 记录。对应消息没有 dispatch、邮箱条目或后续补偿,收件人永久看不到 该消息。

已验证假设

  1. 假设:fanout 使用可静默重置的 stored offset。
  2. 证据:qim-commit-log/src/lib.rs 给 BaseConsumer 设置 auto.offset.reset=earliest,并以 Offset::Stored 静态 assign Outbox 分区。
  3. 结果:确认。
  4. 假设:启动时会校验 stored offset 是否位于 broker 保留范围。
  5. 证据:同一文件对 mailbox dispatch 消费者已有 validate_dispatch_start_offsets,但 RdkafkaCommitLog::connect 未读取 Outbox low/high watermark 或 committed offset; qim-fanout/src/main.rs connect 后直接进入消费循环。
  6. 结果:否定。
  7. 假设:writer 或 fanout 后续链路能补偿已过期的 Outbox 记录。
  8. 证据:writer 的提交状态在 Outbox durable publish 后进入 COMMITTED;fanout 只能从 Outbox 消费后事务性发布 dispatch。没有按 canonical record 扫描并补发 dispatch 的恢复器。
  9. 结果:否定。
  10. 假设:部署保留期足以覆盖可恢复停机窗口。
  11. 证据:docs/03-mailbox-and-group-fanout.md 明定 Redis 与分发日志保留期必须联合校验, 但当前 fanout 启动配置不要求或校验 Outbox 保留期与恢复窗口。
  12. 结果:否定。

根因

  • 直接原因: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 Admin DescribeConfigs 读取 Outbox topic 的实际 retention.ms;该配置必须存在、可解析、 不能是 -1(无限/语义歧义),且必须不少于恢复窗口。Admin 请求或该资源被 ACL 拒绝时 fail closed。QIM_OUTBOX_RETENTION_MS 不再作为应用内重复声明,部署编排可保留它仅用于 创建/校验 broker topic 配置;
  • 在通过校验后以已证明安全的 offset 静态 assign;不得先以 Stored assign 再交给 broker 的 reset 策略决定起点。

影响文件

  • crates/qim-commit-log/src/lib.rs
  • crates/qim-fanout/src/main.rs
  • docs/designs/diagnosis_20260823_fanout_retention_offset_gap.md

回归风险

中等且有意为之。此前会静默跳过消息的过期 offset、未设置保留期证明的 fanout 部署将改为 启动失败;这是防止持久消息无声丢失所必需。已在运行且位点合法的 consumer 会从相同的下一条 offset 继续;首次空 topic(low=high=0)的 bootstrap 保持可用。