ADR-0017:FanoutCoordinator 的 dispatch 生产者关闭幂等¶
- 状态:已接受(2026-09-26,由项目负责人决定;实测数据见“验证”)
- 相关:ADR-0012(Fanout 不用 Kafka 事务)、ADR-0016(Outbox 分区与冻结快照缓存)、
docs/PLAN.md§6.5、§10.1.2
背景¶
2026-09-26 在本机以 15,000 msg/s 压测时,FanoutCoordinator 成为第一个瓶颈:每批 “读 Outbox → 规划 → 写出 dispatch 并等待持久确认 → 同步提交源位点”串行执行, 每批 30.5 ms,其中等待 dispatch 确认 27.4 ms、提交位点 1.8 ms。
拆分计时与 broker 自报指标排除了 Redpanda:
| 测量 | 结果 |
|---|---|
| Redpanda 每个 produce 请求的处理时延(broker 自报) | 平均 0.25 ms,99.2% < 2 ms |
| Redpanda 4 核/4G → 8 核/8G | 等确认 27.4 → 29.3 ms,无改善;Redpanda 只用约 1 核 |
| 同时段 writer 单条 Outbox 发布 | 平均 4.8 ms |
原因在客户端:librdkafka 按分区各发一个 produce 请求,一批约 520 条 dispatch 落在
64 个 dispatch 分区上即约 64 个请求;enable.idempotence=true 时每条 broker 连接最多
5 个请求在途,于是一批需要约 13 轮往返。broker 侧 produce 请求约 2,048/s,与
“30 批/s × 64 + writer 的 Outbox 请求”吻合。
决策¶
dispatch 生产者默认 enable.idempotence=false(acks=all 不变),由
QIM_FANOUT_PRODUCER_IDEMPOTENCE(仅接受 true/false,默认 false)控制,可随时
改回。writer 的 Outbox 生产者保持幂等:它只写一个分区,不受在途上限制约。
为什么正确性不依赖幂等¶
幂等从来不是 dispatch 的逻辑唯一性来源(ADR-0012、docs/PLAN.md §10.1.2):同一
dispatch 在故障切换或重放时本就会以不同 offset 追加多次,MailboxStore 以持久的
dispatch_id + payload_digest 收敛,重复记录复用首次 mailbox_seq,水位仍按观察到的
offset 连续推进。
关闭幂等只新增两种、仅在 produce 请求失败重试时出现的现象:
- 重复记录:与既有重放语义相同,由上述去重吸收。
- 同一分区内两条不同 dispatch 的相对顺序对调:
mailbox_seq由 offset 构造, 受影响用户邮箱中两条消息的mailbox_seq先后可能与conversation_seq先后相反。 会话内顺序、历史分页、未读定义式与会话投影(ZADD GT)都以conversation_seq/created_at为准,连续水位按 offset 推进,均不受影响;设备游标仍只由MAILBOX_BATCH连续推进。
“fanout 先取得整批 durable delivery、再提交源位点”的顺序约束(ADR-0012)不变, 因此不会丢消息。
后果¶
- 预期每批等待由约 13 轮往返降为约 1~2 轮;实测见下。
- 重试时的重复与分区内对调属于已接受行为,不设恒零指标;若将来有依赖
“邮箱内
mailbox_seq先后与conversation_seq先后一致”的功能,必须先改回幂等 或另立 ADR。 - 这是解除客户端在途上限的最小改动,不替代 ADR-0016 决策 2 的 Outbox 分区扩展。
验证¶
2026-09-26 同机、同配置(15,000 msg/s × 60 s、8+8 Redis、批上限 512、服务直连 Redis):
| 指标 | 开幂等 | 关幂等 |
|---|---|---|
| 每批等 dispatch 确认 | 27.4 ms | 25.3 ms(−8%) |
| 每批 dispatch 数 | 523 | 486 |
| 提交源位点 | 1.8 ms | 1.8 ms |
背景一节“5 在途 → 约 13 轮往返”的解释被实测否定:若它是主因,关闭幂等后应降到
数毫秒。收益仅约 2 ms/批。更符合数据但尚未证实的模型是 64 个分区请求全走同一条连接、
被依次处理,叠加 librdkafka 默认 linger.ms=5 与本机 CPU 饱和下的唤醒排队;下一步的
决定性验证是生产者按分区拆成多条连接,或把 fanout 生产者 linger.ms 调到 0。
决定保持默认关闭(项目负责人判断重试故障率低、语义代价可接受);若后续验证表明 收益可由多连接或 linger 调整单独取得,应重新评估是否改回幂等。