☰
MySQL死锁原理与实战:从日志分析到优化策略
2026/10/20 7:17:11 网站建设 项目流程

1. 死锁的判定与InnoDB的自动处理机制

1.1 死锁的四个必要条件与“为什么数据库必须介入”

死锁这件事,刚开始接触时容易把它当成一种“数据库故障”,但本质上它是一类资源竞争问题。两个事务各自持有一部分资源,又都等着对方释放自己需要的另一部分资源,形成了一个谁都不肯先退出的闭环。数据库里的锁就是资源,事务就是竞争者,死锁就是竞争者之间的环形等待。

要触发死锁,必须同时满足四个条件:互斥、持有并等待、不可剥夺、循环等待。放在MySQL InnoDB里看,互斥是指同一行记录同一时刻只能被一个事务加上排他锁;持有并等待是指事务A拿着第1行的锁,又去申请第2行的锁;不可剥夺是指锁不能被数据库强行从持有者手里抢走;循环等待就是事务A等B、事务B等A。

既然数据库能识别出这个环形关系,它就不能一直等下去。InnoDB的做法是启动死锁检测,一旦发现有环,就立刻选择其中一个事务回滚,释放它持有的锁,让另一个事务继续跑。这个被回滚的事务会在客户端收到一条类似“Deadlock found when trying to get lock; try restarting transaction”的错误,错误码是1213。

为什么数据库宁可牺牲一个事务,也要主动打破僵局?原因很简单:如果不强制介入,这个环会一直阻塞下去,直到业务层超时或者运维手动杀掉会话。更麻烦的是,这种阻塞会像滚雪球一样波及后面所有要访问同一行记录的事务,连接池最终被拖垮。所以InnoDB的自动死锁检测本质上是“两害相权取其轻”,用一次业务失败换取整个数据库的可用性。

1.2 InnoDB如何选“牺牲者”

死锁检测发现环之后,并不是随机挑一个事务回滚,而是倾向于回滚代价更小的事务。代价怎么衡量?InnoDB会参考事务已经修改的行数、持有的锁数量、undo日志的大小等维度,把已经干了更多活、回滚成本更高的事务留下,把较轻的那个事务回滚掉。

这个机制在绝大多数场景下是合理的,但也会造成一种“看起来不公平”的现象:比如事务A先执行了很久,已经改了100行,事务B刚插入一条记录,结果反而是事务B被回滚。很多开发同学第一次遇到这种报错时,会以为是数据库随机回滚或者代码逻辑有bug,其实不是,这恰恰是InnoDB在保护整体代价。

理解了这个选择逻辑之后,再看“死锁是偶发还是高频”这个问题就会更清醒。偶发死锁可以靠重试解决;高频死锁说明事务之间的锁竞争已经非常剧烈,单纯重试只会让失败率居高不下,甚至放大压力。这时候应该做的是回头审查SQL的加锁顺序和事务持有锁的时长,而不是一味地调参数。

1.3 死锁不等于锁等待:先分清两个概念

排查死锁时,最常见的混淆是把死锁和普通的锁等待混为一谈。锁等待是事务A在等事务B持有的锁,但B并没有等着A的资源,B正常提交后A就能继续走;死锁则是两边同时在等对方,必须有人回滚才能解开。

InnoDB对这两种情况分别提供了参数:innodb_lock_wait_timeout控制的是单条锁等待的最大时间,默认50秒;innodb_deadlock_detect控制的是是否启动死锁检测,默认开启,检测到死锁会直接报错,不需要等到超时。

锁等待超时和死锁是两套完全不同的处理路径。锁等待超时发生在“一条事务等了太久还没拿到锁”的场景,报错是1205;死锁是“两边互等”的场景,报错是1213。如果数据库里频繁出现1205而不是1213,说明不是死锁,而是某条慢事务长时间占着锁不放。这种情况下调低等待超时只是治标,真正要做的是找到那个持锁时间长的事务,把它拆短。

2. 从死锁日志到问题定位:必查的几个视图与命令

2.1 SHOW ENGINE INNODB STATUS 的正确打开方式

遇到死锁后,第一反应应该是去拿第一手现场资料。MySQL里最直接的工具就是SHOW ENGINE INNODB STATUS,它会在输出中保留最近一次死锁的详细信息。注意关键字“最近一次”,也就是说这个命令只会保留最后一次,如果死锁已经过去很久或者中间又发生过多次,你只能看到最后一次的现场。

实际使用的时候,不建议直接去生产环境敲这条命令,因为它的输出非常长。更常见的做法是把结果输出到文件或者截取特定片段。死锁信息集中在输出里的LATEST DETECTED DEADLOCK这一段,里面会列出两个事务各自的完整信息。

SHOW ENGINE INNODB STATUS\G

输出里能看到的东西大致包括:每个事务的ID、运行状态、持有锁结构、正在等待的锁、执行的SQL语句、涉及的索引和表名。我习惯把这几项摘出来,单独拉一个排查文档。日志中的WAITING FOR THIS LOCK TO BE GRANTED这一段,会明确告诉你这个事务在等哪一把锁;两边事务一对比,环形等待关系就会非常清楚。

2.2 用 information_schema 拼出锁等待现场

SHOW ENGINE INNODB STATUS只保留最近一次死锁,而线上出问题的时候往往不止一起,或者你想看“当前还有没有事务在互相等待”,这时候就需要查系统表。MySQL提供的三张表组合起来基本够用:information_schema.INNODB_TRX看事务状态,information_schema.INNODB_LOCK_WAITS看等待关系,sys.innodb_lock_waits则是一个更方便的视图。

我常用的排查SQL是这样写的:

SELECT w.waiting_trx_id, w.waiting_pid, w.blocking_trx_id, w.blocking_pid, t.trx_query AS waiting_query FROM information_schema.innodb_lock_waits w LEFT JOIN information_schema.innodb_trx t ON w.waiting_trx_id = t.trx_id;

这张结果能直接告诉你:谁在等、谁被等、等待的事务正在执行什么SQL。再配合performance_schema里的events_statements_history_long,还能还原出当前持有锁的事务之前执行过哪些语句。虽然它不能像死锁日志那样给出完整的回滚选择依据,但对于“当前有没有锁等待积压”和“卡在哪张表”这两个问题,效率非常高。

2.3 把日志还原成业务调用链

拿到死锁日志只是一半,真正花时间的部分是把数据库层的锁现象映射回业务代码。死锁日志里只会出现表名、索引名和SQL片段,不会告诉你这两个事务是从哪个接口进来的。

我见过一个比较典型的案例:线上出现死锁,日志显示事务A执行的是一条库存扣减SQL,事务B执行的是一条订单状态更新SQL。如果只盯着这两条SQL看,你会觉得它们操作的不是同一张表,死锁发生得莫名其妙。后来翻业务代码才发现,订单状态更新那条路径里,因为要校验库存快照,又去查了库存表,两个事务都在同一批库存记录上加了锁,只是加锁的顺序不同。

所以我的习惯是:拿到死锁日志后,先做一个映射表,列清楚事务ID、表名、索引名、等待的锁类型、SQL片段,再用代码仓库里的接口入口去反查。这个步骤不能省,因为如果只看数据库层面,你只能得出“两个事务发生了环形等待”的结论,根本不知道是哪个业务流程没对齐。

3. 一个真实项目里的死锁复现:并发扣库存的两条SQL

3.1 表结构与事务路径

为了把死锁这个偏理论的概念讲清楚,我拿一个很经典的并发扣库存场景来做例子。这个场景我在多个项目里都遇到过,细节虽然不同,但死锁的骨架完全一致。

假设有一张库存表,结构大致长这样:

CREATE TABLE `t_stock` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `sku_id` INT UNSIGNED NOT NULL, `warehouse_id` INT UNSIGNED NOT NULL, `quantity` INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_sku_wh` (`sku_id`, `warehouse_id`) ) ENGINE=InnoDB;

业务上有两条事务路径:一条是“从A仓库调拨到B仓库”,需要同时扣减sku_1在A仓的数量、增加sku_1在B仓的数量;另一条是“从C仓库调拨到B仓库”,需要同时扣减sku_2在C仓的数量、增加sku_2在B仓的数量。

这两条路径本身看起来是两个不同的sku,但如果它们都涉及到B仓库这一行,并且采用“先更新目标仓库、再更新源仓库”的顺序,那么当两个事务同时操作时就有可能出现交叉等待。简化后,它们实际上都在争抢B仓库这一行记录上的锁,只是先手后手不一样。

3.2 死锁产生的完整时序

我用下面这个表格还原死锁的过程。两个事务分别操作两行库存数据,假设A仓和B仓分别对应sku_id=100、warehouse_id=1和warehouse_id=2。

时间点事务A事务B
T1UPDATE t_stock SET quantity=quantity-1 WHERE sku_id=100 AND warehouse_id=1;
T2UPDATE t_stock SET quantity=quantity-1 WHERE sku_id=101 AND warehouse_id=2;
T3UPDATE t_stock SET quantity=quantity+1 WHERE sku_id=100 AND warehouse_id=2;(等待B释放仓库2的行锁)
T4UPDATE t_stock SET quantity=quantity+1 WHERE sku_id=101 AND warehouse_id=1;(等待A释放仓库1的行锁)

T4一发生,事务A等着B持有的仓库2的锁,事务B等着A持有的仓库1的锁,环形等待成立。InnoDB的死锁检测器会在T4之后很快发现这个环,并回滚其中的一个事务。

这个例子看起来很简单,但它有一个很容易被忽略的重点:死锁不一定发生在同一个sku上。即使事务A操作sku_100,事务B操作sku_101,只要它们都同时碰了仓库1和仓库2这两行,就照样会互相卡住。这就是为什么排查死锁时不能只盯着单条SQL,必须把整个事务的多条SQL当成一张图来看。

3.3 为什么“批量更新”常常是重灾区

单条UPDATE的场景其实不容易出死锁,因为一个事务一次只锁一行。真正的高发区是“一个事务里按集合循环更新”或者“一条UPDATE影响多行”的情况。比如有个需求是批量给一批订单打上标签,代码写的是:

for (Order order : orderList) { updateOrderStatus(order.getId(), order.getStatus()); }

如果有两个并发请求,分别处理两批订单,而这两批订单存在交叉,那么第一个请求先锁了订单1,第二个请求先锁了订单2,然后第一个请求去锁订单2时发现被第二个请求占着,第二个请求又回去锁订单1,死锁就出现了。

批量操作里还有另一种隐蔽触发方式:UPDATE ... WHERE id IN (...)子句里的id顺序不一致。比如事务A更新的是IN (1,2,3),事务B更新的是IN (3,2,1)。表面上看都是更新同样三行,但InnoDB在加锁时是按索引顺序逐个加锁的,两个事务加锁的先后顺序不同,瞬间就可能形成交叉等待。

在代码里,这个问题很好解决:把IN列表做一次排序,保证所有事务都以相同的顺序去获取锁。但很多团队都没有注意到这一点,因为单条SQL执行计划里看不出顺序问题,只有并发压测才会暴露。

4. 快速恢复的兜底策略:重试、超时与连接治理

4.1 应用层重试机制

死锁报错在业务侧是无法完全避免的,所以第一层防线永远是应用层的重试。无论是Java的Spring事务,还是Python的数据库驱动,捕获到1213错误后,都应该把整个事务重新执行一次,而不是只重放最后一条SQL。

这里有一个非常重要的细节:重试的单位必须是整个事务,不能是单条UPDATE。因为事务被回滚之后,之前所有的修改都已经失效,如果只重试最后一条语句,前置条件可能已经不对了。以扣库存为例,事务里先查询了可用库存,又做了扣减,死锁被回滚后再只执行那条扣减SQL,等于跳过了库存校验。

重试次数和重试间隔需要结合业务耗时来定。我常用的策略是:最多重试3次,第一次间隔100毫秒,第二次200毫秒,第三次400毫秒,整体退避时间控制在1秒以内。如果接口的完整超时时间比较短,比如要求2秒内返回,那重试次数要相应减少,不能为了重试牺牲整体SLA。

@Transactional public void deductStock(List<StockItem> items) { try { // 业务逻辑 stockService.batchDeduct(items); } catch (DeadlockLoserDataAccessException e) { // 记录日志 retryExecutor.retryWithBackoff(() -> deductStock(items), 3); } }

这种写法只是示意,真正落地时要把重试逻辑和业务逻辑隔离,不能把事务注解和重试套在同一个方法上,否则事务已经标记为rollback-only,后续重试也会失败。

4.2 超时参数怎么调:先看懂两个参数

重试机制解决的是“死锁已经发生了怎么办”,而超时参数解决的问题是“拿不到锁的时候等多久”。innodb_lock_wait_timeout默认是50秒,这个值对大多数面向用户的服务来说都太长了。试想一下,一个接口的P99耗时是800毫秒,如果数据库锁等待等满50秒,连接池里这条连接至少要挂50秒,几轮下来连接池直接耗尽。

我第一次处理生产死锁时,就把这个参数从50秒调到了5秒。后面又根据接口耗时往回压,调到3秒左右。调参的逻辑很简单:锁等待时间应该大于事务正常执行时间,但必须小于接口可接受的超时时间。如果业务事务平均执行时间是500毫秒,接口最大容忍时间是1秒,那锁等待设置3秒已经足够宽裕,再大没有意义,因为客户端早就超时返回了。

另一个参数innodb_deadlock_detect要谨慎动。官方默认是开启的,建议在绝大多数场景下保持开启。只有一种情况可以考虑关掉:死锁检测本身在极端高并发下会消耗不少CPU,而且你确认业务里的锁等待非常短,所有冲突都可以被innodb_lock_wait_timeout兜住。

关掉死锁检测的风险是:一旦真的形成环形等待,不再会立即报错,所有事务都要傻等直到超时。超时时间设置得长,问题会更早爆炸。所以除非你已经把事务边界压缩得很短、锁竞争又集中在高频小事务上,否则不要轻易关。

4.3 连接池与告警联动

死锁对应用的伤害不只是单次请求失败,更可能引发连接池被占满的连锁反应。因为每条死锁事务在被应用捕获之前,它持有的数据库连接一直没释放。如果前台很多请求同时撞进同一个热点行,死锁不断发生,连接池的活跃连接数会迅速蹿高。

我见过一个比较隐蔽的连接池问题:应用层重试逻辑写得不对,死锁发生后,重试任务又去抢占同一个连接池连接。原事务还没完全释放,重试请求又进来了,连接池被双重消耗,很快就达到maximum-pool-size上限。后续所有健康的请求都被挡在外面。

这块我的经验是,重试逻辑必须加并发控制:同一业务键(比如同一个订单号、同一个skuid)的重试要串行化,不能无脑并发重试。同时告警指标不能只看死锁次数,还要看活跃连接数、锁等待平均时长、threads_running这几个指标。死锁是不可消除的,但只要连接池没被打爆,应用层重试总能把它消化掉。

5. 消灭死锁的设计手段:锁顺序、索引、事务边界与隔离级别

5.1 统一加锁顺序

处理死锁最有效的手段,不是等在出了问题之后去重试,而是在设计阶段就保证所有事务对共享资源的加锁顺序一致。这个概念听起来很简单,但在业务复杂之后特别难坚持。

统一加锁顺序有一个比较实用的落地方式:给资源定义一个逻辑顺序。比如操作多个仓库的库存时,永远按warehouse_id从小到大更新;操作多个订单状态时,永远按order_id从小到大更新。如果一个操作的资源是动态的,就要在代码里先做排序再进入事务。

List<StockItem> items = buildItems(); items.sort(Comparator.comparing(StockItem::getWarehouseId)); transactionTemplate.execute(status -> { for (StockItem item : items) { stockDao.deduct(item); } });

这种改动看起来很小,但它在根上消除了“循环等待”的可能性,因为所有事务都在按同一个方向拿锁,永远不会出现A拿1等2、B拿2等1的情况。

有些项目会说“我们的业务路径太复杂,统一排序不现实”。我承认这种情况存在,但至少可以做一件事:把最容易产生竞争的几个核心资源明确出来,单独约束它们的访问顺序。比如库存、账户余额、优惠券这类热点资源,值得花精力去梳理。

5.2 索引对锁范围的影响

很多人对锁的理解停留在“锁住了一行记录”,但实际上InnoDB的锁是加在索引记录上的。如果一条UPDATE没有走索引,它要扫描全表,那么它就可能把扫描路径上的一系列记录都锁住,导致锁范围被放大好几倍,死锁概率也跟着暴涨。

我之前排查过一个问题:一张订单明细表,查询条件里写了order_id,但order_id没有建索引,导致每次更新都走全表扫描。两个并发事务都在更新不同状态的订单,结果互相卡住。最后加了一个普通索引idx_order_id,索引选择从全表扫描变成ref,锁范围从几百行变成几行,死锁直接消失了。

判断一条UPDATE走了什么索引,标准姿势是执行EXPLAIN:

EXPLAIN UPDATE t_stock SET quantity = quantity - 1 WHERE warehouse_id = 2;

看type列就行:如果是ALL,说明全表扫描;ref或range是比较理想的索引范围。记住一个结论:能用唯一索引定位就用唯一索引,这样做行锁的精度最高;普通索引也能接受,但扫描范围内所有命中的索引记录都会被加锁。

还要注意一个细节:二级索引和主键索引的加锁顺序不一致也会引发死锁。比如事务A先通过二级索引锁到了目标行,然后要回表锁主键;另一个事务B先通过主键更新了同一行,然后要去维护二级索引。两个事务可能在二级索引和主键索引之间形成交叉等待。这种情况下,日志里会看到两个事务等待的索引不同,一个停在二级索引上,一个停主键索引上。

5.3 事务边界越小越安全

一个事务持锁时间越长,被别人撞上的概率就越大。所以控制死锁率的另一个重要手段,是压缩事务的执行时间。怎么压缩?已经有过明确实践:把耗时的远程调用、HTTP请求、消息发送全部移出事务;只把必须保证原子性的数据库操作留在事务里。

有个反例是很多老系统会把“查询抵用券、计算优惠、更新订单、扣减库存、发送短信”全部放在一个事务里。短信接口一慢,整个事务就拖着数据库锁不放,所有要更新同一条订单的请求都在排队,死锁和超时一起爆发。改造方式很简单:事务里只做订单状态更新和库存扣减,短信走消息队列异步发送。

批处理事务是另一个需要注意的地方。一次事务更新1000行,和分成10次事务每次更新100行,锁持有时间差别很大。如果更新过程中某一行本来就存在竞争,大批次会让其他事务等待更久,也更容易形成交叉等待。拆批之后,单个事务变短,死锁率通常会明显下降。

5.4 隔离级别取舍

隔离级别对死锁的影响主要体现在间隙锁上。REPEATABLE READ(RR)是MySQL默认隔离级别,它为了保证可重复读,在范围查询时会额外加间隙锁,锁住的是“记录之间的间隙”,甚至可能锁住一个范围。READ COMMITTED(RC)只加记录锁,不加间隙锁,所以在很多高并发场景下,RC会有效降低死锁概率。

我的经验是:如果业务可以接受RC,把数据库隔离级别从RR改成RC,往往能显著减少更新冲突。但RC也不是银弹,它照样有两个问题:第一,无法保证同一事务内多次查询结果一致;第二,主从复制要求binlog格式为ROW,否则可能会出现主从数据不一致。

SET GLOBAL transaction_isolation = 'READ-COMMITTED'; SET SESSION transaction_isolation = 'READ-COMMITTED';

这种调整最好先在压测环境做对比。我的一个习惯是,拿同一套并发脚本分别在RR和RC下跑,统计死锁次数和平均耗时,用数据决定是否切换。切换之前要跟业务团队确认,他们能不能接受一个事务里两次查询结果可能不同。

6. 死锁监控闭环:从告警到预防的落地经验

6.1 用监控数据回答“死锁多不多、影响多大”

死锁处理最怕的是“老虎来了打一枪,平时不设防”。想建立一套可复用的闭环,首先要从监控数据开始。数据库层面可以统计死锁次数,应用层面可以统计业务报错码1213的出现频率。两个数字一起看,才能判断死锁对用户的影响面。

performance_schema里有一个比较实用的指标来源:events_errors_summary_by_error,可以按错误码统计次数。查询一下就能知道1213在一天里出现了多少次,是集中在一个业务还是散落在多个业务。

SELECT error_name, count_star FROM performance_schema.events_errors_summary_by_error WHERE error_name LIKE '%ER_LOCK_DEADLOCK%';

应用层则建议在捕获死锁异常时打印结构化日志,包含事务涉及的资源集合、重试次数、耗时。数据沉淀下来之后,你就能回答几个关键问题:每天死锁次数的基线是多少;哪些时间段是高峰;哪个业务域的占比最大。没有这些数据,后续的优化就只能靠猜。

6.2 自动采集死锁日志并定期复查

SHOW ENGINE INNODB STATUS只能看到最近一次死锁,所以靠人工去抓日志是不行的。我自己习惯的做法是定期从错误日志里捞死锁片段,然后做标准化解析,把表名、索引名、事务ID、等待锁类型都提取出来,汇总到一份表格里,每周过一遍。

MySQL的错误日志默认会记录部分死锁信息,但要确保log_error_verbosity配置能覆盖到。更可靠的方式是写一个定时采集脚本,每隔几分钟执行一次SHOW ENGINE INNODB STATUS,把LATEST DETECTED DEADLOCK段保存下来,同时记录采样时间。注意采集太频繁也没有意义,因为死锁不是每秒都发生,间隔5到10分钟一次足够。

采集不是目的,复查才是。我每周会把死锁样本按“发生区域”分类,观察是否有新增的锁冲突模式。很多人只关心“死锁解决了吗”,但我觉得更重要的是“为什么会因为这一组SQL产生死锁”。这种复查做一段时间后,你会对自己业务里的锁行为形成一个直觉:哪些表是高危表、哪些业务路径必须加统一顺序。

6.3 在开发阶段就拦截:压测与SQL审查

最后一道防线是预防,把问题挡在发布之前。预防的主要手段是并发压测和SQL审查,两件事都不难,但需要坚持。

并发压测时,不能只测“同一个请求并发100次”,那测不出死锁。死锁往往发生在“不同的请求路径并发执行但操作了相同的资源”。所以压测脚本要设计成多个接口混合调用,模拟真实业务里的交叉请求。比如同时跑“调拨入库”和“调拨出库”,让它们竞争同一批仓库库存。

SQL审查主要看两件事:第一,UPDATE和DELETE语句有没有走合适的索引,EXPLAIN的结果是否令人满意;第二,一个事务里是否出现了多组UPDATE却缺少统一的访问顺序。这两点可以直接写进代码评审的检查清单里,不用搞很重的流程,只要每次改到涉及多条数据更新的地方,顺手看一眼就行。

我在几个项目里把这个闭环跑通后,最大的一个感受是:死锁处理不是一个一次性修复,而是一个持续收敛的过程。每次上线前做一次并发压测,每周看一次死锁样本,配合应用层重试兜底,死锁对业务的影响会被压到很低。真正危险的从来不是死锁本身,而是你对死锁的形成链路还一无所知的时候,就急着堆参数、加重试。先把日志读明白,再谈优化,这一点永远不会过时。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询