30分钟快速响应 24小时紧急待命 云南16地州
新客立减20元 登录 注册 公众号: 易云城IT运维服务

MySQL死锁排查全流程:从一条慢查询到根因定位

eycit 来源:QCLAW自动生成 2026-04-19 15 次阅读 系统安装
看完了还搞不定?这类问题我上门 / 远程帮你处理
城区1小时上门 · 30分钟响应 · 先检测后报价 · 修不好不收费
---

theme: default themeName: "默认主题" title: "MySQL死锁排查全流程:从一条慢查询到根因定位"


MySQL死锁排查全流程:从一条慢查询到根因定位

凌晨两点,手机振动。监控系统报了一连串MySQL告警——死锁频率异常升高,最近10分钟内出现了47次。

这种级别的死锁频率不是偶发的业务冲突,大概率是某个最近上线的代码变更引入的系统性问题。我打开电脑,SSH连上数据库从库开始排查。

告警现场:先看现象

生产环境跑的是MySQL 5.7.38,主从架构,业务是电商订单系统。告警页面显示死锁集中在`order_stock`这张表上,涉及的SQL有两条:

-- 事务A

UPDATE order_stock SET locked_qty = locked_qty + 1 WHERE order_id = 10086 AND sku_id = 2001 AND warehouse_id = 5;

-- 事务B UPDATE order_stock SET stock_qty = stock_qty - 1 WHERE sku_id = 2001 AND warehouse_id = 5 AND order_id = 10086;

看SQL本身,操作的是同一行数据,只是字段不同。按理说InnoDB的行锁应该让一个事务等另一个事务提交就行了,为什么会死锁?

第一步:拉死锁日志

直接上`SHOW ENGINE INNODB STATUS`,拉最近一次死锁的完整记录:

SHOW ENGINE INNODB STATUS\G

在输出中找到`LATEST DETECTED DEADLOCK`这一段,关键信息如下:

* (1) TRANSACTION:

TRANSACTION 2847391, ACTIVE 2 sec updating mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 4521, OS thread handle 140234567891200, query id 98234 UPDATE order_stock SET locked_qty = locked_qty + 1 WHERE order_id = 10086 AND sku_id = 2001 AND warehouse_id = 5

* (1) WAITING FOR THIS LOCK TO BE GRANTED:

RECORD LOCKS space id 89 page no 456 n bits 72 index idx_sku_warehouse of table `ecom`.`order_stock` trx id 2847391 lock_mode X waiting

* (2) TRANSACTION:

TRANSACTION 2847392, ACTIVE 1 sec updating mysql tables in use 1, locked 1 3 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 4523, OS thread handle 140234567899200, query id 98235 UPDATE order_stock SET stock_qty = stock_qty - 1 WHERE sku_id = 2001 AND warehouse_id = 5 AND order_id = 10086

* (2) HOLDS THE LOCK(S):

RECORD LOCKS space id 89 page no 456 n bits 72 index idx_sku_warehouse of table `ecom`.`order_stock` trx id 2847392 lock_mode X locks rec but not gap

* (WE ROLL BACK BACK TRANSACTION (1))

这段信息非常关键。事务1在等`idx_sku_warehouse`索引上的X锁,而事务2持有这个锁。事务2在等什么呢?再往下看,事务2在等主键索引(或另一个索引)上的锁,而这个锁恰好被事务1持有着。

这就是典型的多索引交叉死锁。

第二步:分析锁的持有逻辑

问题出在索引使用路径不同。两条SQL的WHERE条件字段顺序不同,导致MySQL走了不同的索引:

事务A的WHERE是`order_id, sku_id, warehouse_id`,命中了主键(假设主键就是order_id)或者`idx_order`索引,先锁了这个索引的记录,再去锁`idx_sku_warehouse`索引。

事务B的WHERE是`sku_id, warehouse_id, order_id`,命中了`idx_sku_warehouse`索引,先锁了这个索引的记录,再去锁`idx_order`索引。

两个事务各持有一个锁,都在等对方手里的锁。经典的"你等我、我等你",InnoDB的死锁检测器发现后直接rollback了事务1。

这种问题在代码review阶段很难发现,因为单条SQL本身完全没问题。只有并发执行、锁的获取顺序相反时才会暴露。

第三步:用pt-deadlock-logger抓模式

单次死锁可能是偶发,但47次/10分钟说明这是个高频场景。我用Percona Toolkit的`pt-deadlock-logger`持续抓取死锁记录:

pt-deadlock-logger h=127.0.0.1,u=monitor,p=xxx \

--dest h=127.0.0.1,D=deadlock_log,t=deadlocks \ --run-time 30m \ --interval 5s \ --create-dest-table

跑30分钟后查`deadlock_log.deadlocks`表,按`LEFT(locked_sql, 100)`分组统计:

SELECT 

LEFT(locked_sql, 80) AS sql_pattern, COUNT(*) AS cnt, MIN(first_seen) AS first_seen, MAX(last_seen) AS last_seen FROM deadlock_log.deadlocks GROUP BY LEFT(locked_sql, 80) ORDER BY cnt DESC;

结果验证了我的判断:死锁全部发生在`locked_qty +1`和`stock_qty -1`这两条UPDATE之间,没有任何其他SQL卷入。问题范围已经收窄到订单锁定库存这个业务场景。

第四步:深入分析索引和表结构

SHOW CREATE TABLE ecom.order_stock\G

表结构如下(简化):

CREATE TABLE order_stock (

id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, warehouse_id INT NOT NULL, stock_qty INT NOT NULL DEFAULT 0, locked_qty INT NOT NULL DEFAULT 0, UNIQUE KEY uk_order_sku_wh (order_id, sku_id, warehouse_id), KEY idx_sku_warehouse (sku_id, warehouse_id, order_id) ) ENGINE=InnoDB;

这里有两个索引:

  • `uk_order_sku_wh`:以order_id开头
  • `idx_sku_warehouse`:以sku_id开头

事务A走`uk_order_sku_wh`,事务B走`idx_sku_warehouse`,两个索引指向同一行数据但锁定路径相反,死锁就产生了。

第五步:从业务层面解决

解决方案有好几个层面:

方案一:统一索引访问路径。

让两条SQL都用同一个索引,最简单的做法是把WHERE条件的字段顺序写成一致的。比如都用`order_id, sku_id, warehouse_id`的顺序:

-- 事务A(不变)

UPDATE order_stock SET locked_qty = locked_qty + 1 WHERE order_id = 10086 AND sku_id = 2001 AND warehouse_id = 5;

-- 事务B(改写WHERE顺序) UPDATE order_stock SET stock_qty = stock_qty - 1 WHERE order_id = 10086 AND sku_id = 2001 AND warehouse_id = 5;

MySQL优化器在大多数情况下会选择同一个唯一索引,这样两个事务的锁获取顺序一致,就不会交叉死锁。但要注意,优化器不保证100%选同一个索引,尤其是在数据分布变化时。

方案二:合并为单条SQL。

既然两条UPDATE操作的是同一行数据,为什么不合成一条?

UPDATE order_stock 

SET locked_qty = locked_qty + 1, stock_qty = stock_qty - 1 WHERE order_id = 10086 AND sku_id = 2001 AND warehouse_id = 5;

一条SQL只涉及一次索引锁定,根本不存在交叉的可能。这是最干净的方案。和业务方确认后,这两个字段确实可以在同一个事务中一起更新——锁定库存和扣减库存本来就是同一个原子操作。

方案三:如果必须分两条SQL,加分布式锁或SELECT ... FOR UPDATE。

在UPDATE之前先加锁:

BEGIN;

SELECT id FROM order_stock WHERE order_id = 10086 AND sku_id = 2001 AND warehouse_id = 5 FOR UPDATE;

UPDATE order_stock SET locked_qty = locked_qty + 1 WHERE id = ?; UPDATE order_stock SET stock_qty = stock_qty - 1 WHERE id = ?; COMMIT;

`FOR UPDATE`会直接在主键上加排他锁,后续的UPDATE都走主键,锁的获取顺序完全一致。

最终方案和落地

跟业务方确认后,采用了方案二——合并SQL。改完后观察了48小时,监控系统再没报过死锁告警。性能上也有所提升,因为从两次索引查找+两次行锁变成了一次。

顺便把`idx_sku_warehouse`这个索引也评估了一下。这个索引是之前为"按SKU查库存"的查询加的,但经过慢查询分析,那个查询命中率极低(每天不到10次),而且走了全表扫描也只需要几十毫秒。直接砍掉这个索引,减少维护开销,也从根本上消除了多索引交叉死锁的可能。

-- 执行前确认没人在用

SELECT * FROM sys.schema_unused_indexes WHERE object_schema = 'ecom'; -- 确认后删除 ALTER TABLE order_stock DROP INDEX idx_sku_warehouse;

写在最后

排查死锁这件事,很多人的第一反应是"加大innodb_lock_wait_timeout"或者"减少事务粒度"。这些手段偶尔有用,但大多数时候只是在掩盖问题。死锁的本质是并发事务对同一组资源的访问顺序不一致。找到那个不一致的地方,问题就解决了一大半。

建议在生产环境开启死锁日志持久化:

SET GLOBAL innodb_print_all_deadlocks = ON;

配合`pt-deadlock-logger`做持续监控,下次再出问题就不用半夜爬起来逐条翻了。


【放心,我们兜底】

不管你是自己尝试修复,还是需要专业人员上门,易云城IT服务都给你托底。修不好不收费,修好了质保期内随时找我。

? 服务热线:13708730161 ? 微信:eyc1689 ? 邮箱:service@eycit.com ? https://www.eycit.com

您身边的IT专家。

觉得有用?分享给朋友吧
💡 遇到类似问题?

易云城工程师帮您解决

远程协助30分钟响应 · 云南全省上门 · 先检测后报价

遇到类似问题?我帮你看一眼

留个手机号,我今天就联系你,帮你判断是什么问题、大概要多少钱。 30 分钟响应,2 小时出报价,先检测后报价——没上门之前不收任何费用。你的号码只用于这一次联系。

也可以直接打 13708730161, 或到 留言页 描述情况。

阅读本文后,推荐您试试这些
免费 · 随意保存

别用手机流量搜工具了

我现在就给你发 137 款运维工具的官方直链,存手机里应急用,不着急装也可以等师傅上门。

🔥 限时特惠

升级VIP会员,解锁全部IT工具

137款专业运维工具 · 系统镜像/WinPE · 工程师1对1远程支持

系统工具包 数据恢复工具 网络诊断工具 安全防护工具
¥ 99 /年 约 0.27元/天
立即开通
相关服务

评论

评论功能开发中,敬请期待~