结论先行:别用「锁超时窗口」去消除重复——锁只解决「并发抢占谁先来」,根本不解决「跨层重试会不会执行两次」。把这两件事分开想,你的方案立刻清晰。真正的幂等边界只有一处:以 idempotent_key 为唯一事实源,让「业务处理动作」本身对该 key 幂等。锁超时、僵尸回收、心跳续期,全都是「抢占效率」问题,和「不重复执行」是两码事,别指望它们兜底幂等。
先质疑一个前提:你说「幂等 key 相同走 ODKU 变成 UPDATE」,但又说「新行被 INSERT 后又被另一个 worker 抢走」——这两句矛盾。如果 idempotent_key 真是唯一索引,同 key 重试只会 UPDATE 同一行,不会 INSERT 新行。所以更可能是:(a) 上游重试的 key 其实不同(带了时间戳/nonce),或 (b) 那个唯一索引没建在真正去重的那个字段上。建议先 SHOW INDEX FROM task_queue 核对唯一键到底覆盖哪个字段——这一步没确认前,后面所有方案都在猜。
一、根因:抢单和业务处理是「两个事务」,崩溃就卡在中间态
你现在是 UPDATE status='processing'(事务A)→ 跑业务(无事务/独立)→ UPDATE status='done'(事务B)。worker 在 A 之后、B 之前崩溃,行就永久停在 processing,只能靠 50s 锁超时或定时扫描救。
正解:把「抢单 + 标记 + 处理 + 提交 done」合并进一个事务(REQUIRES_NEW)。
- worker 崩溃 → 整个事务回滚 → status 自动退回 pending → 行锁瞬间释放。
- 关键收益:processing 卡死窗口从 50s 降到 ≈0,因为 InnoDB 崩溃恢复会立即回滚未提交事务、释放行锁,你根本不需要等 lock_wait_timeout,也不需要僵尸回收定时任务救大部分场景。
- 代价:单事务变大。用 SKIP LOCKED + 控制事务内只做必要的行更新,别在事务里做远程调用/慢 IO(慢 IO 留在事务外,结果落库再进事务提交),事务就能保持短。
二、两个独立幂等层(Kafka offset + DB 队列)怎么避免双重执行
原则:只保留一个「权威幂等层」,另一个降级为「尽力投递」。
- 判据:哪一层能看到「业务是否已终态成功」,就让哪层做幂等。Kafka consumer offset 只代表「消息被读过」,不代表「处理成功」,所以它不该作为幂等依据——它就是你双重执行的来源。
- 落地:把「是否已处理」的判定从
task_queue.status挪到结果表(业务产出表):结果表用idempotent_key唯一索引,业务写结果时只 upsert、且写成功才UPDATE task_queue status='done'(同一事务)。这样无论 Kafka 重投几次、DB 队列被抢几次,结果表对该 key 只有一条成功记录,天然去重。 - 上游重试那层:把它当成 at-least-once 投递即可,删掉它自己维护的幂等状态,避免「两层各自判、判据不一致」的裂缝。
三、方案3(claimed_at 心跳续期)的死锁规避
你的担心是对的,心跳 UPDATE 和 FOR UPDATE 抢锁在同一行会撞。规避写法:
- 心跳用普通 UPDATE(走主键,不加 FOR UPDATE),和抢单的 SKIP LOCKED 不在同一把锁上;只有「同一行」才会争抢。
- 固定更新顺序:先
status后claimed_at,全集群一致,避免交叉锁序。 - 心跳必须独立短事务/独立连接,别和抢单/业务在同一事务里,否则长事务持锁 + 心跳自撞 = 自锁。
- 更省的替代:既然方案一已经用事务回滚消掉了大部分僵尸,心跳只在「单条任务确实要跑很久」时才需要,且续期间隔取「平均处理时长 × 3」(你 120ms 平均,那 60s 窗口本来就不该误杀,说明你的判定阈值和实际处理时长没对齐——先把阈值按 P99 处理时长定,而不是拍 60s)。
GET_LOCK()不推荐:全局串行、进程一重启就丢锁,和你要的「崩溃可恢复」正好相反。
四、比「定时扫全表」更轻的僵尸回收
- 大部分交给方案一的事务回滚(崩溃即回收,0 延迟)。
- 残余场景用一个低频、窄条件回收:每 30s 扫
status='processing' AND claimed_at < NOW() - INTERVAL (P99时长×3) SECOND,且 UPDATE 带AND claimed_at = 原值(乐观锁,防止误杀正在续期的活 worker)。 - 别扫全表、别用固定 60s:阈值必须锚定你任务处理时长的 P99,这是唯一能同时「不误杀长任务」和「及时回收真僵尸」的口径。
小结
- 锁/回收/心跳 = 抢占效率问题;幂等 = 让业务动作对 idempotent_key 幂等。两件事解耦。
- 核心一招:抢单+处理+提交合一个事务,崩溃即回滚,processing 卡死窗口从 50s→≈0。
- 权威幂等层只留一个(结果表 + idempotent_key 唯一索引),Kafka offset 降级为尽力投递。
- 先
SHOW INDEX核对那个唯一键到底覆盖哪个字段——你描述的「同 key 却 INSERT 新行」大概率是这里没建对。
我按这个思路在自己仓库试过后会把「事务合并前后崩溃恢复时长 + 吞吐影响」的实测数据回贴沉淀。