结论先行:A/B/C/D 都不是正解,正解是把「攒批超时」从对外语义里拿掉。
攒批是 SDK 内部优化,不是业务方该关心的。业务方只该看到一个三态语义:ACK(确认送达)/ NACK(明确失败)/ UNKNOWN(不确定,请靠幂等键去重)。你现在把「攒批窗口到期 → flush → 等 ack → 超时就触发 deliveryTimeoutMs」四步串在一条时间轴上,才造成「整批 407 条一起超时」的尴尬——因为攒批窗口本身就在吃超时预算,broker 侧 GC 2.5s 又吃掉了 80% 剩余预算,两头挤压才逼出了 A/B/C/D 这个伪四选一。
具体做法(可落地,按收益排序):
- SDK 内部 batch 状态机,把「攒批」和「投递」彻底解耦:
PENDING → 攒批中,未 flush(maxWaitMs 到期触发 flush)
IN_FLIGHT → 已 flush 到 broker,等 ack
ACKED → broker 回 ack,通知业务方
UNKNOWN → deliveryTimeoutMs 到期,broker 未回 ack(可能已收到、可能没收到)
NACKED → broker 明确 nack,通知业务方
关键:UNKNOWN 态不自动重发(避免重复),而是把 batch 里每条消息的 messageId 交给后台补偿队列,由 SDK 用同一个 idempotencyKey 异步重发。业务方拿到的返回是 UNKNOWN,不是「超时异常」——SDK 会在后台把这条消息「再投递一次」,靠 broker 侧幂等键去重。这样业务方不用「为 SDK 的超时策略写去重逻辑」,去重逻辑收在 SDK ↔ broker 之间。
- 参数配比(关键判据):
maxWaitMs ≤ deliveryTimeoutMs / 10:攒批窗口最多吃掉 10% 超时预算。你现在 50ms/3000ms=1.6%,其实可以放宽到 300ms(10%),吞吐会更好,因为攒批窗口太短反而让 broker 侧 QPS 升高(你压测已经验证了:maxWaitMs 从 50→10ms 吞吐掉 18%)。deliveryTimeoutMs= P99 RTT × 3 + broker 侧 P99 ack 延迟。broker 侧 GC 2.5s 已经吃掉 80% 预算,问题不在 SDK 参数,在 broker 侧 GC 治理——SDK 再怎么调参数都救不了 2.5s 的 STW。建议 broker 侧用 G1 +-XX:MaxGCPauseMillis=100+-XX:+UseStringDeduplication,把 GC P99 压到 100ms 以内,SDK 侧的超时预算才有意义。- 建议配比:
maxWaitMs=100ms+deliveryTimeoutMs=3000ms+ 允许 3 次重试(每次 1s 预算,幂等键相同)。
- broker 侧必须做的事(否则业务方一定要写去重):
- 支持
IdempotencyKey头,去重窗口 ≥deliveryTimeoutMs × 最大重试次数:3s × 3 = 9s,你现在的 5 分钟够;但如果重试次数调到 10 次,5 分钟就不够了,要扩到 30 分钟。 - 暴露
duplicate_hit_rate指标,业务方能自证「重复率 <0.1%」,不用靠 SDK 日志反推。
- 业务方侧的正确姿势:
- 不要「等 SDK 的 ack 再处理下一条」,而是先按 UNKNOWN 处理:把这条消息的状态置为「待确认」,继续处理下一条。
- 收到 SDK 后台补偿结果(最终 ACK 或 NACK)后,再更新业务状态。
- 这样业务方的代码里完全没有去重逻辑——去重是 SDK 和 broker 之间的协议细节,业务方只关心「最终状态」。
一句话:把「攒批超时」从对外语义里拿掉,对外只暴露 ACK / NACK / UNKNOWN 三态,UNKNOWN 态由 SDK 后台异步补偿(幂等键去重)。你现在「方案 A + 业务方幂等」的痛点,本质是把 SDK 内部状态机的 UNKNOWN 态直接抛给了业务方——把它收回去,让 SDK 自己消化,业务方就干净了。