Kafka 消费端「条件跳过」的不可逆性:at-least-once 下判断"当前不需要处理"写入幂等表后,DB 状态已变,这条消息的副作用就永久丢了——怎么设计可撤销的跳过?
该问题的正文与回答。
背景
我带团队跑订单履约链路,Kafka 3.5(KRaft,3 broker),topic order-event 24 分区(按 order_id 哈希),消费组 8 实例,at-least-once + 幂等表(MySQL 8.0,唯一索引 uk_msg_id)。
消费端的核心逻辑不是「无脑执行」,而是带条件判断的:
// 伪代码
Order order = orderDao.selectById(msg.orderId);
if (order.status == msg.expectedStatus) {
// 状态匹配,执行业务副作用
doSideEffect(msg); // 调 WMS 锁库存 / 调物流取号 / 发通知
idempotentDao.markProcessed(msg.msgId, Action.DONE);
} else {
// 状态不匹配(比如已被退款、已被人工取消),跳过
idempotentDao.markProcessed(msg.msgId, Action.SKIPPED);
}
// 提交 offset
这个「条件跳过」模式在 90% 场景下工作正常。但剩下 10% 的窗口期,我踩了一个反复出现的坑。
现象(真实事故,去年 Q3)
一次 broker 滚动重启,消费组 rebalance 后分区重新分配。一条消息 msg-7391(订单 8821 的「PAID → 触发发货」事件)被重新投递,处理时序如下:
- t0:消息
msg-7391(PAID 事件)和msg-7392(SHIPPED 事件)在同一分区内,正常顺序是 7391 → 7392。 - t1:rebalance 后新实例先消费了
msg-7392(SHIPPED),DB 状态变为SHIPPED,msg-7392标记DONE。 - t2:
msg-7391(PAID)随后被消费。消费端查 DB:status=SHIPPED ≠ expectedStatus(PAID),判定「不需要处理」,写入幂等表msg-7391 → SKIPPED。 - t3:
msg-7391的副作用——「PAID 时触发积分发放」——被永久跳过。 - 用户投诉:付款成功但没收到积分。
事后排查发现:
- 幂等表里
msg-7391的状态是SKIPPED,不可逆; - 我们的幂等表设计只有
DONE / SKIPPED两个终态,没有PENDING_REVIEW或REVERSIBLE状态; - 如果要"恢复",只能人工删幂等表记录 + 手动补发积分,24 分区 × 8 实例的规模下根本对不全。
根因:「跳过」的判定依赖了一个会变的 DB 状态,但「跳过」的结果被永久固化在幂等表里。这两个设计放在一起,就产生了一个不可撤销的错误判定窗口。
已尝试的方案与结果
-
把「跳过」也做成条件写:
INSERT INTO idempotent(msg_id, action) VALUES(?, 'SKIPPED') ON DUPLICATE KEY UPDATE ... WHERE action != 'DONE'。- 问题:如果后来发现应该处理(DB 状态回退了),
SKIPPED记录还在,后续重投还是被挡。
- 问题:如果后来发现应该处理(DB 状态回退了),
-
跳过时不写幂等表,只记日志:
- 问题:rebalance 后同一条消息被重投 N 次,每次都走一遍「查 DB → 判断跳过 → 记日志」,24 分区 × 8 实例下日志量爆炸,而且没有任何持久化记录,出了事还是对不全。
-
给幂等表加
status字段:DONE / SKIPPED / SKIPPED_REVERSIBLE:SKIPPED_REVERSIBLE允许后续被"翻转"回PENDING,重新进入处理流程。- 问题:谁来翻转?翻转的触发条件是什么? 如果靠定时扫描「DB 状态已回退的 SKIPPED_REVERSIBLE 记录」,扫描频率、窗口、竞态都是新问题。
-
把「条件判断」从消费端移到 DB 侧:用
UPDATE orders SET status=? WHERE id=? AND status=expected的 affectedRows 判定,0 行就跳过。- 问题:这只保护了「状态写」这一步,不保护「副作用」——PAID 时的积分发放、WMS 锁库存这些外部调用,不在 DB 事务里。
期望得到的答案形态
我不想要一篇「幂等的重要性」综述(社区已经有好几篇了)。我想看到具体到判定逻辑和表结构的方案:
- 「跳过」到底该不该写幂等表? 如果该写,
SKIPPED状态的生命周期怎么设计?TTL 多长?过期后自动删除还是标记EXPIRED? - 「可撤销的跳过」的触发机制:是事件驱动(DB 状态变更时发一条「重审」事件回 Kafka)还是定时扫描?两种方案的延迟、成本、竞态各是什么?有没有人真的在生产上跑过?
- 「跳过」的副作用对账:如果跳过了「PAID 时触发积分发放」,事后怎么发现?靠 T+1 对账还是实时告警?对账的 SQL 长什么样(我猜是
SELECT msg_id FROM idempotent WHERE action='SKIPPED' AND order.status IN (需要该副作用的状态),但这样写性能扛不住)? - 有没有人用「延迟确认」替代「立即跳过」:不写
SKIPPED,而是写PENDING_REVIEW,等 N 秒后重新检查 DB 状态再决定?N 怎么定?窗口内 DB 状态又变了怎么办? - 最坏情况兜底:如果以上都失败了,人工介入的 SOP 是什么?你们有没有「幂等表回滚工具」?
我引用两条相关讨论
- 提问 [1007]《Kafka 重复消费时幂等表挡不住的外部副作用》——那个问题聚焦「重复做」的副作用,我这个问题聚焦「错误地不做」的副作用,方向相反但根因同源:幂等表是单向门。
- 讨论 [1365]《幂等的判定依据到底是「业务状态机」还是「消息/请求 ID」?》——我认同状态机是更可靠的判定依据,但「用状态机判定跳过」和「跳过结果被固化」是两层事,那一帖没有展开后者。
期待看到:具体的表结构、SKIPPED 状态的生命周期设计、可撤销机制的触发伪代码、以及真实事故后的修正方案。