MySQL死锁全解析:原理、排查与预防实战
1. 死锁到底是什么以及它为什么可怕1.1 从一次报错开始认识死锁MySQL死锁这个话题只要你在业务系统里跟数据库打过交道多多少少都会碰上。我第一次遇到死锁时的场景至今还记得一个库存扣减的接口在高峰期突然大量报错错误信息是Deadlock found when trying to get lock; try restarting transaction错误码 1213。当时我第一反应是“库存被扣超了”赶紧去看数据结果数据没问题但接口的失败率已经飙上去了。这就是死锁最让人头疼的地方它不是数据错误不是语法错误而是两个事务在竞争锁资源的时候“僵住”了。数据库为了不让整个系统挂掉会强制回滚其中一个事务于是你的业务逻辑里就凭空多了一个异常。它只在特定并发时机下出现可能压测的时候一次都不发生一上线就在高峰期疯狂出现特别难复现。这篇文章我想从原理到排查再到预防完整梳理一遍 MySQL主要是 InnoDB 引擎死锁的前因后果。适合被线上死锁困扰过的后端开发也适合准备 MySQL 面试、想系统理解“锁”和“事务”关系的同学。1.2 死锁产生的四个必要条件死锁的官方定义很简单两个或两个以上的事务各自持有一个锁同时又在等待对方持有的锁形成了一个“循环等待”的僵局。操作系统教材里讲死锁有四个必要条件互斥、持有并等待、不可剥夺、循环等待。数据库场景下这四个条件依然成立只是“资源”换成了行锁、间隙锁这些数据库资源。互斥同一时刻一条记录只能被一个事务加上排他锁。持有并等待事务 A 持有记录 1 的锁同时还想获取记录 2 的锁但记录 2 被事务 B 拿着。不可剥夺事务 B 的锁不能被强行抢走只能等 B 自己提交或回滚。循环等待A 等 BB 又在等 A谁也动不了。我们常说锁是有“顺序”的其实就是打破第四条如果所有事务都按同一个顺序去加锁就不会形成环。但实际业务里SQL 写法千奇百怪索引选择不一样、事务大小不一样、查询条件不一样环就悄悄形成了。InnoDB 不是眼睁睁看着死锁发生不管它有死锁检测机制。它内部维护着一张“等待图”waits-for graph每当一个事务请求锁被阻塞时就检查这张图里有没有环。一旦发现环会挑选一个代价较小的事务通常是 undo 量小的那个回滚释放它的锁让另一个事务继续走。所以你看到的现象是死锁发生时其中一个事务立刻收到 1213 错误另一个事务往往毫无感知正常执行完毕。1.3 死锁和锁等待千万别混为一谈很多刚接触的同学会把死锁和Lock wait timeout exceeded锁等待超时搞混。这两件事完全不一样。锁等待是“还能等”事务 A 在等事务 B 释放锁B 提交了 A 就继续走只是 B 执行太久超过了innodb_lock_wait_timeout的默认 50 秒A 才放弃并报错。而死锁是“等到死也等不到”因为 B 反过来也在等 A双方都不可能主动释放。区别最明显的地方在于触发机制锁等待超时是“被动超时”死锁是 InnoDB 主动检测出来后“立刻回滚”。所以死锁往往一瞬间就发生错误码是 1213而锁等待超时错误码是 1205。排查方向也完全不同锁等待要看“谁占着锁不放”而死锁要看“事务之间的资源环是怎么形成的”。2. 死锁高发场景拆解我实际踩过的几种坑2.1 两个事务按不同顺序更新同一组记录这是最经典、最容易理解的死锁场景。假设一个订单系统里一个事务同时要更新订单表和用户账户表另一个事务也同时更新这两张表但顺序反过来事务 A先更新订单表加锁再更新账户表。事务 B先更新账户表加锁再更新订单表。两个事务同时执行时A 拿到了订单表的锁B 拿到了账户表的锁然后 A 等账户表B 等订单表互相等死锁就出现了。我举个具体 SQL 例子有一张商品库存表product_stock两个事务都做“扣减库存”操作-- 事务 T1 START TRANSACTION; UPDATE product_stock SET stock stock - 10 WHERE id 1; UPDATE product_stock SET stock stock - 10 WHERE id 2; COMMIT;-- 事务 T2 START TRANSACTION; UPDATE product_stock SET stock stock - 10 WHERE id 2; UPDATE product_stock SET stock stock - 10 WHERE id 1; COMMIT;只要 T1 和 T2 几乎同时执行T1 锁住 id1T2 锁住 id2然后两条事务再各自去拿对方手里的锁死锁立刻触发。这类问题在代码里尤其隐蔽因为很多时候两个事务并不在同一个方法里而是由不同的接口、不同的调用链拼出来的。2.2 间隙锁与插入意向锁的“堵车”这是比上面的场景更难发现的一类。间隙锁是 InnoDB 在可重复读隔离级别下为了解决幻读问题引入的机制。它锁的不是某一行而是“一个范围”这个范围内不允许其他事务插入数据。假设商品表product里有 id 为 10、20、30 的三条记录此时事务 A 执行START TRANSACTION; SELECT * FROM product WHERE id BETWEEN 15 AND 25 FOR UPDATE;因为 id15 到 25 之间没有记录InnoDB 会对这个“空隙”加间隙锁也就是不允许其他事务在这个范围内插入新行。此时事务 B 执行START TRANSACTION; INSERT INTO product (id, product_name) VALUES (18, 新商品);B 需要拿“插入意向锁”但插入的 id18 正好落在 A 的间隙锁范围内于是 B 被阻塞。光到这里还只是锁等待不是死锁。但如果 B 在此之前已经持有其他锁而 A 又需要去拿 B 持有的那个锁环就形成了。这种场景最常见的业务是“先查范围再插入”比如在后端代码里先SELECT ... FOR UPDATE判断某个区间有没有数据没有就插入。两个并发请求同时做这个操作时很容易互相卡死。2.3 索引路径不一致锁范围失控死锁不一定发生在你“以为”在锁的那几行上。InnoDB 的锁是加在索引记录上的如果两条 SQL 走了不同的索引路径它们锁定的记录范围就可能完全不同即便最终操作的是同一批数据。举个例子。订单表有两个索引主键id以及业务字段order_no上的普通索引。事务 A 执行UPDATE order_info SET status 2 WHERE order_no DD001;这条 SQL 会先通过idx_order_no找到对应的主键值然后对主键索引记录加锁。事务 B 执行另一条 SQLUPDATE order_info SET status 3 WHERE id 1001;如果 B 操作的是同一行的另一个订单两者锁的是同一行按理说只是竞争。问题出在范围操作或隐式类型转换导致索引失效SQL 走了全表扫描把一批不相关的行都锁住了。锁的范围变大和其他事务产生交叉点的概率就指数上涨死锁自然更容易出现。这类情况在排查时最容易绕弯子因为从业务上看“我们修改的是不同订单啊”但实际锁冲突发生在索引扫描路径覆盖的范围内。所以排查死锁时一定要把 SQL 的执行计划EXPLAIN一起拉出来看。2.4 大事务和小事务并发锁持有时间不对称还有一类死锁是由“事务大小严重不对称”引发的。大事务里执行了很多语句锁了一大批记录执行时间长小事务只操作其中一两条记录执行很快。乍看大事务不会主动“等待”小事务但如果小事务恰好操作了大事务已经锁定的记录大事务后续又要更新一条被小事务刚锁过的记录死锁就会在小事务即将提交的那一刻发生。举个例子。大事务 A 分页更新了 100 条记录更新到第 50 条时获了锁小事务 B 更新了第 50 条记录并持有锁随后 A 还要更新第 50 条附近的另一条记录但那条记录又被 B 锁住了。B 虽然快但在它提交前A 的等待已经形成如果 B 此时又因为某种原因回滚或重新执行锁的竞争就更乱了。这类死锁演变成线上事故往往是因为有人在事务里混入了远程调用、循环更新、或者等待外部接口返回这类“慢操作”。锁的持有时间被无限拉长并发稍微高一点死锁和锁等待就会一起爆发。3. 死锁排查教程从拿到日志到定位 SQL 的完整流程3.1 第一步把死锁日志完整留下来排查死锁第一件事是确保你能拿到完整的死锁信息。很多人遇到 1213 报错后直接去网上搜“MySQL 死锁怎么办”但手上没有现场日志纯靠猜效率极低。MySQL 提供了一个非常有用的开关innodb_print_all_deadlocks。默认它是 OFF 的只会在错误日志里打印最新一次死锁信息。把它打开后每一次死锁都会完整记录到错误日志中方便你事后集中分析。SET GLOBAL innodb_print_all_deadlocks ON;这个变量是动态的可以在线开启不需要重启数据库。但要注意线上生产环境错误日志本身可能非常庞大开启后建议配合日志切割策略避免日志文件无限增长。如果你用的是云数据库也可以登录控制台查看错误日志或者开启审计功能来辅助分析。另一个常用的命令是SHOW ENGINE INNODB STATUS\G;这条命令输出里包含LATEST DETECTED DEADLOCK一节会展示最近一次死锁的详细信息。但注意它只能看到“最近一次”而且 MySQL 一旦重启这部分内存中的信息也会丢失。所以要靠它做持续排查还是得结合innodb_print_all_deadlocks一起用。3.2 第二步看懂死锁日志里的关键信息死锁日志看起来很长但真正有用的核心就那几个部分。一段典型的死锁日志长这样------------------------ LATEST DETECTED DEADLOCK ------------------------ 2025-02-18 14:23:11 0x7f9c2c1a8700 *** (1) TRANSACTION: TRANSACTION 5839142, ACTIVE 3 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 1024, OS thread handle 140027393695, query id 5839149 10.0.0.12 app_user updating UPDATE product_stock SET stock stock - 10 WHERE id 2 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 3 n bits 72 index PRIMARY of table test.product_stock trx id 5839142 lock_mode X locks rec but not gap waiting Record lock, heap no 6 PHYSICAL RECORD: n_fields 5; compact format; info bits 32 0: len 4; hex 80000002; asc ;; 1: len 6; hex 0000000059151a; asc Y ;; 2: len 7; hex 6d0000014a2cba; asc m J, ;; 3: len 30; hex 736b752d303030322d2d; asc ... *** (2) TRANSACTION: TRANSACTION 5839143, ACTIVE 2 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 1025, OS thread handle 140027393688, query id 5839150 10.0.0.12 app_user updating UPDATE product_stock SET stock stock - 10 WHERE id 1 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 3 n bits 72 index PRIMARY of table test.product_stock trx id 5839143 lock_mode X locks rec but not gap Record lock, heap no 5 PHYSICAL RECORD: n_fields 5; compact format; info bits 32 0: len 4; hex 80000001; asc ;; 1: len 6; hex 0000000059151c; asc Y ;; 2: len 7; hex 6d0000014a2cca; asc m J, ;; 3: len 30; hex 736b752d303030312d2d; asc ... *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 3 n bits 72 index PRIMARY of table test.product_stock trx id 5839143 lock_mode X locks rec but not gap waiting Record lock, heap no 6 PHYSICAL RECORD: n_fields 5; compact format; info bits 32 0: len 4; hex 80000002; asc ;; 1: len 6; hex 0000000059151d; asc Y ;; 2: len 7; hex 6d0000014a2cbb; asc m J, ;; 3: len 30; hex 736b752d303030322d2d; asc ... *** WE ROLL BACK TRANSACTION (1)关键要抓住几个要素TRANSACTION 5839142和TRANSACTION 5839143是两个死锁参与者。每段末尾的*** (1) WAITING FOR THIS LOCK TO BE GRANTED说明事务 1 在等哪一行锁。*** (2) HOLDS THE LOCK(S)表明事务 2 当前持有哪一行锁。最下面的WE ROLL BACK TRANSACTION (1)说明 InnoDB 牺牲了谁一般来说它会回滚 undo 量较小、开销较小的事务。日志里的hex 80000002是主键值的十六进制表示80000002 换算过来就是主键 id280000001 就是 id1。所以在日志里看到page no 3、n_bits 72这些物理信息不用太纠结重点是从主键值判断锁落在哪一行、哪个索引上。3.3 第三步结合执行计划和代码定位具体 SQL拿到死锁日志还不能直接拍板改哪段代码。原因是一个事务里可能有好多条 SQL日志里只体现“正在等待锁的那条语句”。所以接下来要做两件事一是把事务内的所有 SQL 从代码里扒出来看执行顺序二是用EXPLAIN看每条 SQL 实际走了哪个索引、锁定了哪些范围。比如我在排查一个真实死锁时日志里显示事务 A 等待的是UPDATE product_stock SET stock stock - 10 WHERE id 2但这只是 A 当时的“最后一步”A 在此之前还执行过SELECT ... FROM product_stock WHERE id 1 FOR UPDATE。这行前置操作才是锁冲突的起点。如果只盯着日志里的那条 UPDATE不看代码流程压根找不到根因。还经常遇到一种情况日志里的等待锁涉及辅助索引比如lock_mode X locks rec but not gap出现在某个普通索引上。这时候你还需要继续确认这一行数据对应的主键是哪一条是不是存在两个事务通过不同索引重复锁定了同一行。3.4 实际操作一次完整的死锁模拟分析纸上谈兵没意思我带着你实际操作一遍。先建一张表并插入两条测试数据CREATE TABLE product_stock ( id int NOT NULL, sku varchar(32) NOT NULL, stock int NOT NULL DEFAULT 0, cate_id int DEFAULT NULL, PRIMARY KEY (id), KEY idx_cate_id (cate_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO product_stock (id, sku, stock, cate_id) VALUES (1, SKU001, 100, 10), (2, SKU002, 100, 10);打开两个 MySQL 终端模拟两个事务。终端 1START TRANSACTION; UPDATE product_stock SET stock stock - 5 WHERE id 1; -- 先不提交停在原地终端 2START TRANSACTION; UPDATE product_stock SET stock stock - 5 WHERE id 2; -- 先不提交停在原地回到终端 1继续执行UPDATE product_stock SET stock stock - 5 WHERE id 2;此刻终端 1 会阻塞因为它想拿 id2 的锁但 id2 被终端 2 握着。不要提交回到终端 2继续执行UPDATE product_stock SET stock stock - 5 WHERE id 1;洞察到这一步InnoDB 的死锁检测器会立刻介入其中一个终端马上会收到 1213 错误另一个终端则能正常执行完毕。再去执行SHOW ENGINE INNODB STATUS\G;就能看到和第 3.2 节里类似的死锁日志。整个复现过程非常直观两个事务都在不回滚的前提下申请对方持有的锁死锁必然发生。4. 避免和解决死锁的实战打法4.1 从事务设计层面立规矩死锁的预防首先是“约定”。这个约定不复杂核心一句话在同一个系统里凡是涉及多条记录更新的操作务必按照固定的顺序来访问资源。我以前在团队里定过一条规范所有批量更新操作先对 WHERE 条件涉及的记录按主键排序然后再执行 UPDATE。怎么落地如果你是通过代码循环更新多条记录可以先查出主键列表按主键升序排好后再依次执行更新。这样在并发场景下所有事务都是按同一个顺序去加锁循环等待这个必要条件从根上就断了。另外事务尽可能小。事务的本质是“合并多个操作成一个原子单元”但这个单元的粒度不要无脑做大。不要在事务里做远程调用、等待外部接口、执行大查询。那些处理耗时特别长的逻辑要么拆出事务要么异步化。锁持有时间越短和其他事务发生循环等待的时间窗口就越小。4.2 SQL 层面的优化技巧SQL 写法直接决定了锁的形态。这里有几个非常实用的招第一尽量让加锁的 SQL 走索引。如果更新语句因为没有合适索引而全表扫描InnoDB 会对扫描到的每一行都加锁有时甚至会锁住你根本不想动的那批数据。为高频更新和删除条件建立合适的索引是减少锁冲突最直接的手段。第二合理使用NOWAIT和SKIP LOCKED。在 MySQL 8.0 中SELECT ... FOR UPDATE NOWAIT表示如果拿不到锁不等待立刻报错SELECT ... FOR UPDATE SKIP LOCKED表示跳过已经被其他事务锁住的行。这两个语法尤其适合“任务抢单”“流水号分配”“库存秒杀”这类只需要处理未被占用的记录的场景。比如任务调度系统里多个 worker 同时从任务表捞数据加FOR UPDATE SKIP LOCKED可以分别捞到不同的任务根本不会有锁互等的可能。第三注意隔离级别对间隙锁的影响。默认的可重复读REPEATABLE READ隔离级别下InnoDB 会加间隙锁或 next-key 锁来防幻读如果业务对幻读不敏感可以考虑把隔离级别降到读已提交READ COMMITTED间隙锁的数量会大幅减少死锁概率也随之下降。当然修改隔离级别需要业务方确认不能一拍脑袋就改。4.3 死锁重试机制的正确写法不管怎么预防死锁在极端并发下依然无法做到 100% 避免。因此业务代码里必须处理 1213 这个异常。很多团队的做法是捕获到死锁异常后稍等一下把整个事务重试一次。这个方案没问题但要注意几个细节重试的边界是整个事务而不是出错的某条 SQL。事务里可能已经执行了若干条 UPDATE如果只重试最后一条前面的修改会造成重复更新。重试前要确保事务已经回滚连接状态干净。建议在重试时开启一个全新的事务。要设置最大重试次数比如 3 次避免无限循环。一个 Java 伪代码如下Transactional public boolean deductStock(ListInteger skuIds) { int retryCount 0; int maxRetry 3; while (retryCount maxRetry) { try { // 1. 按 skuIds 升序排序后逐个更新 // 2. 执行扣减库存的 UPDATE 语句 return doDeduct(skuIds); } catch (DeadlockLoserDataAccessException e) { retryCount; if (retryCount maxRetry) { throw e; } // 稍等片刻再重试让其他事务有机会提交 Thread.sleep(20 * retryCount); } } return false; }重试是兜底手段不是代码事故的遮羞布。如果线上死锁频率高到每秒钟都在打重试日志那说明设计上还有问题不要只靠重试硬扛。4.4 监控与运维让死锁“有迹可循”死锁是否高频不能靠用户投诉才知道。我通常会做三件事第一开启innodb_print_all_deadlocksON并配置监控对错误日志中的 “DEADLOCK” 关键字进行告警。一旦出现立刻能收到通知。第二定期拉取SHOW ENGINE INNODB STATUS或者performance_schema里的锁等待数据分析近期的锁等待趋势。第三排查线上的长事务。长事务是锁冲突和死锁的温床通过information_schema.innodb_trx可以看到当前运行时间超过阈值的事务。SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds, trx_mysql_thread_id FROM information_schema.innodb_trx WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) 10;发现超过 10 秒还在跑的事务就要提醒业务方关注。如果事务里卡着外部接口调用那它持有的锁会让一大批后续操作排队这可不是死锁频发那么简单是整个系统吞吐量下降的前兆。5. 常见问题速查与避坑经验5.1 死锁高频场景与应对速查表典型症状底层原因快速定位方法解决方向两个事务互相等待各自持有的行锁多记录操作顺序不一致死锁日志中事务的等待锁和持有锁互为因果统一按主键或固定业务字段排序后再更新插入数据时频繁死锁间隙锁 / next-key 锁与插入意向锁冲突日志中可见lock_mode X locks gap before rec缩小范围查询条件或评估降低隔离级别更新明明不同行却死锁两条 SQL 走了不同索引路径对比两条 SQL 的 EXPLAIN 计划统一索引策略避免隐式转换导致索引失效事务执行很久后偶发死锁大事务持有锁时间过长查询innodb_trx找出长事务拆事务、减少事务内外部调用、分批处理5.2 几个必须避开的误区第一死锁不会导致数据丢失它只是让其中一个事务回滚。很多人一看到死锁就担心数据错乱其实 InnoDB 会把事务原子性保住回滚后数据是干净一致的状态。真正要关注的是那个被回滚的事务给业务带来的失败重试成本。第二不要盲目关闭死锁检测。MySQL 8.0 支持innodb_deadlock_detectOFF在某些极高并发、锁冲突本来就是常态的场景里关闭检测能省下检测开销但代价是自己要通过锁等待超时来兜底。这不是通用的性能优化手段我基本不推荐生产环境直接关。第三innodb_lock_wait_timeout设置得越小不代表死锁越少。这个参数控制的是锁等待超时死锁是即时检测出来的这两个逻辑并不互相替代。把超时时间调小反而可能让正常的锁排队操作频繁失败影响业务稳定性。5.3 我给新手的三条建议如果你刚接触这个领域第一次在线上看到死锁报错别慌。先稳住局面把错误日志和死锁日志完整捞出来这是最值钱的现场证据。然后复现一下构造两个事务按不同顺序执行你会发现死锁的整个过程其实非常“讲道理”。最后才是改代码改之前想清楚是锁顺序没定好、事务太大、还是索引路径有问题。我自己在实际操作中的体会是死锁问题本质上不是一个“数据库 bug”而是并发设计上的一个信号。它告诉你代码里对共享资源的访问顺序失控了。只要理顺了顺序、缩小了锁范围、兜住了重试死锁就能从“阴魂不散的线上事故”降级为“偶发但可控的系统噪声”。最后再分享一个小技巧写 UPDATE 语句时脑内过一遍–如果这段代码被两个线程同时执行它们会以什么顺序接触这些行只要这一关没有明显的“循环感”绝大多数死锁都能在编码阶段提前化解。这个习惯比任何事后排查工具都省心。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →