十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL死锁排查与预防:从原理到实战

MySQL死锁排查与预防:从原理到实战 那天晚上九点四十三分报警群突然开始刷屏。业务方反馈订单支付接口大量报错错误信息清一色是“Deadlock found when trying to get lock; try restarting transaction”。我当时的第一反应不是看代码而是先连上MySQL执行了一条SHOW ENGINE INNODB STATUS果然LATEST DETECTED DEADLOCK 段里躺着一串眼熟的SQL。这已经不是我们第一次被MySQL死锁教育了。做后端开发这些年我越来越确信一件事死锁不是DBA的专属问题而是所有写SQL的人迟早都会撞上的墙。这篇文章就是想把MySQL死锁这件事彻底讲透——包括它是怎么产生的、线上出问题时怎么快速定位、以及更重要的是怎么从根源上把死锁发生概率压到最低。无论你是刚入门的新人还是已经带团队的老手这套排查与预防思路都能直接拿去用。1. 锁与死锁的本质为什么InnoDB会互相卡死1.1 从InnoDB的行锁说起要理解死锁先得搞清楚InnoDB在并发条件下是怎么加锁的。MySQL默认的存储引擎是InnoDB它支持事务和行级锁。行级锁的意思是一个事务在执行UPDATE、DELETE、SELECT ... FOR UPDATE时会锁定它扫描并命中的那些行避免其他事务同时修改这些数据。InnoDB提供的锁主要分成两类共享锁S锁和排他锁X锁。共享锁和共享锁之间是兼容的也就是说多个事务可以同时对同一行加共享锁但共享锁和排他锁、排他锁和排他锁之间是互斥的。用一张表说明就是锁类型共享锁S排他锁X共享锁S兼容冲突排他锁X冲突冲突更复杂的是InnoDB的行锁并不是只锁一行。在可重复读RR隔离级别下MySQL为了实现范围查询的“幻读”防护还会引入间隙锁Gap Lock和临键锁Next-Key Lock。间隙锁锁的不是某一行数据而是索引记录之间的“空隙”它的存在意味着你执行SELECT ... WHERE id BETWEEN 10 AND 20 FOR UPDATE时哪怕表里只有id10和id20两条记录10到20之间的位置也会被锁住别的事务想在这个范围插入新记录就会被阻塞。还有一个非常关键的锁是插入意向锁Insert Intention Lock。当一个事务想往某个间隙里插入记录时它先要获取插入意向锁。如果这个间隙已经被另一个事务的间隙锁锁住插入操作就得等待。死锁的高发场景很多都是插入意向锁和间隙锁相互博弈的结果。这部分内容后面讲场景时会具体展开。1.2 死锁产生的四个必要条件死锁不是MySQL独有的概念但在数据库里它有一个非常明确的定义两个或多个事务互相持有对方需要的锁资源并且都在等待对方释放导致任何一个事务都无法继续推进。经典的死锁四个必要条件在MySQL里同样适用互斥条件同一把锁在同一个时刻只能被一个事务持有InnoDB的行锁天然满足这一点。持有并等待事务A持有一行数据的锁同时又去申请另一行数据的锁而这一行已经被事务B持有。不可剥夺一个事务持有的锁只能由它自己释放或者事务回滚其他事务不能强行抢走。循环等待事务A等待事务B的锁事务B又在等待事务A的锁形成一个环。只要这四条同时成立死锁就一定会发生。理解这几个条件最大的价值在于预防死锁的思路本质上就是破坏其中任何一个条件。后面我讲的索引设计、锁顺序控制、事务瘦身全都是围绕这几个字展开的。2. 四类高频死锁场景还原照着排查你也能复现2.1 经典交叉加锁两个事务并发更新两条不同记录最典型的死锁场景发生在两个事务以不同的顺序更新两条记录的时候。假设有一张账户表account(id, user_id, balance)事务A执行UPDATE account SET balance balance - 100 WHERE id 1; UPDATE account SET balance balance 100 WHERE id 2;事务B执行UPDATE account SET balance balance - 100 WHERE id 2; UPDATE account SET balance balance 100 WHERE id 1;当两个事务并发执行时可能出现这样的时间线事务A先锁住了id1事务B先锁住了id2然后A尝试去锁id2发现被B持有于是A等待B与此同时B尝试去锁id1发现被A持有于是B等待A。两个事务互相等对方释放锁死锁立刻形成。InnoDB的死锁检测机制马上会介入选择回滚undo量较小的事务另一个事务才能继续执行。这种场景在真实业务里太常见了尤其是复制的核心表结构后用错误拼接的ID列表或先查后改逻辑导致的错序更新。排查时只要把两条SQL的执行顺序对比一下基本一眼就能定位。2.2 间隙锁冲突RR隔离级别下的范围操作间隙锁导致的死锁比交叉加锁隐蔽得多因为它可能不涉及任何实际存在的行。比如订单表orders(order_id, shop_id, amount)shop_id上有普通索引两个事务并发执行事务ASELECT * FROM orders WHERE shop_id 100 FOR UPDATE;事务BINSERT INTO orders(shop_id, amount) VALUES (100, 500);在RR隔离级别下事务A的FOR UPDATE不光锁住了shop_id100的现有记录还会锁住这个索引范围内的间隙。事务B的INSERT想把新记录插入到这个间隙必须先获得插入意向锁但此时间隙被A锁着所以B阻塞。如果此时事务A又因为别的原因需要访问B持有的锁资源——比如B先更新了另一张表的数据——两个事务就会形成死锁。还有一种更让人摸不着头脑的情况两个事务都执行范围查询然后都尝试在间隙里insert结果彼此持有的间隙锁相互冲突直接死锁。解决这类问题的方向是要么把隔离级别从RR降到RC读已提交因为RC下MySQL不再需要间隙锁要么在SQL层面保证范围查询和插入操作不会在同一时刻交叉。2.3 唯一键冲突的隐形锁并发插入相同唯一值唯一键冲突触发死锁是我被问得最多也最容易被忽视的场景。建一张用户表users(id, phone)phone字段有唯一索引。两个事务同时插入同一个phoneINSERT INTO users(phone, nickname) VALUES (13800001111, 张三);在真正插入之前InnoDB需要对唯一索引进行重复检查这个过程会给已存在的那条记录加一个共享锁。如果两个事务同时检查到phone13800001111这个值还不存在它们都会尝试插入但实际插入时只有一个能成功另一个会遇到唯一键冲突。冲突之后InnoDB还要对那条记录加共享锁去读取冲突的信息此时另一个事务已经持有了排他锁或共享锁等待链就有可能形成环最终演变成死锁。我之前排查过一个营销系统的问题用户同时点击两次“领取优惠券”按钮前端虽然做了防抖但因为网关层重试机制后端仍然会收到两个几乎同时到达的插入请求。最终数据库里出现了一条死锁记录而业务方一脸无辜觉得“我们根本没有并发导致死锁的代码”。这就是唯一键冲突锁最坑的地方它由数据库内部机制触发不仔细看死锁日志根本发现不了。2.4 大事务与批量操作的顺序不一致大事务里包含多条SQL如果每条SQL访问的索引顺序不一致也会埋下死锁的雷。比如事务A先更新订单表再更新商品表事务B先更新商品表再更新订单表。虽然表面上两个事务操作的是不同字段但锁的顺序反了一旦两个事务并发必然有一方在等待另一方释放锁。批量操作同样要小心。用一个循环批量更新一批ID时如果两次循环的顺序不一样——比如一次按ID升序更新另一次按ID降序更新——死锁的风险会明显上升。我的建议是批量更新操作尽量统一排序方向最好在代码里对ID集合做一次排序再执行花不了多少性能但可以帮你省掉一堆排查死锁的时间。3. 死锁排查实战从日志到监控的一整套手段3.1 抓取死锁现场show engine innodb status排查死锁第一件事永远是看死锁现场记录。MySQL每产生一次死锁就会把死锁相关信息写到InnoDB的状态里通过下面的命令可以查看最近一次死锁的详细信息SHOW ENGINE INNODB STATUS\G输出结果中有一个LATEST DETECTED DEADLOCK段里面会包含死锁发生的时间、涉及的事务ID、事务执行的SQL、持有的锁和等待的锁。我抓到的典型长这样简化后LATEST DETECTED DEADLOCK ------------------------ 2026-01-15 21:43:32 0x7f1a2c0a4700 *** (1) TRANSACTION: TRANSACTION 8923412, ACTIVE 12 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 4 lock struct(s) *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 4 n bits 72 index PRIMARY of table test.orders *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 8 n bits 72 index PRIMARY of table test.orders *** (2) TRANSACTION: TRANSACTION 8923413, ACTIVE 9 sec starting index read LOCK WAIT 2 lock struct(s) *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 8 n bits 72 index PRIMARY of table test.orders *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 4 n bits 72 index PRIMARY of table test.orders *** WE ROLL BACK TRANSACTION (2)注意看HOLDS THE LOCK和WAITING FOR THIS LOCK两段这就是我之前说的循环等待的直接证据事务1持有某一个page上的锁等待另一个page事务2正好反过来。另外还要记下WE ROLL BACK TRANSACTION (2)这一行InnoDB已经帮你做了选择回滚了代价较小的事务。看日志时千万别只看SQL还要看锁对象对应的索引。如果是辅助索引引发的死锁日志里会显示索引名称往往比看主键更能定位问题。SHOW ENGINE INNODB STATUS默认只保留最近一次死锁信息这远不够排查历史问题。SQL上线后偶发死锁到你发现时现场可能已经被覆盖了。所以生产环境一定要把这个参数打开SET GLOBAL innodb_print_all_deadlocks ON;开了之后每次死锁都会直接写入MySQL错误日志error log不再只是覆盖式的最后一条。建议直接写到独立的日志文件方便后续按时间线检索。3.2 实时监控事务与锁等待系统表查询死锁是结果锁等待是前兆。如果能实时看到当前有哪些事务在等锁、谁持有的锁阻塞了谁很多问题可以赶在死锁之前解决。MySQL提供了三张核心系统表information_schema.innodb_trx当前所有正在执行的事务包含事务ID、开始时间、状态、SQL等。information_schema.innodb_lock_waits当前锁等待的关联关系记录谁在等谁的锁。information_schema.innodb_locks当前正在被持有的锁和正在等待的锁。MySQL 5.7开始innodb_locks的表结构有所变化8.0之后锁信息更是迁移到了performance_schema.data_locks和data_lock_waits。为了兼容旧版本我最常用的一个查询是这样SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_rows_modified, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;通过这个查询我能很快看到有没有长时间未提交的事务。一旦某个事务trx_state是RUNNING但开始时间已经过去几十秒甚至几分钟它很可能就是阻塞链的源头。接下来再用innodb_lock_waits去查它到底堵住了谁SELECT waiting_trx_id, blocking_trx_id, waiting_pid, blocking_pid, waiting_query, blocking_query FROM information_schema.innodb_lock_waits;这条SQL能直接给出阻塞者和等待者的PID拿到PID后配合SHOW PROCESSLIST可以进一步杀掉或提醒对应的客户端连接。不过要提醒一句8.0里innodb_lock_waits仍然可用但推荐切换到performance_schema.data_lock_waits数据更完整。3.3 用工具自动化记录死锁pt-deadlock-logger靠人工执行SHOW ENGINE INNODB STATUS肯定不够死锁一旦高频出现最好有自动化工具帮你把每一次死锁都沉淀下来。Percona Toolkit里的pt-deadlock-logger是干这个活的标准武器它能持续监测MySQL的死锁和锁等待信息并把结果输出到文件、表或者syslog。我常用的命令pt-deadlock-logger \ --hostlocalhost \ --usermonitor \ --passwordxxx \ --interval5 \ --dest/var/log/mysql/deadlock.log \ hlocalhost它会每5秒探测一次死锁信息每次发现新死锁就追加到日志文件。加上--dest指定文件后日志会和错误日志分开排查时直接grep文件就行不用每次登录数据库看状态。如果想实现更高阶的告警可以让pt-deadlock-logger把结果写入MySQL表再配一个定时任务扫描新记录CREATE TABLE deadlock_log ( id INT AUTO_INCREMENT PRIMARY KEY, server VARCHAR(64), ts DATETIME, thread_id INT, trx_id INT, victim TINYINT, query TEXT, INDEX idx_ts (ts) );写入表之后配合监控平台可以做到死锁实时告警不再依赖人工盯日志。3.4 结合慢查询日志与监控曲线缩小嫌疑范围死锁日志能告诉你在哪一秒钟发生了什么死锁但很多时候我们需要知道的是“为什么在这个时间段集中爆发”。我的习惯是拿到死锁时间点后再回看三个维度的数据慢查询日志看这个时间点前后有哪些SQL明显变慢尤其是Rows_examined很大的语句往往是它持有了大量行锁。活跃连接监控看连接数是否在这段时间突然飙升如果并发数翻倍死锁概率也会非线性上升。业务部署记录是不是刚发布过新版本是不是在做定时任务如果是定时任务死锁往往和批量更新有关。这三个维度交叉对比之后死锁的根因通常就浮出水面了。比如有一次我查一个反复出现的死锁死锁日志里两条SQL都很简单单看任何一条都不至于。后来对比慢查询记录发现这两个事务共同操作了一张表而这张表某天起没有走索引每次更新都要全表扫描行锁数量从几行暴增到几万行锁竞争一下子激烈起来死锁频率就上去了。4. 预防死锁的六大实战手段从架构到SQL4.1 统一锁顺序最简单但最有效既然死锁的本质是循环等待那最直接的预防方法就是让所有事务以相同顺序获取锁。以转账为例不要一个事务先更新id1再更新id2另一个事务反过来。可以在应用层做约束每次更新账户前对账户ID进行排序确保所有事务都按升序加锁。如果你用的是Java类似这样ListLong accountIds Arrays.asList(1L, 2L); Collections.sort(accountIds); for (Long id : accountIds) { executeUpdate(UPDATE account SET balance balance ? WHERE id ?, amount, id); }排序之后两个事务都先锁id1再锁id2就不可能出现循环等待。这个规则在批量操作、跨表事务里同样适用比如先更新主表再更新子表或者统一按数据字典的字母顺序访问多张表。它不改变业务逻辑性能损失几乎为零却能把90%以上的交叉加锁死锁消掉。4.2 索引设计影响锁的边界索引不当整表被锁很多死锁和锁竞争问题根源不是并发太高而是索引没设计好。InnoDB在RR隔离级别下执行UPDATE时需要在读取到的索引记录上加锁。如果WHERE条件没有可用索引MySQL只能进行全表扫描那就意味着表里所有记录都会被锁住。哪怕业务逻辑上你只打算更新一行实际锁范围却是整张表。举个我实际排查过的例子UPDATE orders SET status PAID WHERE order_no 20260115001;这条SQL是高频执行语句但order_no上没有索引执行计划走了全表扫描。结果就是任何两个事务同时执行不同订单的状态更新时都会因为锁了同一批已有记录而互相等待。更棘手的是SHOW ENGINE INNODB STATUS里显示的锁对象全是主键记录不看执行计划完全猜不到根因。解决办法很简单给order_no加索引ALTER TABLE orders ADD INDEX idx_order_no(order_no);加了索引之后执行计划变成走idx_order_no锁只会落在命中的那一两条索引记录上锁范围大幅收窄。所以排查死锁时如果发现SQL的执行计划是ALL或者全表扫描第一步不是调并发而是把索引补上。4.3 把事务控制在“短平快”的范围内锁持有时间越长等待方就会越多死锁概率随之上升。很多事务原本可以在几毫秒内完成但因为里面混了一些不该有的操作硬生生拖成了秒级。常见的大毛病包括事务里查大量数据再逐条在循环中做更新。事务里调用远程RPC接口网络耗时被包含在锁持有时间内。事务里执行复杂的聚合计算比如一次计算几十万行数据。事务开始后先做SELECT再弹出人工确认弹窗等用户点击后才提交。事务边界和锁的生命周期高度相关所以控制事务大小是最有效的预防策略之一。在InnoDB里一条UPDATE的锁要到事务结束才释放不是语句结束。也就是说一个开了一分钟的事务会导致其他事务等锁一分钟如果等待方足够多死锁检测资源消耗也会飙升。关于超时时间建议根据业务特点配置合理的innodb_lock_wait_timeout默认50秒对OLTP来说太长了。一个在线查询接口等50秒不可接受所以不少业务会把这个值调成5秒。超时后MySQL会把等待侧事务回滚避免无限等待。但这只能兜底不能从根本上降低死锁概率。4.4 权衡隔离级别从RR降到RC的效果MySQL默认的隔离级别是REPEATABLE READ这是死锁高发的重要诱因因为它引入了间隙锁。前面讲过间隙锁会锁住索引范围里的“空隙”让其他事务无法插入新记录。所以只要是RR级别下的范围查询、范围内的UPDATE或DELETE都天然容易与并发的INSERT产生锁冲突。如果在业务上能接受读已提交READ COMMITTED的语义降级到RC是一个效果非常明显的预防手段。RC级别下InnoDB对范围操作的间隙锁基本取消只锁住实际扫描到的索引记录上的临键锁退化为记录锁。死锁发生的频率通常会明显下降。但是降级不能拍脑袋。RC下有两个问题需要提前评估。第一binlog格式必须设置成ROW否则主从复制可能因为语句执行结果不确定性而失败。第二RC下不可重复读现象是允许的如果业务要求同一个事务里多次读取必须结果一致就不能降级。生产环境是否降级建议在压测环境先跑一轮性能对比观察死锁、锁等待、吞吐量三个指标后再做决定。4.5 应用层重试机制应对偶发死锁的兜底方案再完善的预防手段也无法把死锁降到零尤其是高并发场景下总有意外情况。真正成熟的系统不会只想着“不要死锁”而是会设计“死锁了怎么办”。InnoDB检测到死锁后会回滚其中一个事务并向客户端返回1213错误码ER_LOCK_DEADLOCK。对应用来说最直接的处理方式就是捕获这个错误稍等片刻后重试整个事务。一个典型的Java重试逻辑大致长这样public void transferWithRetry(AccountTransaction txn) { int maxRetry 3; for (int i 0; i maxRetry; i) { try { accountDao.transfer(txn); return; } catch (DeadlockLoserDataAccessException e) { if (i maxRetry - 1) { throw e; } Thread.sleep(ThreadLocalRandom.current().nextLong(10, 50)); } } }重试时要注意两个点。第一不是只重试出问题的SQL而是要重试整个事务因为锁被回滚后事务内前面几条语句已经执行完的数据变更可能已经被标记为回滚如果只重试最后一条语句会产生数据不一致。第二重试要控制次数并加入随机延迟不然多个客户端同时重试会再一次撞成死锁形成雪崩。Spring的Retryable注解也可以优雅实现同样的逻辑不过我个人更喜欢显式写try-catch逻辑更透明方便在重试前后打日志。4.6 架构层面的分散与排队最终极的降死锁手段如果业务并发高到无论怎么调SQL都会撞锁那就该考虑把压力从数据库层分流出去。最常见的思路是把热点操作异步化用户下单请求先写入消息队列由消费者串行或少量并发地处理库存扣减这样数据库层的锁竞争从“几百个并发同时抢一把锁”降级成“几个消费者顺序拿锁”死锁风险直接消失。另一种做法是引入分布式锁做前置拦截比如用Redis的SETNX实现在操作同一用户、同一商品时先抢一把业务锁抢到锁的请求才允许访问数据库。分布式锁有一个副作用就是会降低系统的吞吐量所以更适合用在真正的热点场景而不是无脑套用。说到底死锁只是并发问题的表层症状。如果并发模型简单、压力可控靠SQL优化和重试就能解决如果业务天然就是高并发抢购类场景那么只有把流量在数据库前面削平才是治本之路。我在多个项目里的经验是架构层削峰之后死锁告警数量基本都能从每日几百条降到个位数甚至直接归零。5. 踩坑记录与高频问题速查5.1 我踩过的三个坑第一个坑发生在某次线上大促前。我给一张高频订单表加了一个普通索引结果没注意隔离级别是RR。上线后接口出现大量锁等待和死锁排查的时候才发现这个新增的辅助索引让本来全表级的小范围更新变成了一个间隙锁范围更新反而增加了并发冲突。后来回滚了索引才恢复正常。这件事给我的教训是索引既是预防锁问题的工具也可能成为锁竞争的放大器。加任何索引都要结合业务并发场景评估不能只看查询性能。第二个坑是幂等重试做得不彻底。用户创建订单时因为幂等键冲突触发死锁我的重试逻辑把整个创建流程重新执行了一遍结果生成了两笔订单。死锁本身没让数据出错是我的重试破坏了幂等性。后来我改造了重试逻辑写入业务幂等号重试前先按幂等号查一次订单是否已存在存在就直接返回。第三个坑是关于死锁日志保留的。早期我以为SHOW ENGINE INNODB STATUS就够了后来某次线上偶发死锁等我登录数据库去看时已经是第二天的死锁覆盖了第一天的记录现场完全丢失。从那以后我不仅在测试环境开启innodb_print_all_deadlocks还会定期把死锁日志归档并接入告警系统。排查死锁最怕的就是“没有日志”所以这一点无论如何不能省。5.2 高频问题速查表下面整理一些我常被问到的问题以及我多年实践下来的答案问题答案死锁会损坏数据吗不会。事务具备原子性死锁发生后InnoDB会回滚一方事务数据不会损坏但应用需要处理事务失败后的重试。死锁是不是MySQL的bug绝大多数不是。死锁是并发控制机制的正常结果通常和锁顺序、索引、隔离级别、事务大小有关。死锁和锁等待超时是一回事吗不是。锁等待超时指事务等一把锁超过阈值innodb_lock_wait_timeout后放弃死锁是多个事务循环等待InnoDB主动检测并选择一方回滚。RR和RC哪个更容易死锁RR更容易。RR的间隙锁会扩大锁范围增加与插入操作冲突的概率。死锁发生后会自动重试吗会回滚一方事务但不会自动重试。需要应用端捕获1213错误码后自行重试否则该次请求失败。为什么死锁的两条SQL看起来毫无关联因为锁冲突不一定表现在SQL语句上可能表现为锁与锁之间的索引间隙冲突比如一个事务范围锁另一个事务插入新行。关闭死锁检测可以吗不建议。innodb_deadlock_detectOFF只适合并发极低或已知死锁频率极低的场景而且关闭后风险非常高。5.3 最后的压仓建议把死锁当成一个系统性问题来治排查和预防MySQL死锁归根结底不是多背几条命令的事而是要在系统设计层面形成一套闭环上线前做并发评估写SQL时先看执行计划事务逻辑尽量短小加锁顺序全局统一重试要有幂等兜底日志和告警必须提前配好。我个人在实际操作中最受益的一条经验是每一次死锁都要当成一次事故来复盘把死锁日志存下来把涉及的SQL和索引信息整理出来形成一份内部的“死锁案例手册”。遇到新的死锁先查手册里的历史案例经常能直接命中。这套方法帮我省下了大量反复排查同一类问题的时间。最后再分享一个小技巧排查死锁时先看执行计划再看死锁日志最后看业务代码。按这个顺序90%的问题在第一步就能锁定方向。
返回列表