开放问答
MySQL FOR UPDATE SKIP LOCKED 做任务队列:worker 崩溃后锁超时窗口内如何避免重复处理?
该问题的正文、回答与评论。
提问者 林望舒-931 · 状态 open
MySQL任务队列FOR UPDATE高并发幂等性能调优
背景
我负责一条数据清洗管道:上游 Kafka 把脏数据事件落到 MySQL 的 task_queue 表(单表,约 2000 万行,InnoDB,id 自增主键,status 字段为 pending/processing/done/failed),下游 16 个 Java worker(JDK 17,HikariCP maxSize=5)用 SELECT id FROM task_queue WHERE status='pending' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED 抢任务,抢完 UPDATE status='processing',处理完 UPDATE status='done'。整体 TPS 约 3000,单条任务平均处理 120ms。
现象
最近压测发现一个边界问题:当某个 worker 因 OOM 被 K8s 杀掉时,它持有的行锁要等 innodb_lock_wait_timeout(默认 50s)才释放。在这 50s 窗口内:
- 该行 status 仍是
processing,其他 worker 的 WHERE 条件status='pending'根本看不到它,所以不会重复处理——这一点我理解是对的; - 但真正的问题是:如果 worker 是在
UPDATE status='processing'之后、业务逻辑执行之前崩溃(比如 GC 停顿后 OOM),行就卡在processing状态 50s,且这 50s 内如果业务超时重试机制(上游 Kafka consumer 的max.poll.interval.ms触发 reassign)把同一条消息又投递了一次,就会 INSERT 一条新行(因为幂等 key 是idempotent_key唯一索引,但上游重试时 key 相同,会走 ODKU 变成 UPDATE),此时旧行仍卡在 processing,新行被 INSERT 后又被另一个 worker 抢走——结果同一条业务数据被处理了两次。
已尝试的排查
- 把
innodb_lock_wait_timeout从 50s 降到 5s → 锁释放快了,但压测中正常 worker 的FOR UPDATE偶尔超时,吞吐掉了 12%; - 加了一个"僵尸任务回收"定时任务(每 10s 扫
status='processing' AND updated_at < NOW() - INTERVAL 60 SECOND的行重置为pending)→ 有效,但 60s 的判定窗口在任务本身需要跑 45s 的长逻辑时会误杀; - 考虑把 status 改成
pending → claimed(worker_id, claimed_at) → done,用claimed_at做心跳续期(worker 每 5s UPDATE 一次)→ 感觉改动大,且 16 个 worker × 高频 UPDATE 对这条热行的锁竞争我有点担心。
期望得到的答案
- 有没有比"定时扫僵尸行"更轻量、延迟更低的抢占/回收方案?(比如利用
innodb_rollback_on_timeout、或者GET_LOCK()应用层锁?) - 方案 3(claimed_at 心跳续期)在高并发下,UPDATE 心跳行和 FOR UPDATE 抢锁的行如果撞在一起,会不会出现死锁?有没有已知的规避写法?
- 如果上游重试和 DB 队列是两个独立的幂等层,怎么保证它们之间不出现"双重执行"?有没有实践中的取舍经验?
欢迎有实际踩坑经验的同学分享具体配置和代码片段,谢谢。
已采纳
回答者
回答正文
评论
暂无评论。