
作为一名后端开发者,我坚信你不会对Deadlock found when trying to get lock; try restarting transaction这个错误感到陌生。它就像一场突如其来的暴雨,总在你系统并发量最高的时候倾盆而下。今天,我就分享一个在电商场景中遇到的、极具代表性的死锁案例,带你深入MySQL的加锁迷雾。
线上警报突然响起,监控平台显示数据库出现少量死锁错误,频率不高但持续存在。主要发生在订单表的更新操作上。用户的反馈是:“偶尔提示支付失败,但银行卡的钱已经扣了”,这是一个非常危险的信号——出现了数据一致性问题。
面对死锁,千万不要慌。MySQL已经为我们提供了强大的诊断工具。
步骤一:开启并查看死锁日志
首先,我们确认InnoDB的监控状态是开启的(默认通常是开启的):
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';如果未开启,可以临时动态设置(重启后会失效):
SET GLOBAL innodb_print_all_deadlocks = ON;当死锁发生时,MySQL会将完整的死锁信息输出到错误日志(Error Log) 中。这是我们排查的唯一线索。
我们登录服务器,找到错误日志,检索“DEADLOCK”关键字,找到了如下日志(注:这是简化整理后的核心内容):
------------------------
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)步骤二:解读死锁日志
这份日志是破解死锁的关键。我们来逐段分析:
UPDATE orders ... status = 'completed' ... WHERE order_id = 'ORDER10001' AND status = 'unpaid'UPDATE orders ... status = 'cancelled' ... WHERE order_id = 'ORDER10001' AND status = 'unpaid'
它们都在更新同一条订单记录,且更新前状态都是unpaid。idx_order_id 索引上的X锁(排他锁),等待 PRIMARY 索引(主键索引)上的X锁。PRIMARY 索引上的X锁,等待 idx_order_id 索引上的X锁。经典!这就是一个典型的“循环等待”,构成了死锁的必要条件。 两个事务各自持有一个锁,又都在请求对方已经持有的锁,MySQL别无选择,只能牺牲其中一个(这里回滚了事务2)。
步骤三:分析加锁过程
为什么会出现这种循环等待?这需要结合表的索引设计和UPDATE语句的加锁机制来分析。
假设我们的orders表结构如下:
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;加锁流程推演:
WHERE order_id = 'ORDER10001'条件,在唯一二级索引uk_order_id上进行查询。id),回表到主键索引(聚簇索引) 去定位真正的数据行。uk_order_id索引找到了同一条记录。uk_order_id的锁,正在等待PRIMARY的锁。PRIMARY的锁,正在等待uk_order_id的锁。核心原因: 两个事务通过二级索引进行更新时,InnoDB的加锁顺序是先锁二级索引,再锁主键索引。在高并发下,这个细微的时间差窗口为死锁创造了条件。
明白了原理,解决方案就清晰了:消除加锁顺序带来的环路等待风险。
方案一(首选):优化索引与查询条件,避免回表加锁
如果WHERE条件能直接用到主键,就可以直接对主键加锁,一步到位,避免先二级索引再主键索引的流程。
我们的业务代码中,其实在获取订单信息时,早已拿到了主键id,但在支付回调和取消逻辑中,图方便直接用了order_id来更新。这是一个常见的开发习惯陷阱。
修改前:
// 支付回调服务
@Transactional
public void onPaySuccess(String orderId) {
// 直接使用order_id更新
orderDao.updateStatusByOrderId(orderId, "unpaid", "completed");
}
// 订单取消服务
@Transactional
public void cancelOrder(String orderId) {
orderDao.updateStatusByOrderId(orderId, "unpaid", "cancelled");
}修改后:
// 支付回调服务
@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通过主键锁定目标行。这样其他事务再想加锁就会被阻塞,而不是形成环路等待。这种方式并发性能会有损耗,需根据场景权衡。
@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我们最终采用了方案一。因为它的改动量最小,且从根本上避免了加锁顺序问题,性能也更优。上线后,死锁警报消失。
SHOW ENGINE INNODB STATUS 或错误日志中的死锁信息。MySQL 8.0的死锁日志可视化和信息详细度远超5.7,升级也是选项之一。WHERE条件。这可以简化加锁过程,减少不确定性。开发规范中应明确这一点。这次排查经历再次证明,深入了解数据库内核原理,是后端开发者解决深度难题、编写高性能高并发代码的必经之路。希望这篇记录也能帮你填上未来可能遇到的一个大坑。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。