栈:Kafka 3.5.1(KRaft,3 broker,8C32G)、Spring Kafka 3.1.5、Java 17(G1)、MySQL 8.0.36(NVMe SSD)、K8s 1.29 场景:订单事件流
order-event,48 分区,消费组 16 实例,max.poll.records=500,日均约 4500 万条
一、现象与背景:「重试整批」让 299 条已落库的消息又跑了一遍
我们的消费逻辑:poll() 拿 500 条 → 逐条写 MySQL(JDBC batch,rewriteBatchedStatements=true)→ 整批 commitSync。上线三个月后,一个反复出现的告警:
「订单
ORD-20260915-88231状态从PAID回退到CREATED」
排查发现:一批 500 条中,第 37 条(一条 ORDER_CANCEL 事件)写库时撞了 DuplicateKeyException(幂等表里已有该 biz_id,说明之前某次重试已写入过)。我们当时的策略是「批内任何一条失败 → 整批不 commit → 等 broker 重投 → 500 条全部重新处理」。结果:前 36 条(包括 29 条 ORDER_CONFIRM)被重新写入,其中一条 ORDER_CONFIRM 的幂等检查因为 biz_id 规则与 ORDER_CANCEL 不同而通过了,把已经 CANCELLING 的订单状态又刷回了 CONFIRMED。
一周内这类「状态回退」工单 4 起,涉及 12 笔订单,其中 3 笔已发货,靠客服人工拦截。
二、根因:「批」是传输优化,不是事务单元——部分失败打破了 at-least-once 的整批语义
Kafka 的 at-least-once 承诺的是:一条消息至少被处理一次。但当我们把 500 条消息打包成一个「处理单元」时,实际语义变成了:
500 条消息要么全部被处理,要么全部不被处理(等重投)。
这引入了「部分成功、部分未处理」的中间态,而「整批重试」策略把这个中间态放大为「已成功的 299 条被再处理一次」。
更本质的问题:批内消息之间可能存在业务依赖。ORDER_CONFIRM 和 ORDER_CANCEL 是同一订单的两条独立消息,但它们的执行顺序和幂等键不同。整批重试打破了「先确认、后取消」的时序假设——不是消息顺序错了,而是「已经取消的订单又被确认了一次」。
四种策略的本质区别:
| 策略 | 语义 | 重复量 | 丢失风险 | 复杂度 | |------|------|--------|----------|--------| | A:整批重试 | at-least-once per batch | 高(最多 N-1 条重复) | 无 | 低 | | B:跳过失败,提交其余 | 成功条 at-least-once / 失败条 at-most-once | 零 | 失败消息丢失(除非有 DLQ) | 中 | | C:逐条确认 | at-least-once per message | 低(仅失败条重试) | 无 | 高 | | D:两阶段批处理 | at-least-once per message | 低 | 无(需验证阶段兜底) | 高 |
三、具体做法:策略 D(两阶段 + 单条重试 + DLQ)的实现
核心思想:把「处理」和「确认」拆成两个阶段,失败条走独立路径,不拖整批。
阶段 1(乐观处理):500 条全部尝试写入,失败的记入 failedList,成功的记入 succeededList。不 commit。
阶段 2(验证与提交):
failedList为空 →commitSync(offset + 500),结束。failedList非空 → 对失败条做单条重试(最多 3 次,指数退避 200ms / 800ms / 3200ms)。- 重试成功 → 归入
succeededList。 - 重试仍失败 → 写入 DLQ topic(
order-event-dlq),从本批中跳过。
- 重试成功 → 归入
- 无论失败条最终是否成功,都
commitSync(offset + 500)——已成功的不会重放,失败的已在 DLQ 里有记录。
// 核心逻辑(伪代码)
List<Record> batch = poll();
List<Record> succeeded = new ArrayList<>();
List<Record> failed = new ArrayList<>();
for (Record r : batch) {
try {
process(r); // 写 DB + 调下游
succeeded.add(r);
} catch (Exception e) {
failed.add(r);
}
}
if (failed.isEmpty()) {
commitSync(offset + batch.size());
return;
}
// 单条重试,指数退避
for (Record r : failed) {
boolean ok = retryWithBackoff(r, 3, List.of(200, 800, 3200));
if (ok) {
succeeded.add(r);
} else {
sendToDlq(r, e); // 写 DLQ topic,独立消费组做补偿
}
}
// 整批 offset 一次提交
commitSync(offset + batch.size());
关键配置:
# consumer
max.poll.records=500
enable.auto.commit=false
max.poll.interval.ms=300000
session.timeout.ms=10000
auto.offset.reset=latest
# DLQ topic:3 分区,retention 30d
# 独立消费组 order-event-dlq-compensator,1 实例,max.poll.records=10
# 幂等表:UNIQUE(biz_id),biz_id 粒度 = 事件级(ORD-xxx-CONFIRM / ORD-xxx-CANCEL 是两条独立记录)
量化效果(上线 6 周后):
- 整批重试次数:日均 3.2 次 → 0(失败条走单条重试 + DLQ,不再拖整批)
- 重复写入量:日均 ~1400 条 → ~12 条(仅 DLQ 补偿重放时的重复)
- 「状态回退」工单:4 起/周 → 0
- DLQ 消息量:日均 3~8 条,人工补偿 SLA < 30min
- 单批处理 P99 耗时:4.2s(整批重试时)→ 820ms(单条重试不拖整批)
四、踩过的坑与反直觉之处
坑 1:「跳过失败、提交其余」看似零重复,实际是 at-most-once 的伪装。
策略 B 对失败的消息语义等价于 at-most-once。如果你的幂等表只覆盖「成功写入」场景(biz_id UNIQUE 只拦重复 insert,不拦「该写没写」),失败的消息就静默丢失。我们最初就是这么做的,丢了 3 条 ORDER_CANCEL,靠对账脚本第二天才发现。所以策略 B 必须搭配 DLQ,否则它不是「零重复」,是「高级别丢失」。
坑 2:单条重试的幂等窗口比整批重试窄,但对幂等键粒度要求更高。
整批重试时,幂等表里的 biz_id 是「上一批写入的」,重试时 INSERT ... ON DUPLICATE KEY UPDATE 天然幂等。但单条重试时,如果这条消息的 biz_id 和同批其他消息的 biz_id 有前缀重叠(ORD-20260915-88231-CONFIRM 和 ORD-20260915-88231-CANCEL),幂等表的 UNIQUE 键设计就决定了它们是两条独立记录——重试 CONFIRM 不会误触 CANCEL 的幂等检查,反之亦然。这要求幂等键的粒度是事件级而非订单级。
坑 3:DLQ 不是「垃圾桶」,它是一个需要独立消费组的 topic。
DLQ 消息如果没人消费,就是「高级别丢失」。我们给 DLQ 配了独立消费组(order-event-dlq-compensator),1 实例,max.poll.records=10(小批量,人工审核),处理逻辑是「查原消息上下文 → 重试 → 成功则标记补偿完成」。DLQ retention 设 30 天,超过 30 天未补偿的消息告警到值班群。
反直觉:策略 C(逐条确认)在「看起来最精确」的同时,把吞吐打到了 40%。
逐条确认意味着每条消息都要等 commitSync 返回(~2-5ms),500 条就是 1000-2500ms 的额外延迟,而且失去了 JDBC batch 的合并写入优化(每次只写 1 条,rewriteBatchedStatements 失效)。对日均 4500 万条的场景,这个延迟累积到 lag 上就是持续 30 分钟以上的积压。
五、结论与可复用清单
- 「批」是传输优化,不是事务单元。 部分失败时,不要默认「整批重试」——它把 at-least-once 悄悄变成了「at-least-once per batch」,重复量放大 N 倍。
- 策略 D(两阶段 + 单条重试 + DLQ)是 90% 场景下的最优解。 代价是幂等键必须设计到「事件级」粒度,DLQ 必须有独立消费组。
- 幂等键的粒度决定了你的重试策略上限。 订单级幂等键 → 只能整批重试;事件级幂等键 → 可以单条重试。先定幂等键粒度,再选重试策略。
- DLQ 是「有 SLA 的延迟补偿」,不是「高级别丢失」。 配独立消费组 + retention + 告警,否则它和丢数据没有区别。
- 量化你的「重复容忍度」。 下游能吸收 1% 重复(幂等表兜底)→ 策略 A 够了;不能(状态回退、资金重复扣减)→ 必须上策略 D。