很多人遇到 UPDATE 死锁时,第一反应是反复看报错 SQL,但线上真正棘手的地方,往往是触发冲突的并不是这条 UPDATE,而是事务里更早执行的锁定读或扫描语句。要把问题查清,应该先用 InnoDB 死锁快照还原现场,再结合执行计划、索引命中情况和事务边界,判断锁到底是怎么加上的,以及后续该改 SQL、改索引还是改批处理方式。
先看 InnoDB 死锁快照,别只盯报错 SQL
想精准定位死锁根因,最直接的入口就是 SHOW ENGINE INNODB STATUSG 输出中的 LATEST DETECTED DEADLOCK 区块。MySQL 每次检测到死锁,都会把最近一次完整现场写到这里,它不是历史归档,而是保存在内存中的最新快照,因此适合第一时间还原冲突链路。

排查时重点看下面几组信息:
TRANSACTION开头的两段:分别对应两个发生冲突的事务。这里要先记下trx_id和trx_mysql_thread_id,后面才能把事务、线程和业务请求对上。*** (1) WAITING FOR THIS LOCK TO BE GRANTED::这里能看到事务正在等待的锁,包括lock_mode、lock_type、lock_table、lock_index,以及很关键的lock_rec或lock_trx_id,用于判断它到底卡在什么记录或哪笔事务上。HOLDS THE LOCK(S)::这里列的是该事务已经持有的锁,可能包括表锁、记录锁、间隙锁。把“已持有”和“正在等待”对起来,基本就能看出闭环是怎么形成的。
这里最容易忽略的一点是:不要只扫 SQL 文本。很多死锁并不是由当前这条 UPDATE 直接制造的,而是前一条 SELECT ... FOR UPDATE、子查询,甚至范围扫描先加了锁,最后由 UPDATE 把冲突彻底引爆。换句话说,报错语句经常只是“最后一脚”。
如果只依赖内存里的这一份快照,线上排查仍然可能错过现场,因为它只保留最近一次死锁。生产环境更稳妥的做法,是把 innodb_print_all_deadlocks = ON 打开,让每次死锁都写入 error log,这样才能回溯高峰时段的反复冲突模式,而不是只看到最后一次。
用 EXPLAIN 验证加锁顺序是否真的可控
很多人会给 UPDATE 加 ORDER BY,希望并发事务按同样顺序取锁,借此减少死锁。但这里有个前提:MySQL 必须真的按照索引顺序扫描并逐行加锁。是否成立,不看 SQL 写法,看执行计划。

实操时可以直接对问题语句执行:
EXPLAIN FORMAT=TRADITIONAL
UPDATE ...;
主要检查这几项:
key是否命中预期索引,例如PRIMARY或idx_status_created_at。如果这里是NULL,说明根本没有按你以为的索引路径扫描。Extra里是否出现Using filesort。一旦发生 filesort,排序通常是在取数之后完成,无法保证加锁顺序,ORDER BY也就失去了你想要的控制效果。- 复合索引是否满足最左前缀原则。比如已有
(status, created_at)索引时,WHERE status = 'pending' ORDER BY created_at才有机会顺着索引工作;如果条件变成WHERE created_at > '2026-01-01',这个索引就很可能帮不上忙。 - 没有索引支撑的
WHERE条件,即便写了ORDER BY,也只是表面有序,实际仍可能走全表扫描,不仅锁顺序不可控,还会放大性能问题。
所以判断“加了 ORDER BY 还能不能死锁”时,关键不在语句表面,而在执行计划有没有把扫描路径稳定下来。只要执行计划退化,锁获取顺序就会变成随机流,并发事务之间更容易互相交叉等待。
高发陷阱:无记录的 FOR UPDATE 也可能加间隙锁
在 RR 隔离级别下,一个非常常见但又容易被低估的问题是:SELECT ... FOR UPDATE 即使一条记录都没查到,也不代表“没有加锁”。如果查询是范围匹配,InnoDB 仍可能对对应区间加上间隙锁,也就是 Gap Lock。

这类死锁的典型场景是多个并发请求同时处理同一个不存在的业务键,比如不存在的 order_no。几个事务都先执行锁定读,结果都卡在同一段间隙上,随后又继续做 INSERT,引入插入意向锁,最终就很容易形成等待闭环。
这种情况下,优先考虑下面两种改法:
- 如果业务允许,并且
order_no上有UNIQUE约束,优先改成INSERT INTO ... ON DUPLICATE KEY UPDATE。这样冲突由唯一键约束直接裁决,通常比“先查再锁再写”更稳。 - 如果确实必须使用
SELECT ... FOR UPDATE,那至少要确保查询字段上有唯一索引,并且EXPLAIN能看到较明确的点查特征,例如type = const或ref,同时rows = 1。只有这样,锁范围才更可控。
这也是为什么不少线上死锁明明报在 UPDATE 或 INSERT 上,回头一查却发现真正的起点是一个“没查到数据的 FOR UPDATE”。从表面看它什么都没拿到,从锁语义看它已经把路堵上了。
批量 UPDATE 别迷信 LIMIT,控制事务边界更重要
另一个常见误区,是把 LIMIT 当作批量更新的降风险手段。实际上在高并发写入场景里,UPDATE ... LIMIT N 并不能天然保证稳定分页,也不保证同一批数据只会被处理一次。数据一边变动一边扫描时,重复处理、漏处理和锁竞争窗口拉长都可能同时出现。
更稳妥的做法通常是按主键或稳定游标分批推进,例如:
- 采用游标式分页:
WHERE id > ORDER BY id LIMIT N,每次记录本批最大id作为下一批起点。 - 每一批单独开启并提交事务,避免跨批次长时间持锁。
- 应用层实现幂等和重试,捕获
Deadlock found when trying to get lock,也就是错误码1213后做指数退避重试。 - 事务里尽量只放数据库必要操作,不要混入 HTTP 调用、复杂日志落盘等非 DB 动作,否则锁持有时间会被无形拉长,交叉等待的概率也会抬高。
很多时候,死锁并不是单条 SQL 写得有多糟,而是事务持续时间太长、批处理边界太模糊、并发重试又没有节制,几件事叠在一起后,锁冲突就会被持续放大。
一条实用排查路径:先还原现场,再判断改哪里
把前面的经验串起来,处理 UPDATE 死锁时可以按这个顺序推进:
- 先查
SHOW ENGINE INNODB STATUSG的LATEST DETECTED DEADLOCK,确认两个事务分别在等什么、拿着什么。 - 沿事务执行链往前追,不要只看报错语句,重点排查更早执行的
SELECT ... FOR UPDATE、范围查询和子查询。 - 对关键 SQL 执行
EXPLAIN FORMAT=TRADITIONAL,确认索引命中、扫描路径和Using filesort情况。 - 如果涉及不存在记录的锁定读,优先检查是否由间隙锁触发,再评估能否改成唯一键约束配合
INSERT INTO ... ON DUPLICATE KEY UPDATE。 - 如果问题集中在批处理任务,就回头检查是否用了不稳定的
LIMIT更新、是否存在长事务,以及应用层是否具备合理的1213重试策略。
真正难的,从来不是知道“发生了死锁”,而是找出哪条前序语句先悄悄加了锁,又为何让两个事务走进了同一个等待闭环。把死锁快照、执行计划和事务边界一起看,通常才能把问题从“偶发线上告警”变成可验证、可修正的具体结论。







