MySQL死锁全解析:从锁机制到排查实践

MySQL死锁全解析:从锁机制到排查实践 开发中经常会遇到一个让人很费解的现象SQL 单看都很正常一个 UPDATE 改一行一个 SELECT 带 WHERE为什么并发一上来就出现Deadlock found when trying to get lock; try restarting transaction更让人头疼的是很多死锁并不是写错 SQL 导致的。你检查了 SQL 语法查看了索引甚至把事务逻辑都翻了一遍仍然找不到头绪。这篇文章就从锁机制讲起用一个经典场景和几个进阶场景完整拆解 MySQL 死锁是怎么产生的以及遇到死锁后如何快速定位、如何从设计上规避它。1. 死锁现象看起来完全正常的 SQL 为什么会卡死先看一个最常见的场景。假设有一张账户表account两个事务分别执行两条 UPDATE-- 事务 A START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1; -- 未提交 -- 事务 B START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 2; -- 未提交如果到这里为止两个事务不会互相影响。真正出问题的是接下来的操作-- 事务 A 继续执行 UPDATE account SET balance balance 100 WHERE id 2; -- 事务 B 继续执行 UPDATE account SET balance balance 100 WHERE id 1;事务 A 持有 id1 的行锁想去拿 id2 的行锁事务 B 持有 id2 的行锁想去拿 id1 的行锁。两个事务互相等对方释放锁谁也不会先放手死锁就这么发生了。这里的关键点在于两条 SQL 本身没有问题问题出在并发事务对同一个资源集合的加锁顺序不一致。很多刚接触 MySQL 的开发者会困惑明明两个事务执行的是“正常”的修改语句为什么会产生死锁原因就藏在 InnoDB 的锁机制里。我们先把死锁的理论基础理清楚。2. 死锁的本质与四个必要条件2.1 用一句话解释死锁两个或多个事务在持有资源锁的同时互相等待对方持有的锁并且没有外力介入导致所有事务都无法继续推进这就是死锁。MySQL 的 InnoDB 引擎能自动检测到死锁。一旦检测到它会立刻选择一个“代价较小”的事务进行回滚打破循环等待另一个事务才能继续执行。这也是为什么你看到的是Deadlock found而不是一直卡住不动的原因。2.2 产生死锁的四个必要条件数据库死锁和操作系统里的线程死锁底层逻辑是相通的。产生死锁需要同时满足四个条件必要条件在 MySQL 中的体现互斥一行数据在同一时刻只能被一个事务以 X 锁排他锁占用持有并等待事务持有某些锁同时还在等待其他事务持有的锁不可剥夺事务已获得的锁不能被其他事务强制抢走循环等待事务 A 等事务 B事务 B 又等事务 A形成闭环只要打破其中任意一个条件死锁就不会发生。比如“互斥”和“持有并等待”在数据库里很难完全避免工程师最常用的优化思路就是打破“循环等待”让所有事务都按照相同的顺序访问资源。比如先更新 id1再更新 id2那么两个事务就不会形成环路。2.3 MySQL 死锁和线程死锁的关系如果你熟悉 Java 多线程里的死锁理解数据库死锁会非常快。二者的核心模型一样区别只在于线程死锁竞争的是 CPU 资源、对象锁、Monitor。数据库死锁竞争的是数据行、索引记录、间隙锁。线程死锁通常通过 JVM 工具和线程 dump 分析数据库死锁则通过SHOW ENGINE INNODB STATUS和相关视图分析。排查思路也是一样的找循环等待关系确认每个锁的持有者和等待者。3. InnoDB 锁的基础搞懂锁才能理解死锁在深入复现死锁之前必须先搞清楚 InnoDB 到底有哪些锁以及它们之间怎么兼容。很多死锁案例的答案都在“间隙锁”和“插入意向锁”上。3.1 行锁与记录锁InnoDB 默认使用行级锁行锁分为共享锁S 锁和排他锁X 锁S 锁事务读取一行数据时加共享锁多个事务可以同时持有 S 锁。X 锁事务修改一行数据时加排他锁同一行数据只能有一个事务持有 X 锁。X 锁与任何其他锁都不兼容S 锁与 S 锁兼容。这是最基础的锁兼容关系。如果 UPDATE 条件命中普通索引或主键InnoDB 会对扫描过程中遇到的记录加锁。注意并不是只锁“最终更新的那一行”而是会对扫描过程中访问到的所有满足条件的记录逐条加锁。如果 SQL 没有走索引InnoDB 可能需要扫描大量记录锁的范围会被放大死锁概率也随之上升。3.2 间隙锁与 Next-Key 锁间隙锁Gap Lock是 MySQL 在可重复读REPEATABLE READ隔离级别下默认使用的锁。它锁住的是一个区间而不是某条具体记录。例如一个表里有 id10、20、30 三条记录事务执行UPDATE orders SET status 1 WHERE id BETWEEN 15 AND 25;此时即使 id15 和 id25 的记录不存在InnoDB 也可能锁住 (10,20) 和 (20,30) 这两个区间防止其他事务在区间内插入 id15、id25 这类记录。这就是为了防止幻读。Next-Key Lock 可以理解为“记录锁 间隙锁”的组合锁住的是“记录本身 记录前面的间隙”。在 REPEATABLE READ 隔离级别下InnoDB 默认使用 Next-Key Lock。间隙锁和间隙锁之间是互相兼容的。但间隙锁与插入意向锁之间会冲突这是很多奇怪的死锁案例的根源。3.3 插入意向锁插入意向锁Insert Intention Lock是一种特殊的间隙锁。一个事务在插入数据前会先判断目标插入位置是否被其他事务加了间隙锁。如果被加了它就需要等待同时它会申请一个插入意向锁告诉其他事务“我准备在这个间隙里插入数据”。插入意向锁与间隙锁的冲突是许多范围更新 插入场景死锁的直接原因。我们后面会用完整案例演示。3.4 表级锁与元数据锁除了行锁InnoDB 还存在表锁和元数据锁MDL。表锁用于显式锁定整张表元数据锁用于保护表结构。比如一个事务正在更新表数据时另一个事务执行ALTER TABLE后者就可能等待元数据锁。这类死锁在生产环境中偏少但一旦出现影响范围往往很大因为 ALTER TABLE 会阻塞整个表的读写。3.5 锁的兼容关系为了后面排错方便这里给出一张简化版本的兼容表锁类型S 锁X 锁间隙锁插入意向锁S 锁兼容冲突兼容兼容X 锁冲突冲突兼容冲突间隙锁兼容兼容兼容冲突插入意向锁兼容冲突冲突兼容这张表不需要死记硬背重点是记住两个结论X 锁是“一山不容二虎”。间隙锁最怕别人插数据插入操作最容易和间隙锁“撞车”。4. 案例一两条 UPDATE 互相等行锁这个案例是最经典、最容易理解的行锁死锁。4.1 准备演示数据先创建一张账户表CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(50) NOT NULL, balance DECIMAL(10,2) NOT NULL DEFAULT 0.00 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO account(id, user_name, balance) VALUES (1, 张三, 1000.00), (2, 李四, 1000.00);4.2 两个会话复现死锁打开两个 MySQL 终端分别模拟事务 A 和事务 B。会话 ASTART TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1;会话 BSTART TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 2;此时事务 A 持有 id1 的行锁事务 B 持有 id2 的行锁两者井水不犯河水。然后会话 A 继续执行UPDATE account SET balance balance 100 WHERE id 2;这条 SQL 需要获取 id2 的 X 锁但 id2 已经被事务 B 持有所以会话 A 进入锁等待。再回到会话 B执行UPDATE account SET balance balance 100 WHERE id 1;这条 SQL 需要获取 id1 的 X 锁但 id1 已经被事务 A 持有。此时形成了循环等待。4.3 运行结果其中一端会立刻收到如下错误ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction另一端则正常执行成功事务可以提交。MySQL 会自动选择回滚代价更小的事务作为“牺牲者”通常是持有锁数量更少、undo 日志更少的事务。这个选择不是随机的而是基于代价估算。4.4 案例启示这个案例看起来简单但在真实项目中不少见。最常见的出现场景就是账务系统里多个账户之间的转账操作事务 A扣 A 账户加 B 账户。事务 B扣 B 账户加 A 账户。两个事务对账户的处理顺序完全相反并发量一上来就会死锁。解决办法很简单所有事务都先处理固定 id 更小的账户再处理另一个账户保证加锁顺序一致。5. 案例二范围更新 插入意向锁的间隙锁死锁第二个案例更隐蔽即使你只更新一批数据不涉及“两个方向更新”也可能死锁。5.1 为什么范围更新会引入间隙锁先看数据准备CREATE TABLE orders ( id INT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO orders(id, order_no, status) VALUES (10, NO_10, 0), (20, NO_20, 0), (30, NO_30, 0);两个事务分别执行范围更新并且更新范围不重叠-- 事务 A START TRANSACTION; UPDATE orders SET status 1 WHERE id 20; -- 事务 B START TRANSACTION; UPDATE orders SET status 1 WHERE id 20;在 REPEATABLE READ 隔离级别下事务 A 会锁定 id10 的记录并且可能在区间 (-∞,10) 和 (10,20) 上加间隙锁。事务 B 会锁定 id30 的记录并且可能在区间 (20,30) 和 (30,∞) 上加间隙锁。注意间隙锁之间是兼容的所以这两条 UPDATE 并不会直接互相阻塞。真正出问题的是插入操作。5.2 两个事务同时插入对方间隙事务 A 继续执行INSERT INTO orders(id, order_no, status) VALUES (25, NO_25, 1);id25 落在事务 B 加锁的区间 (20,30) 内。事务 A 需要在这个间隙上获取插入意向锁但事务 B 已经持有该间隙的间隙锁因此事务 A 等待。事务 B 继续执行INSERT INTO orders(id, order_no, status) VALUES (15, NO_15, 1);id15 落在事务 A 加锁的区间 (10,20) 内。事务 B 需要获取该间隙的插入意向锁但事务 A 已经持有间隙锁因此事务 B 等待。于是死锁产生。5.3 案例启示这个案例比第一个更隐蔽因为两条 UPDATE 语句看起来“井水不犯河水”范围也没有直接重叠。真正引发死锁的是后续的 INSERT。在实际业务中这种问题的典型场景是批量更新某个范围内的订单同时又有新订单插入到同一范围内。报表统计任务锁住一段范围业务系统在相同范围内插入新数据。规避思路是尽量避免过大的范围更新如果必须做可以评估在业务低峰期执行并建议应用层加分布式锁或队列化处理避免多个写任务并发进入同一数据区间。6. 案例三唯一键冲突引发的死锁还有一种容易被忽略的死锁来源唯一键冲突。6.1 唯一键为什么会引入锁等待很多人认为唯一键冲突是“立刻报错”实际上 InnoDB 在检测到唯一键重复时会尝试对已存在的唯一记录加锁这就可能产生锁等待。举个简化场景。假设用户表user的email字段有唯一索引CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, email VARCHAR(100) NOT NULL, point INT NOT NULL DEFAULT 0, UNIQUE KEY uk_email(email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO user(id, email, point) VALUES (1, aexample.com, 0);事务 A 先执行START TRANSACTION; INSERT INTO user(email, point) VALUES (bexample.com, 0); -- 插入成功但事务未提交事务 A 持有新记录 bexample.com 的唯一索引锁 UPDATE user SET point point 10 WHERE id 1; -- 这里等待事务 B 释放 id1 的行锁事务 B 先执行START TRANSACTION; UPDATE user SET point point 20 WHERE id 1; -- 持有 id1 的行锁 INSERT INTO user(email, point) VALUES (bexample.com, 0); -- 唯一键冲突需要等待事务 A 释放 bexample.com 上的唯一索引锁两个事务互相等锁死锁产生。这个案例再次说明死锁不一定来自两条 UPDATE 对同一行记录的竞争任何需要获取锁的操作包括唯一键检查、外键约束检查都可能成为死锁的一环。6.2 案例启示生产中遇到唯一键相关死锁常见原因有多个事务同时插入相同唯一键比如同一个手机号、相同订单号。使用INSERT ... ON DUPLICATE KEY UPDATE时并发量过高。业务逻辑里先插入数据再更新其他表记录插入和更新的顺序在两个事务中不一致。如果业务允许可以对唯一键冲突做预检查或者使用INSERT IGNORE、ON DUPLICATE KEY UPDATE等语法时确保处理逻辑幂等并配合重试机制。7. 如何快速定位死锁死锁出现后第一步要做的不是急着改代码而是拿到死锁现场分析两个事务到底持有哪些锁、等待哪些锁。7.1 使用 SHOW ENGINE INNODB STATUSMySQL 提供了内置命令来查看最近一次死锁信息SHOW ENGINE INNODB STATUS\G在输出内容中重点看LATEST DETECTED DEADLOCK这一段。简化输出类似------------------------ LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION: TRANSACTION 12345, ACTIVE 2 sec starting index read LOCK WAIT *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 10 page no 4 n bits 72 index PRIMARY of table test.account *** (2) TRANSACTION: TRANSACTION 12346, ACTIVE 2 sec *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 10 page no 4 n bits 72 index PRIMARY of table test.account *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 10 page no 4 n bits 72 index PRIMARY of table test.account这段信息会明确告诉你两个事务的 ID。每个事务持有的锁。每个事务正在等待的锁。哪张表、哪个索引、哪个行的锁。根据这份信息基本就能画出事务之间的环。7.2 查看当前事务与锁等待如果死锁还没有解除或者想提前发现锁等待可以查询系统表SELECT * FROM information_schema.innodb_trx\G该表能列出当前所有事务包括事务状态、当前执行的 SQL、锁等待时间等。在 MySQL 8.0 中还可以查看锁的具体信息SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS FROM performance_schema.data_locks;配合SELECT * FROM performance_schema.data_lock_waits\G可以更清晰地看到锁等待关系。不同版本的字段名可能略有差异执行前可以先查看表结构。7.3 日志与监控建议生产环境建议打开死锁日志并配合监控系统采集SHOW ENGINE INNODB STATUS输出。常见的做法有定期采集死锁信息到日志系统保留历史记录。对innodb_lock_wait_timeout和死锁次数设置告警。对涉及锁等待频率较高的表重点分析索引使用情况。遇到死锁不要只看表面 SQL要结合执行计划、事务上下文和并发场景一起分析。8. 常见问题与排查思路下面汇总一些开发中高频出现的死锁、锁等待问题供快速查阅。问题现象常见原因解决思路业务报Deadlock found when trying to get lock两个事务形成循环等待查看SHOW ENGINE INNODB STATUS分析事务持有和等待的锁统一加锁顺序报Lock wait timeout exceeded; try restarting transaction一个事务持锁时间过长另一个事务等待超过阈值检查是否有长事务未提交优化事务耗时调大innodb_lock_wait_timeout要谨慎只更新一行数据也会死锁更新条件没有走索引扫描并锁定了多行通过 EXPLAIN 分析索引使用情况为 WHERE 条件添加合适索引范围 UPDATE 后插入新数据频繁死锁间隙锁与插入意向锁冲突避免大范围更新评估隔离级别错峰执行批量任务多个事务插入相同唯一键出现死锁唯一键冲突引发的锁等待幂等设计捕获死锁异常并重试避免并发插入相同唯一键DELETE 大批量数据时死锁一次删除行数太多锁范围大分批删除每次控制行数避免长时间持有大量锁排查顺序建议先看错误码1213死锁InnoDB 已经自动回滚了其中一个事务。1205锁等待超时还没有形成死锁但说明锁竞争已经非常严重。接着查SHOW ENGINE INNODB STATUS确认锁等待关系再结合业务代码画出事务获取锁的时序图。绝大多数问题在这个阶段都能定位清楚。9. 工程实践如何尽量避免和应对死锁死锁无法 100% 消灭但通过合理设计可以大幅降低发生概率并在发生后快速恢复。9.1 统一加锁顺序这是最有效、最直接的方案。多张表或多行数据的更新如果所有业务事务都按照相同的顺序访问资源循环等待就不会出现。比如转账操作先处理 id 较小的账户再处理 id 较大的账户。或者按照固定规则排序后再更新。这类约束最好在代码层面强制约定并通过 Code Review 检查。9.2 保持事务短小事务持有锁的时间越长与其他事务发生冲突的概率越大。可以通过以下方式缩短事务不要在事务里执行远程 RPC、HTTP 调用。不要在事务里做复杂的耗时空闲等待。先做数据校验和准备再开启事务执行必要更新。批量操作拆分成为多个小批次事务。9.3 合理设计索引UPDATE 和 DELETE 的 WHERE 条件一定要能走索引。如果走全表扫描InnoDB 会锁住扫描到的所有记录锁冲突概率大大增加。使用 EXPLAIN 查看执行计划EXPLAIN UPDATE account SET balance balance - 100 WHERE user_name 张三;如果发现typeALL说明全表扫描需要尽快给user_name加索引。要注意MySQL 选择索引是优化器行为索引是否真的被使用必须以执行计划为准。9.4 隔离级别的取舍在 REPEATABLE READ 隔离级别下InnoDB 会使用间隙锁和 Next-Key 锁死锁概率相对更高。如果业务场景可以接受读已提交READ COMMITTED那么就不会使用间隙锁死锁概率会明显下降。比如一些互联网业务数据表对幻读并不敏感切换到 READ COMMITTED 可以在并发和一致性之间取得更好的平衡。修改全局隔离级别SET GLOBAL transaction_isolation READ-COMMITTED;修改当前会话隔离级别SET SESSION transaction_isolation READ-COMMITTED;注意这个决策必须由 DBA 和业务负责人一起评估不能为了规避死锁而破坏了业务对一致性的要求。9.5 捕获死锁异常并重试即使做了很多设计上的优化死锁仍然可能在极端并发下出现。因此应用层必须处理死锁异常。Java 开发中常见的做法是捕获死锁异常并重试核心伪代码如下int retryTimes 3; for (int i 0; i retryTimes; i) { try { // 执行事务逻辑 transactionService.execute(); break; } catch (DeadlockLoserDataAccessException e) { // 记录日志等待短暂时间后重试 if (i retryTimes - 1) { throw e; } Thread.sleep(100L * (i 1)); } }使用 Python 时也可以捕获 MySQL 的 1213 错误码import time import pymysql def execute_with_retry(sql, params, retries3): for i in range(retries): try: conn pymysql.connect( host127.0.0.1, userroot, passwordroot, databasetest ) try: with conn.cursor() as cursor: cursor.execute(sql, params) conn.commit() return except Exception: conn.rollback() raise finally: conn.close() except pymysql.err.OperationalError as e: # 1213 是死锁错误1205 是锁等待超时 if e.args[0] in (1213, 1205) and i retries - 1: time.sleep(0.2 * (i 1)) continue raise重试的关键前提是事务逻辑必须幂等否则重复执行可能产生重复扣款、重复插入等污染数据的问题。9.6 生产环境变更注意事项涉及批量 UPDATE、DELETE 或建索引等操作时务必记住以下原则必须先备份数据或者确保有完善的回滚方案。在测试环境验证 SQL 的执行计划和锁范围。不要在业务高峰期执行大批量数据订正。大批量更新建议分批执行例如每次更新 500 或 1000 条隔一小段时间再继续。涉及生产环境表结构变更必须有 DBA 审核并关注 MDL 锁等待情况。数据库安全无小事任何一条无意识的锁等待都可能在极端情况下拖垮整个业务。10. 小结与进阶方向MySQL 死锁并不神秘它的本质是多个事务在并发竞争锁资源时形成了循环等待。只要理解了 InnoDB 的行锁、间隙锁、插入意向锁以及它们之间的兼容关系再看死锁日志就会变得很轻松。建议你在自己本地环境亲手复现一下前面几个案例两个事务反向更新两行数据观察死锁报错。两个事务范围更新后再插入对方间隙观察间隙锁死锁。模拟唯一键冲突场景观察锁等待的形成。只有亲手跑过一遍对锁机制的理解才会真正内化。遇到死锁时不要急着在应用层“死等”先把SHOW ENGINE INNODB STATUS里的锁图看清楚再决定是调整 SQL、修改索引还是统一事务的加锁顺序。如果这篇文章对你有帮助可以先收藏备用下次遇到Deadlock found when trying to get lock时再对照排查。你在项目中遇到过什么样“奇怪”的 MySQL 死锁欢迎在评论区留言我们来一起拆解。