Event Sourcing 消费端崩溃重投时,幂等靠 sequence CAS 还是本地 event_log 表?
该问题的正文与回答。
【背景】
我们在做一个交易流水的事件溯源系统:Kafka 3.5(3 broker)承载 tx-event topic(32 分区,per-transaction 顺序保证),消费者是 16 实例的 Java 服务。每个事件携带 tx_id + event_seq(业务递增序号),消费者将事件追加到本地 event_log 表(MySQL 8.0),再更新业务状态表 order_state。
【卡点】 核心问题:消费者在「处理完事件、更新本地状态」之后、提交 Kafka offset 之前崩溃。重启后 offset 回退,同一条事件被重新消费。此时幂等保证靠什么?我们内部两种方案在争:
方案 A(sequence CAS):消费时 UPDATE order_state SET version=? WHERE order_no=? AND version < ?,靠业务版本号做乐观锁。问题:状态表只存「当前版本号」不存历史事件,重放 seq=6 时不知道 seq=5(部分退款)已执行过,业务语义丢失。
方案 B(本地 event_log 去重表):消费前先 SELECT 1 FROM event_log WHERE tx_id=? AND event_seq=?,有记录就跳过。问题:日均 2000 万事件,event_log 表已 40 亿行,归档任务每周跑一次 P99 延迟 45 分钟;归档窗口内的重投如何处理?
【已尝试】
- 方案 A 压测通过,但业务方不接受「状态表不存历史事件」
- 方案 B 跑了 3 个月,大表问题越来越严重
- 考虑过 Redis 做去重(TTL=7 天),但运维担心 Redis 故障时去重丢失
- 也看过「Kafka offset 本身当版本指针」的思路,但 offset 是 consumer group 粒度的,多实例下不好用
【期望得到的答案形态】
- 你们在 Event Sourcing / 事件溯源场景下,消费端崩溃重投的幂等方案具体是什么?(表结构 DDL、关键 SQL、TTL / 归档策略)
- 如果 event_log 表已经很大,归档策略怎么设计?按时间还是按 seq 范围?归档后「归档窗口内的重投」怎么兜底?
- 有没有人用过「Kafka offset 当版本指针 + 本地 event_log 做二次校验」的混合方案?实际效果如何,踩过什么坑?
- 更开放地:如果让你重新设计,你会不会干脆放弃「消费端自己做幂等」,改用「Kafka 事务 + 本地事务」的 atomic commit 语义?性能预算够吗?
【2026-10-23 补充】 发布这个问题后,我在站内「Kafka 批量消费部分成功、重试整批时只用 DB 做幂等」的讨论(#2278)里看到一套三段式事务 + outbox 模式,回过头来发现它直接适用于 Event Sourcing 的崩溃重投场景,比方案 A / B 都干净。补充进来供后来者参考:
方案 C(三段式事务 + outbox + reclaim):
- T1(同一事务):
INSERT event_log (tx_id, event_seq, status=0, owner, claimed_at)+UPDATE order_state+INSERT outbox (tx_id, event_seq, status=pending)→ COMMIT。这一步把「去重记录 + 业务状态 + 副作用意图」绑定在同一事务里,崩溃时三者一起回滚,不存在「去重已写、状态未更新」的撕裂窗口。 - 事务外:执行副作用(调下游 API / 发通知),携带
idem_token = tx_id + event_seq。 - T2(同一事务):
UPDATE event_log SET status=1 WHERE tx_id=? AND event_seq=? AND status=0 AND owner=?+UPDATE outbox SET status=1→ COMMIT。
崩溃语义:T1 提交后、副作用执行前崩溃 → 重投时看到 pending → reclaim 后重新执行副作用(idem_token 保证不双发);副作用执行后、T2 前崩溃 → 同理,idem_token 兜底。这和方案 A/B 的根本区别是:去重表不再只做「有没有处理过」的二值判断,而是做了「处理到哪一步」的状态机,崩溃窗口被压缩到「副作用执行中」这一个点,且这个点有 idem_token 兜底。
reclaim 机制:16 实例并发时,用 SELECT ... FOR UPDATE SKIP LOCKED 抢占 pending 行,claimed_at < NOW() - INTERVAL 10 MINUTE 判定超时。注意 reclaim 扫描的索引设计:(claimed_at, status) 比 (status, claimed_at) 回表率低 60%(我们 6 实例 × 300 条/批实测)。
event_log 大表问题:方案 C 下 event_log 仍然会涨到 40 亿行,但清理策略可以从「按时间软删」升级为「按月 RANGE 分区 + DROP PARTITION」——秒级完成,不产生 undo / purge 压力。保留期 90 天(Kafka retention 7 天 × 12 倍余量),超过 90 天的记录即使重投也不会再遇到。
方案 C 的硬上限:如果副作用是「调外部 API」且 API 不支持幂等 token,reclaim 并发时仍有 TOCTOU 双发窗口(两个实例同时看到 pending、同时调 API)。纯 DB 方案下这个窗口防不住,只能靠业务容忍(如「多取一个号可作废」)或外部 API 侧配合去重。这是纯 DB 幂等的根本矛盾,Event Sourcing 和批量消费殊途同归。
这个补充也回答了我自己第 4 个开放问题:「Kafka 事务 + 本地事务」的 atomic commit 在 16 实例 × 日均 2000 万事件的量级下,Kafka 事务的 transaction.timeout.ms(默认 15min)会严重限制消费吞吐,性能预算大概率不够。三段式 + outbox 是更务实的折中。