首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >记一次惊心动魄的MySQL死锁排查:高并发下的订单更新陷阱

记一次惊心动魄的MySQL死锁排查:高并发下的订单更新陷阱

原创
作者头像
远方诗人
发布2025-08-27 09:32:08
发布2025-08-27 09:32:08
7500
举报

作为一名后端开发者,我坚信你不会对Deadlock found when trying to get lock; try restarting transaction这个错误感到陌生。它就像一场突如其来的暴雨,总在你系统并发量最高的时候倾盆而下。今天,我就分享一个在电商场景中遇到的、极具代表性的死锁案例,带你深入MySQL的加锁迷雾。

技术环境
  • MySQL版本: 5.7.29(此死锁现象在8.0+版本中同样存在,但8.0的死锁日志更详细,下文会提到)
  • 存储引擎: InnoDB
  • 事务隔离级别: REPEATABLE-READ(MySQL默认级别)
  • 业务场景: 用户下单后,支付成功回调与用户主动取消订单几乎同时发生。
一、Bug现象

线上警报突然响起,监控平台显示数据库出现少量死锁错误,频率不高但持续存在。主要发生在订单表的更新操作上。用户的反馈是:“偶尔提示支付失败,但银行卡的钱已经扣了”,这是一个非常危险的信号——出现了数据一致性问题。

二、排查步骤

面对死锁,千万不要慌。MySQL已经为我们提供了强大的诊断工具。

步骤一:开启并查看死锁日志

首先,我们确认InnoDB的监控状态是开启的(默认通常是开启的):

代码语言:sql
复制
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';

如果未开启,可以临时动态设置(重启后会失效):

代码语言:sql
复制
SET GLOBAL innodb_print_all_deadlocks = ON;

当死锁发生时,MySQL会将完整的死锁信息输出到错误日志(Error Log) 中。这是我们排查的唯一线索。

我们登录服务器,找到错误日志,检索“DEADLOCK”关键字,找到了如下日志(注:这是简化整理后的核心内容):

代码语言:txt
复制
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-10-27 15:45:32 0x7f2b2c17b700
*** (1) TRANSACTION:
TRANSACTION 312345678, ACTIVE 0 sec updating or deleting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 111, OS thread handle 123456, query id 22222 192.168.1.100 api_user updating
UPDATE orders SET status = 'completed', pay_time = NOW() WHERE order_id = 'ORDER10001' AND status = 'unpaid';

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 456 page no 7 n bits 80 index idx_order_id of table `prod_db`.`orders` trx id 312345678 lock_mode X locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; ...

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 456 page no 7 n bits 80 index PRIMARY of table `prod_db`.`orders` trx id 312345678 lock_mode X locks rec but not gap waiting
Record lock, heap no 5 PHYSICAL RECORD: n_fields 8; ...

*** (2) TRANSACTION:
TRANSACTION 312345679, ACTIVE 0 sec updating or deleting
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 112, OS thread handle 123457, query id 22223 192.168.1.100 api_user updating
UPDATE orders SET status = 'cancelled' WHERE order_id = 'ORDER10001' AND status = 'unpaid';

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 456 page no 7 n bits 80 index PRIMARY of table `prod_db`.`orders` trx id 312345679 lock_mode X locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: n_fields 8; ...

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 456 page no 7 n bits 80 index idx_order_id of table `prod_db`.`orders` trx id 312345679 lock_mode X locks rec but not gap waiting
Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; ...

*** WE ROLL BACK TRANSACTION (2)

步骤二:解读死锁日志

这份日志是破解死锁的关键。我们来逐段分析:

  1. 两个事务(TRANSACTION):事务1(312345678)和事务2(312345679)。
  2. 执行的SQL
    • 事务1UPDATE orders ... status = 'completed' ... WHERE order_id = 'ORDER10001' AND status = 'unpaid'
    • 事务2UPDATE orders ... status = 'cancelled' ... WHERE order_id = 'ORDER10001' AND status = 'unpaid' 它们都在更新同一条订单记录,且更新前状态都是unpaid
  3. 持有的锁(HOLDS THE LOCK)与等待的锁(WAITING FOR)
    • 事务1持有idx_order_id 索引上的X锁(排他锁),等待 PRIMARY 索引(主键索引)上的X锁。
    • 事务2持有PRIMARY 索引上的X锁,等待 idx_order_id 索引上的X锁。

经典!这就是一个典型的“循环等待”,构成了死锁的必要条件。 两个事务各自持有一个锁,又都在请求对方已经持有的锁,MySQL别无选择,只能牺牲其中一个(这里回滚了事务2)。

步骤三:分析加锁过程

为什么会出现这种循环等待?这需要结合表的索引设计和UPDATE语句的加锁机制来分析。

假设我们的orders表结构如下:

代码语言:sql
复制
CREATE TABLE `orders` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `order_id` varchar(32) NOT NULL COMMENT '业务订单号',
  `status` varchar(20) NOT NULL DEFAULT 'unpaid',
  `pay_time` datetime DEFAULT NULL,
  ...其他字段...
  PRIMARY KEY (`id`),            -- 聚簇索引(主键索引)
  UNIQUE KEY `uk_order_id` (`order_id`) -- 唯一二级索引
) ENGINE=InnoDB;

加锁流程推演:

  1. 事务1执行UPDATE:
    • MySQL首先会根据WHERE order_id = 'ORDER10001'条件,在唯一二级索引uk_order_id上进行查询。
    • 找到这条记录后,会立即对其加上X型的行锁(因为要更新)。
    • 然后,通过这颗二级索引记录上存储的主键值(id),回表主键索引(聚簇索引) 去定位真正的数据行。
    • 在回表过程中,尝试对主键索引上的对应记录加X锁
  2. 事务2执行UPDATE:
    • 几乎在同一时刻,事务2也通过uk_order_id索引找到了同一条记录。
    • 成功地对这条二级索引记录加上了X锁(因为此时事务1还没持有它,或者锁竞争机制允许)。
    • 同样,它开始回表,成功地对主键索引上的记录加上了X锁
  3. 死锁发生:
    • 此时,事务1持有uk_order_id的锁,正在等待PRIMARY的锁。
    • 事务2持有PRIMARY的锁,正在等待uk_order_id的锁。
    • 两个事务互相等待,形成环路,死锁诞生。

核心原因: 两个事务通过二级索引进行更新时,InnoDB的加锁顺序是先锁二级索引,再锁主键索引。在高并发下,这个细微的时间差窗口为死锁创造了条件。

三、解决方案及代码

明白了原理,解决方案就清晰了:消除加锁顺序带来的环路等待风险

方案一(首选):优化索引与查询条件,避免回表加锁

如果WHERE条件能直接用到主键,就可以直接对主键加锁,一步到位,避免先二级索引再主键索引的流程。

我们的业务代码中,其实在获取订单信息时,早已拿到了主键id,但在支付回调和取消逻辑中,图方便直接用了order_id来更新。这是一个常见的开发习惯陷阱。

修改前:

代码语言:java
复制
// 支付回调服务
@Transactional
public void onPaySuccess(String orderId) {
    // 直接使用order_id更新
    orderDao.updateStatusByOrderId(orderId, "unpaid", "completed");
}

// 订单取消服务
@Transactional
public void cancelOrder(String orderId) {
    orderDao.updateStatusByOrderId(orderId, "unpaid", "cancelled");
}

修改后:

代码语言:java
复制
// 支付回调服务
@Transactional
public void onPaySuccess(String orderId) {
    // 1. 先通过order_id查询出主键id和当前status(非必须,也可在后续update条件中直接使用status判断)
    Order order = orderDao.selectByOrderId(orderId);
    if (order != null && "unpaid".equals(order.getStatus())) {
        // 2. 使用主键id作为更新条件
        orderDao.updateStatusById(order.getId(), "unpaid", "completed");
    }
}

// OrderDao.xml中的SQL映射
<update id="updateStatusById">
    UPDATE orders
    SET status = #{newStatus},
        pay_time = NOW() <!-- 支付更新才有这个 -->
    WHERE id = #{id}
      AND status = #{oldStatus} <!-- 乐观锁思想,防止状态覆盖 -->
</update>

方案二(次选):使用悲观锁,提前锁定

在事务开始时,就使用SELECT ... FOR UPDATE通过主键锁定目标行。这样其他事务再想加锁就会被阻塞,而不是形成环路等待。这种方式并发性能会有损耗,需根据场景权衡。

代码语言:java
复制
@Transactional
public void onPaySuccess(String orderId) {
    // 1. 使用FOR UPDATE提前且明确地锁定主键记录
    Order order = orderDao.selectByOrderIdForUpdate(orderId);
    if (order != null && "unpaid".equals(order.getStatus())) {
        // 2. 再更新
        orderDao.updateStatusById(order.getId(), "unpaid", "completed");
    }
}
// SQL: SELECT * FROM orders WHERE order_id = #{orderId} FOR UPDATE

我们最终采用了方案一。因为它的改动量最小,且从根本上避免了加锁顺序问题,性能也更优。上线后,死锁警报消失。

四、避坑总结
  1. 死锁不是错误,而是现象:它是高并发系统在设计上存在竞争风险的体现。无需恐惧,但必须重视。
  2. 死锁日志是第一现场:一定要学会查看和解读 SHOW ENGINE INNODB STATUS 或错误日志中的死锁信息。MySQL 8.0的死锁日志可视化和信息详细度远超5.7,升级也是选项之一。
  3. 理解InnoDB加锁机制:这是解决复杂死锁问题的核心。务必理解不同索引、不同查询条件下的加锁顺序(二级索引 -> 主键索引)、锁类型(记录锁、间隙锁、临键锁)。
  4. 优先使用主键更新:在事务更新中,尽量使用主键作为WHERE条件。这可以简化加锁过程,减少不确定性。开发规范中应明确这一点。
  5. 保持事务简短:事务越长时间,持有锁的时间就越长,与其他事务发生冲突的概率就越大。快速提交事务是减少死锁的有效手段。
  6. 约定统一的访问顺序:如果业务上必须更新多个资源,在所有服务中约定一个统一的更新顺序(例如先更新A表再更新B表),可以从设计上避免循环等待。

这次排查经历再次证明,深入了解数据库内核原理,是后端开发者解决深度难题、编写高性能高并发代码的必经之路。希望这篇记录也能帮你填上未来可能遇到的一个大坑。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 技术环境
  • 一、Bug现象
  • 二、排查步骤
  • 三、解决方案及代码
  • 四、避坑总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档