MySQL死锁排查与预防:从InnoDB锁模型到最小复现案例

📅 2026/8/27 11:10:24
MySQL死锁排查与预防:从InnoDB锁模型到最小复现案例
死锁在 MySQL 中一点也不“高端”它几乎每天都在生产环境里发生。尤其是当系统刚上线时运维和开发最常遇到的一类问题就是两条单独执行都很快、语法上完全正常的 SQL一旦并发执行突然就互相卡住接着应用层报出Deadlock found when trying to get lock。这篇文章从 InnoDB 的锁模型讲起用一个可复现的最小案例还原两条正常 update 互相卡死的全过程然后带你读死锁日志最后给出一套在实际项目中可落地的排查与预防方案。主题不算难但涉及事务、索引、锁、隔离级别、死锁日志等多个点。只要按顺序跟着走就能把一个看起来像玄学的问题变成可以推理、可以试验、可以解决的技术问题。1. 先理解 InnoDB 的锁死锁的四个必要条件才能落地1.1 从“卡死”这个现象开始用户看到的“卡死”在数据库内核里其实是锁等待。事务 A 正在修改某一行事务 B 也想修改同一行或相邻范围B 就必须停下来等待 A 提交或回滚。如果只是单方向等待系统不会死锁最多就是一条 SQL 慢一点。真正危险的是循环等待事务 A 持有事务 B 需要的锁事务 B 又持有事务 A 需要的锁两边谁都不肯先放手。MySQL 的 InnoDB 引擎会自动检测这种循环等待。一旦检测到会回滚其中一个事务让另一个事务继续执行。所以“死锁”并不是数据库卡住不动而是其中一个事务被数据库强制回滚应用层收到一个明确的错误码。1.2 行锁、间隙锁和 Next-Key LockInnoDB 的锁是加在索引上的不是加在“行记录”上。这里有两个刚入门的人最容易忽略的事实普通SELECT不加锁MVCC 通过快照读来保证隔离。UPDATE、DELETE、SELECT ... FOR UPDATE加锁加的是排他锁X 锁。在默认隔离级别REPEATABLE READ下范围查询还可能会加间隙锁Gap Lock和 Next-Key Lock。把锁类型整理成一张速查表后续分析死锁日志会反复用到锁类型锁的作用范围典型触发 SQL是否兼容共享锁 S当前记录可以被多个事务同时读SELECT ... LOCK IN SHARE MODES 与 S 兼容S 与 X 互斥排他锁 X当前记录只能被本事务写UPDATE、DELETE、SELECT ... FOR UPDATEX 与任何锁都互斥记录锁 Record Lock锁定某条具体索引记录主键或唯一索引等值更新只锁单条记录间隙锁 Gap Lock锁定索引记录之间的空隙防止其他事务插入RR 隔离级别下的范围查询不同事务的 Gap Lock 互相兼容Next-Key Lock记录锁加前方间隙锁RR 隔离级别下的范围查询与插入意向锁冲突插入意向锁 Insert Intention Lock表示事务准备插入某个间隙INSERT与 Gap Lock 和 Next-Key Lock 冲突最容易被误解的是 Gap Lock。很多人以为UPDATE ... WHERE id 3只锁id3这一行实际上在REPEATABLE READ下如果查询条件命中一个区间或者二级索引不唯一InnoDB 会锁住记录本身以及它前面的间隙防止其他事务在同一个间隙插入新数据。间隙锁的加入既保证了可重复读也成了死锁最常见的来源之一。1.3 死锁必须同时满足四个条件教科书里反复出现的“死锁四条件”在数据库场景下同样成立互斥同一资源同一时刻只能被一个事务以排他方式占有。持有并等待事务持有一个锁又等待另一个锁。不可剥夺已经持有的锁不能被其他事务强行抢走只能由持有事务自己释放。循环等待事务之间形成了一条环形等待链。在 MySQL 里前三个条件基本无法消除因为数据库的行锁天然就是互斥的。开发者的切入点只能放在打破“循环等待”上。1.4 为什么 InnoDB 会主动回滚一个事务InnoDB 默认开启死锁检测相关参数是innodb_deadlock_detect默认值为ON。每次事务请求锁失败进入等待时InnoDB 都会检查是否产生了循环等待。一旦发现它会选择一个“回滚代价更小”的事务进行回滚把锁释放出来另一个事务才能继续。被回滚的事务会收到类似下面的错误ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction这里有个容易混淆的概念1213是死锁1205是锁等待超时。1205表示某个事务等待另一个事务释放锁超过了innodb_lock_wait_timeout默认的 50 秒但没有形成循环等待ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction理解清楚这两个错误码排查时就能少走很多弯路。2. 最小复现案例两条正常 update 为什么互相卡住2.1 建表和初始数据一个最简单的版本不需要间隙锁只需要两个事务按相反顺序更新两行数据。下面这张表模拟账户余额CREATE TABLE account ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL, balance int NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;插入 5 条数据INSERT INTO account (id, user_id, balance) VALUES (1, 101, 500), (2, 102, 500), (3, 103, 500), (4, 104, 500), (5, 105, 500);这里注意一个关键点表必须使用 InnoDB并且要有主键索引。如果使用 MyISAM锁粒度是表锁不会出现行级死锁本文讨论的范围也不适用。2.2 两个事务的执行顺序现在打开两个 MySQL 会话分别执行以下事务。事务 ABEGIN; UPDATE account SET balance balance - 100 WHERE id 1; -- 模拟业务处理这里先不提交 -- 然后再更新另一行 UPDATE account SET balance balance - 100 WHERE id 2; COMMIT;事务 BBEGIN; UPDATE account SET balance balance - 100 WHERE id 2; -- 模拟业务处理 UPDATE account SET balance balance - 100 WHERE id 1; COMMIT;如果这两个事务串行执行任何一组都不会死锁因为前一个事务在极短时间内就提交并释放了锁。如果并发执行并且时间点踩准流程会变成事务 A 执行第一条 update拿到id 1这一行的排他锁。事务 B 执行第一条 update拿到id 2这一行的排他锁。事务 A 执行第二条 update想要id 2的锁发现被事务 B 持有于是 A 进入等待。事务 B 执行第二条 update想要id 1的锁发现被事务 A 持有于是 B 也进入等待。InnoDB 死锁检测介入回滚其中一个事务另一个事务继续执行。用锁矩阵看会更清楚时刻事务 A 持有的锁事务 B 持有的锁事件t1id1 的 X 锁无A 更新 id1t2id1 的 X 锁id2 的 X 锁B 更新 id2t3id1 的 X 锁id2 的 X 锁A 等待 id2t4id1 的 X 锁id2 的 X 锁B 等待 id1形成环2.3 为什么每条 SQL 单独执行都正常把事务 A 的两条 SQL 单独抽出来任何一条都很快UPDATE account SET balance balance - 100 WHERE id 1; UPDATE account SET balance balance - 100 WHERE id 2;问题不在 SQL 本身而在 SQL 的“执行顺序”。两个事务以不同顺序访问同一组资源时就打破了“全局统一访问顺序”的约定。这也是排查死锁时最核心的思路不要盯着一条 SQL 找问题要把事务里所有访问过的表、索引、行、间隙全部画出来。2.4 案例延伸没有索引的 update 更危险把上面的案例改一下如果更新条件不是主键id而是一个没有索引的字段UPDATE account SET balance balance - 100 WHERE user_name 张三;当user_name没有索引时InnoDB 无法通过索引快速定位行只能全表扫描。扫描过程中它会对扫描到的每一行加锁。在高并发下这种 update 会锁住大量记录甚至让其他事务的所有写操作都排长队。更严重的情况是两条无索引 update 条件不同但都扫描到了同一批记录。两个事务各自持有部分记录的锁又等待对方持有的其他记录同样会触发死锁。所以生产环境里UPDATE和DELETE的 WHERE 条件必须能命中索引这不是性能优化建议而是锁安全的基本要求。3. 死锁发生时从哪里找到现场3.1 客户端看到的错误信息被回滚的事务会收到类似这样的错误ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction对应到 Java 的 JDBC 异常通常是Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction看到1213或40001基本可以确定是死锁。3.2 开启死锁日志和 InnoDB 状态输出默认情况下只有最近一次死锁会保存在 InnoDB 的内存里可以通过命令查看SHOW ENGINE INNODB STATUS\G输出结果里重点看LATEST DETECTED DEADLOCK段落。不过SHOW ENGINE INNODB STATUS只能看到最近一次死锁。如果死锁频繁发生建议打开参数让每次死锁都记录到 MySQL 错误日志SET GLOBAL innodb_print_all_deadlocks ON;这个参数修改后立即生效不需要重启。生产环境建议永久写入配置文件[mysqld] innodb_print_all_deadlocks ON innodb_deadlock_detect ON注意官方默认innodb_deadlock_detect本来就是ON日常不需要改。只有在压测中发现死锁检测本身成为瓶颈时才需要考虑关闭但关闭后只能依靠 50 秒超时兜底风险很高不建议普通业务尝试。3.3 LATEST DETECTED DEADLOCK 日志逐段解读SHOW ENGINE INNODB STATUS的输出很长正确阅读顺序是先定位到LATEST DETECTED DEADLOCK然后看下面两个事务块。下面的日志是简化示意字段会比真实输出少但结构一致LATEST DETECTED DEADLOCK ------------------------ 2025-01-20 14:32:10 0x7f1a2c0b1700 *** (1) TRANSACTION: TRANSACTION 12345, ACTIVE 5 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 8, OS thread handle 12345, query id 100 localhost root updating UPDATE account SET balance balance - 100 WHERE id 2 *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 4 n bits 80 index PRIMARY of table test.account trx id 12345 lock_mode X locks rec but not gap Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 4 n bits 80 index PRIMARY of table test.account trx id 12345 lock_mode X locks rec but not gap waiting Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** (2) TRANSACTION: TRANSACTION 12346, ACTIVE 5 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 9, OS thread handle 12346, query id 101 localhost root updating UPDATE account SET balance balance - 100 WHERE id 1 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 4 n bits 80 index PRIMARY of table test.account trx id 12346 lock_mode X locks rec but not gap Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 4 n bits 80 index PRIMARY of table test.account trx id 12346 lock_mode X locks rec but not gap waiting Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** WE ROLL BACK TRANSACTION (2)日志里关键信息是TRANSACTION (1)和TRANSACTION (2)两个事务的编号。HOLDS THE LOCK(S)该事务当前持有的锁。WAITING FOR THIS LOCK TO BE GRANTED该事务等待的锁。WE ROLL BACK TRANSACTION (2)InnoDB 最终回滚了事务 2。读日志的时候把两个事务“持有的锁”和“等待的锁”放在一张表里死锁环基本一目了然。3.4 实时查看锁等待死锁日志只能看到“已经发生”的死锁。如果系统正在持续锁等待可以通过performance_schema实时观察。MySQL 8.0 推荐查询SELECT trx_id, trx_state, trx_query FROM information_schema.INNODB_TRX\G SELECT ENGINE_TRANSACTION_ID, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA FROM performance_schema.data_locks\GMySQL 5.7 及早期版本可以查询SELECT * FROM information_schema.INNODB_LOCKS\G SELECT * FROM information_schema.INNODB_LOCK_WAITS\G注意版本差异8.0 中INNODB_LOCKS已经被移除不要依赖旧查询。锁等待现场的排查思路是先找到所有未提交事务再查每个事务持有哪些锁、正在等哪些锁最后把“等待与被等待”的关系连成环。4. 常见死锁场景与排查路径4.1 场景一不同顺序更新多行这是最简单也最常见的死锁对应的就是上文的最小复现案例。典型特征两个事务都包含两个以上的更新操作。更新的是同一组行。访问顺序不同。每行单独执行时间都很短。并发量上去后死锁概率明显升高。排查时先看两个事务的 SQL 列表如果出现“同样两张表顺序相反”基本可以确定是这类问题。事务 A事务 BUPDATE t SET ... WHERE id1UPDATE t SET ... WHERE id2UPDATE t SET ... WHERE id2UPDATE t SET ... WHERE id1解决办法是统一排序所有事务在更新多行之前先对 id 排序保证每个事务都按 1、2 的顺序访问。方法说明应用层排序在传参前对 id 列表sort数据库层排序先SELECT ... FOR UPDATE按排序顺序锁定要操作的记录业务层拆分尽量不要在一个事务里批量更新多条记录4.2 场景二范围更新触发间隙锁在REPEATABLE READ隔离级别下范围更新会锁住范围前后不存在的记录也就是间隙锁。比如有两张表或对一个范围做更新UPDATE orders SET status 1 WHERE amount 100 AND amount 500;事务 A 更新amount在 100 到 300 的订单事务 B 更新amount在 300 到 600 的订单。两边可能同时持有部分记录锁又在请求对方正在持有的间隙锁最终形成死锁。间隙锁死锁的一个典型特点是死锁日志里能看到lock_mode X locks gap before rec或者lock_mode X locks gap之类的关键字。这类死锁的治理思路要分层如果业务允许把隔离级别改为READ COMMITTEDInnoDB 在该隔离级别下不会使用 Next-Key Lock。尽量把范围更新改成精确到主键的等值更新。不要在事务里执行大范围UPDATE或DELETE。如果必须批量处理建议拆成小批次提交避免长时间持有一大段间隙锁。4.3 场景三唯一键冲突或插入意向锁等待插入场景的死锁更容易被忽视。它的底层机制是事务 A 插入一条记录因为间隙被事务 B 的 Gap Lock 锁住A 进入等待。事务 B 又尝试插入一条记录发现需要等待事务 A 释放的插入意向锁或其他锁。两个事务互相等待死锁。现象通常是两个事务都在执行INSERT报错可能出现在某个INSERT语句上但死锁日志里能看到另外一方持有的是 Gap Lock。典型触发条件并发向同一范围插入数据。使用了唯一索引插入时发生唯一键冲突冲突后另一个事务继续等待。INSERT ... ON DUPLICATE KEY UPDATE在更新阶段又去获取其他锁。检查SHOW ENGINE INNODB STATUS时如果看到lock_mode X inserts intention waiting和lock_mode X locks gap before rec基本可以判断是间隙锁与插入意向锁冲突。4.4 排查路径和工具清单现在把排查路径固定下来。遇到死锁问题按下面的顺序走先确认错误码1213是死锁1205是锁等待超时。打开死锁日志SET GLOBAL innodb_print_all_deadlocks ON;执行SHOW ENGINE INNODB STATUS\G截取LATEST DETECTED DEADLOCK段。从日志中提取两个事务的 SQL、持有的锁、等待的锁。画出循环等待关系图确认环的组成。如果死锁仍在发生用performance_schema.data_locks实时观察事务当前锁状态。回到应用层找到对应事务的调用链确认 SQL 执行顺序是否固定。下面的表格可以作为排查工作的速查清单排查项命令或位置要确认的信息错误码应用日志1213 还是 1205最近死锁SHOW ENGINE INNODB STATUS两个事务的 SQL 和锁等待关系所有死锁记录MySQL error log开启innodb_print_all_deadlocks当前未提交事务information_schema.INNODB_TRX事务状态、执行时间、SQL当前锁MySQL 8.0 用performance_schema.data_locks锁类型、锁模式、锁住的数据锁等待关系MySQL 8.0 用performance_schema.data_lock_waits谁在等谁5. 如何避免和修复死锁5.1 应用层统一更新顺序最小复现案例已经证明了“更新顺序不一致”是死锁最常见的原因。统一顺序是最简单也最有效的办法。假设转账业务涉及两个账户不要这样写Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { accountMapper.updateBalance(fromId, amount.negate()); accountMapper.updateBalance(toId, amount); }两个并发转账如果方向相反就会出现 A 等 B、B 等 A 的循环等待。改成先排序再更新Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { ListLong ids Arrays.asList(fromId, toId); Collections.sort(ids); for (Long id : ids) { if (id.equals(fromId)) { accountMapper.updateBalance(id, amount.negate()); } else { accountMapper.updateBalance(id, amount); } } }排序这个动作看似简单却能保证不同事务访问同一组资源时始终按照相同方向加锁从源头上切断循环等待。5.2 事务要短锁要精确事务越长持锁时间越长与其他事务发生冲突的概率越大。常见的长事务来源在事务里调用了外部 HTTP 接口。在事务里执行大批量查询再逐条更新。事务开始后等待用户输入或人工确认。这些场景都不是数据库本身的问题而是业务设计问题。事务里尽可能只保留数据库操作外部调用放到事务外。单条 SQL 尽量通过索引精确定位要更新的行不要用范围过大的条件。5.3 索引设计决定锁范围死锁日志里经常会出现index PRIMARY或者某个二级索引名。InnoDB 是通过索引定位记录的索引选得不好锁的范围就会扩大。无索引或索引区分度低时一条 update 可能锁住大量记录甚至整表扫描加锁。最典型的例子是UPDATE account SET balance balance - 100 WHERE status ACTIVE;如果status区分度很低优化器可能选择全表扫描于是所有ACTIVE用户所在的行都会被锁住。两个类似的事务并发时死锁概率会急剧上升。合理的索引策略是让WHERE条件能精确命中唯一记录或至少命中一个很小的索引范围。这里不必盲目给所有字段加索引索引过多还会影响写入性能具体字段要根据业务查询条件评估。5.4 隔离级别调整的取舍REPEATABLE READ是 MySQL 默认隔离级别也是死锁高发的常用场景。它的主要特点是具备可重复读能力同时需要 Next-Key Lock 来防止幻读。如果业务允许把隔离级别调整为READ COMMITTEDInnoDB 会放弃 Next-Key Lock只保留记录锁。这样很多由间隙锁引发的死锁会直接消失。适合调整为READ COMMITTED的业务特征不需要在同一事务里多次读取同一范围的数据。对幻读不敏感。能接受某事务提交后另一个事务后续查询读到最新数据。调整前要评估两项主从复制建议把 binlog 格式改为ROW避免基于语句复制出现数据不一致。业务代码如果有地方依赖 RR 的可重复读语义需要先改造。官方默认隔离级别是 RR调整是可行的但需要经过充分的业务评审不能为了“减少死锁”而无脑修改。5.5 死锁发生后的重试策略死锁导致的事务回滚是数据库层面的正常保护机制。应用层不能只把异常打印到日志就结束要主动重试。一个可落地的重试逻辑包括捕获DeadlockLoserDataAccessException或 JDBC 错误码1213。等待一个随机毫秒数避免所有重试请求再次同时撞上。记录重试次数超过上限后人工介入。重试时重新执行整个事务不要只重试最后一条 SQL。伪代码思路max_retry 3 for i in range(max_retry): try: do_transaction() break except DeadlockError: if i max_retry - 1: raise time.sleep(random.uniform(0.05, 0.2))重试不是万能药但能把由死锁引起的偶发失败对用户的影响降到最低。6. 生产环境实践清单6.1 发布前检查清单新功能上线前按下面清单过一遍能减少大部分死锁问题检查项说明UPDATE、DELETE是否通过索引定位行无索引条件必须补充索引事务内是否有多条更新语句如果有确认所有事务访问顺序一致事务内是否有外部调用有则拆出事务或改为异步批量更新范围是否太大大范围更新拆成小批次是否使用INSERT ... ON DUPLICATE KEY UPDATE评估并发冲突场景是否了解当前隔离级别明确使用 RR 还是 RC并确认 binlog 格式是否开启死锁日志建议生产环境设置innodb_print_all_deadlocksON6.2 监控指标和告警死锁日志本身不会主动发送到监控平台需要靠数据库错误日志和应用日志配合。推荐监控以下指标错误日志中关键字Deadlock found的出现次数。information_schema.INNODB_TRX中长时间未提交的事务数。SHOW ENGINE INNODB STATUS中LATEST DETECTED DEADLOCK的时间戳。performance_schema中的锁等待事件数量。如果死锁出现频率从“几天一次”变成“每小时几十次”说明有新的并发路径改变了锁顺序需要立刻查看最近上线的业务。6.3 需要留下的核心结论死锁不是 MySQL 的 bug也不是 SQL 写错了而是多个事务对锁资源的竞争顺序形成了环。真正要解决的是“锁顺序”和“锁范围”。本文最重要的一个判断是想避免死锁不要盯着单条 SQL 看要看整个事务对锁资源的访问路径。下一步值得做的练习是在本地环境复现最小案例跑出1213错误。执行SHOW ENGINE INNODB STATUS读一遍死锁日志。给业务里的多行更新加排序逻辑再看并发下是否还出现死锁。把第一步到第三步完整走一遍比看十篇死锁原理文章都管用。数据库锁相关的知识只有亲手抓到一次死锁现场才算真正入门。