跳转至

Fanout 第二条 Outbox 饥饿诊断

日期:2026-08-23 状态:历史故障诊断,修复结论仍有效;提交语义已由 ADR-0012 继续演进为无 Kafka 事务 的 durable dispatch → source checkpoint 顺序。本文对“事务不变”的描述仅指修复当时。

Bug 摘要

隔离 Redis + Redpanda E2E 中,第一条消息完整送达;第二条消息已收到 durable SEND_ACK,但发送者 15 秒内没有收到 PUSH_EVENTS。服务均保持健康,说明故障位于提交后的 Outbox 消费链。

已验证假设

  1. 假设:writer 未提交第二条消息。
  2. 证据:客户端已收到第二条消息的 SEND_ACK;该 ACK 只能由 COMMITTED 结果生成。
  3. 结果:否定。
  4. 假设:loadgen 把服务端错误缓存后无限等待。
  5. 证据:recv_matching 原先确实会缓存非目标 ERROR 且没有 deadline;修复后明确得到“等待发送者 PUSH 超时”,没有服务端 ERROR。
  6. 结果:该门禁缺陷成立,但不是本次消息缺失的根因。
  7. 假设:fanout 对同一 after 的空轮询反复 seek,饿死下一 offset。
  8. 证据:RdkafkaCommitLog::read_committed(Some(after)) 每轮都执行 seek(after + 1),紧接一次零等待 poll;fanout 主循环空轮询后仍传相同 after。rust-rdkafka 的 seek 会重定位“下一次 poll”读取位置,重复重定位会持续取消刚发起的 fetch。第一条记录走初始 Offset::Stored,因此可消费;第二条首次进入该循环后稳定复现饥饿。
  9. 结果:确认。

根因

  • 直接原因:相同 after 的每次空轮询都重新 seek。
  • 根本原因:适配器把调用方的逻辑游标当成每轮都必须执行的物理定位命令,没有保存“已定位到该 after”的本地状态。

修复方案

RdkafkaCommitLog 持久到进程内记录最近一次已成功定位的 after:同值空轮询只继续 poll,不再 seek;游标只能单调向前,回退或从已定位状态退回 None 明确失败。新增纯状态单测,并把真实 Redpanda ignored 门禁扩展为“第一条确认后先空轮询,再发布并消费第二条”。loadgen 同时对下行等待加入 15 秒 deadline,并在非 ERROR 请求收到服务端 ERROR 时立即失败。

影响文件

  • crates/qim-commit-log/src/lib.rs
  • crates/qim-loadgen/src/net.rs
  • crates/qim-loadgen/src/acceptance.rs

回归风险

中低。该次修复在当时不改变 Kafka 事务、dispatch 内容或源位点提交,只避免冗余 seek; 后续 ADR-0012 已移除 Kafka 事务。风险集中在调用方传入回退游标时现在会显式失败。 真实 Redpanda 连续记录测试和完整 E2E 用于覆盖该边界。