凌晨3点,我把公司数据库锁死了
摘要:从一次"转账业务导致全站卡死"的线上故障出发,深度剖析MySQL死锁的产生原理与解决方案。通过4个经典死锁场景的真实案例、锁类型详解(表锁/行锁/间隙锁/Next-Key Lock)、以及SHOW ENGINE INNODB STATUS的排查技巧,揭秘为什么两个事务会互相等待、间隙锁如何防止幻读、以及如何避免死锁。配合时序图展示加锁过程,给出死锁预防的10条军规。
💥 翻车现场
凌晨3点,哈吉米被电话吵醒。
运维同学(焦急):"哈吉米!数据库锁死了!所有订单接口都超时!"
哈吉米(迷糊):"啥???"
运维同学:"快看钉钉!CEO都在群里了!"
哈吉米一个激灵爬起来,打开监控:
告警:
- MySQL活跃连接数:500(全部卡住)
- 订单接口响应时间:超时
- 转账接口响应时间:超时
- 用户反馈:支付卡死
紧急查看数据库:
SHOW PROCESSLIST;
+-----+------+-------------------+------+---------+------+--------------+
| Id | User | Host | db | Command | Time | State |
+-----+------+-------------------+------+---------+------+--------------+
| 123 | app | 192.168.1.10:3306 | shop | Query | 185 | updating |
| 124 | app | 192.168.1.10:3306 | shop | Query | 185 | updating |
| 125 | app | 192.168.1.10:3306 | shop | Query | 183 | updating |
... (500个连接都在等待)
哈吉米:"卧槽,全在等锁!"
紧急重启数据库后,查看日志发现:
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 123, OS thread handle 140234567890, query id 5678 updating
UPDATE account SET balance = balance - 100 WHERE user_id = 10086
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 124, OS thread handle 140234567891, query id 5679 updating
UPDATE account SET balance = balance - 100 WHERE user_id = 10087
*** WE ROLL BACK TRANSACTION (2)
哈吉米:"死锁了!两个转账事务互相等待!"
第二天早上,南北绿豆和阿西噶阿西来了。
南北绿豆:"典型的死锁场景!两个事务相反顺序加锁,互相等待。"
哈吉米:"怎么会这样?我加了事务啊!"
阿西噶阿西:"事务不能防止死锁,反而是死锁的前提!来,我给你讲讲死锁的4种经典场景。"
🤔 死锁场景1:相反顺序加锁(最经典)
场景重现
业务场景:A给B转账100元,同时B给A转账50元。
事务A(A→B转账100):
START TRANSACTION;
-- 步骤1:锁住A的账户
UPDATE account SET balance = balance - 100 WHERE user_id = 'A'; -- 持有A的锁
-- 步骤2:锁住B的账户
UPDATE account SET balance = balance + 100 WHERE user_id = 'B'; -- 等待B的锁
COMMIT;
事务B(B→A转账50):
START TRANSACTION;
-- 步骤1:锁住B的账户
UPDATE account SET balance = balance - 50 WHERE user_id = 'B'; -- 持有B的锁
-- 步骤2:锁住A的账户
UPDATE account SET balance = balance + 50 WHERE user_id = 'A'; -- 等待A的锁
COMMIT;
死锁时序图
sequenceDiagram
participant TxnA as 事务A(A→B转100)
participant LockA as A账户的锁
participant LockB as B账户的锁
participant TxnB as 事务B(B→A转50)
Note over TxnA,TxnB: T1时刻
TxnA->>LockA: 1. UPDATE WHERE user_id='A'<br/>获取A的锁 ✅
TxnB->>LockB: 1. UPDATE WHERE user_id='B'<br/>获取B的锁 ✅
Note over TxnA,TxnB: T2时刻
TxnA->>LockB: 2. UPDATE WHERE user_id='B'<br/>请求B的锁...
Note over TxnA,LockB: 等待中(B的锁被事务B持有)
TxnB->>LockA: 2. UPDATE WHERE user_id='A'<br/>请求A的锁...
Note over TxnB,LockA: 等待中(A的锁被事务A持有)
Note over TxnA,TxnB: 死锁!<br/>事务A等事务B释放锁<br/>事务B等事务A释放锁
rect rgb(255, 182, 193)
Note over TxnA,TxnB: MySQL检测到死锁<br/>回滚其中一个事务
end
哈吉米:"所以两个事务互相等待对方释放锁,就死锁了?"
南北绿豆:"对!这是最经典的死锁场景。"
解决方案
方案1:统一加锁顺序
@Transactional
public void transfer(String fromUserId, String toUserId, BigDecimal amount) {
// 统一按user_id排序加锁
String firstUser = fromUserId.compareTo(toUserId) < 0 ? fromUserId : toUserId;
String secondUser = fromUserId.compareTo(toUserId) < 0 ? toUserId : fromUserId;
// 先锁小的user_id,再锁大的user_id
Account first = accountMapper.selectByIdForUpdate(firstUser);
Account second = accountMapper.selectByIdForUpdate(secondUser);
// 业务逻辑
if (fromUserId.equals(firstUser)) {
first.setBalance(first.getBalance().subtract(amount));
second.setBalance(second.getBalance().add(amount));
} else {
second.setBalance(second.getBalance().subtract(amount));
first.setBalance(first.getBalance().add(amount));
}
accountMapper.updateById(first);
accountMapper.updateById(second);
}
关键:保证所有事务按相同顺序加锁(从小到大)。
方案2:用乐观锁代替悲观锁
-- 不用SELECT FOR UPDATE,用版本号
UPDATE account
SET balance = balance - 100, version = version + 1
WHERE user_id = 'A' AND version = #{oldVersion};
优点:不加锁,不会死锁
🤔 死锁场景2:间隙锁导致的死锁
场景重现
业务场景:两个事务同时插入相同范围的数据。
-- 表数据
SELECT * FROM account;
+----+---------+
| id | balance |
+----+---------+
| 5 | 1000 |
| 10 | 2000 |
+----+---------+
事务A:
START TRANSACTION;
-- 步骤1:插入id=7
INSERT INTO account (id, balance) VALUES (7, 500);
-- 获取间隙锁:(5, 10)
-- 等待事务B...
事务B(同时执行):
START TRANSACTION;
-- 步骤1:插入id=8
INSERT INTO account (id, balance) VALUES (8, 600);
-- 请求间隙锁:(5, 10),但事务A已经持有了
-- 等待事务A释放间隙锁...
结果:
- 事务A持有间隙锁(5, 10),等待插入id=7
- 事务B等待间隙锁(5, 10),想要插入id=8
- 但MySQL的间隙锁是共享的,两个事务都能获取间隙锁
- 但插入时需要插入意向锁,互相冲突
- 死锁!
间隙锁死锁时序图
sequenceDiagram
participant TxnA as 事务A
participant Gap as 间隙锁(5,10)
participant TxnB as 事务B
TxnA->>Gap: 1. INSERT id=7<br/>获取间隙锁(5,10) ✅
TxnB->>Gap: 1. INSERT id=8<br/>获取间隙锁(5,10) ✅
Note over TxnA,TxnB: 都获取了间隙锁(共享)
TxnA->>Gap: 2. 请求插入意向锁(排他)
Note over TxnA: 等待事务B释放间隙锁
TxnB->>Gap: 2. 请求插入意向锁(排他)
Note over TxnB: 等待事务A释放间隙锁
rect rgb(255, 182, 193)
Note over TxnA,TxnB: 死锁!
end
解决方案
方案1:降低隔离级别
-- 改成READ COMMITTED(没有间隙锁)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
缺点:失去了可重复读的保证。
方案2:业务上避免并发插入相同范围
// 用Redis分布式锁
RLock lock = redissonClient.getLock("insert:account:5-10");
try {
lock.lock();
// 插入数据
accountMapper.insert(account);
} finally {
lock.unlock();
}
🤔 死锁场景3:批量更新顺序不一致
场景重现
业务场景:批量更新订单状态。
事务A:
START TRANSACTION;
UPDATE `order` SET status = 1 WHERE order_id = 100; -- 锁住100
UPDATE `order` SET status = 1 WHERE order_id = 200; -- 锁住200
COMMIT;
事务B(同时执行):
START TRANSACTION;
UPDATE `order` SET status = 1 WHERE order_id = 200; -- 锁住200
UPDATE `order` SET status = 1 WHERE order_id = 100; -- 等待100(事务A持有)
-- 死锁!
解决方案
统一更新顺序:
@Transactional
public void batchUpdate(List<Long> orderIds) {
// 按order_id排序(统一顺序)
Collections.sort(orderIds);
for (Long orderId : orderIds) {
orderMapper.updateStatus(orderId, 1);
}
}
🤔 死锁场景4:索引失效导致锁升级
场景重现
-- 表结构
CREATE TABLE `order` (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32),
user_id BIGINT,
INDEX idx_order_no(order_no)
);
事务A:
START TRANSACTION;
-- 更新订单(假设order_no是VARCHAR,但传了数字)
UPDATE `order` SET status = 1 WHERE order_no = 100001; -- 索引失效,锁整张表
-- 等待...
事务B(同时执行):
START TRANSACTION;
-- 更新另一个订单
UPDATE `order` SET status = 1 WHERE order_no = '200001'; -- 等待表锁
-- 死锁!
原因:
- 事务A的
order_no = 100001(数字)导致索引失效 - InnoDB退化成全表扫描,锁住了整张表
- 事务B无法获取任何行锁
解决方案
确保索引生效:
-- ✅ 正确写法(加引号)
UPDATE `order` SET status = 1 WHERE order_no = '100001';
🔍 锁的类型详解
南北绿豆:"要理解死锁,先搞清楚InnoDB的锁类型。"
锁的分类
| 锁类型 | 锁定范围 | 作用 |
|---|---|---|
| 表锁 | 整张表 | 并发度最低 |
| 行锁 | 单行记录 | 并发度高 |
| 间隙锁(Gap Lock) | 记录之间的间隙 | 防止插入(防幻读) |
| Next-Key Lock | 行锁 + 间隙锁 | RR隔离级别默认 |
| 插入意向锁 | 插入时的特殊锁 | 允许并发插入不同位置 |
行锁(Record Lock)
-- 锁定单行
UPDATE account SET balance = 900 WHERE user_id = 10086;
-- 加锁:锁住user_id=10086这一行
时序图:
sequenceDiagram
participant TxnA as 事务A
participant Row as user_id=10086的行
participant TxnB as 事务B
TxnA->>Row: UPDATE WHERE user_id=10086<br/>获取行锁 ✅
TxnB->>Row: UPDATE WHERE user_id=10086<br/>请求行锁...
Note over TxnB: 等待事务A释放锁
TxnA->>TxnA: COMMIT(释放锁)
Row->>TxnB: 获取行锁 ✅
TxnB->>TxnB: 执行UPDATE
间隙锁(Gap Lock)
作用:锁住记录之间的"间隙",防止其他事务插入数据(防止幻读)。
-- 表数据
SELECT id FROM account;
+----+
| id |
+----+
| 5 |
| 10 |
| 15 |
+----+
-- 事务A执行范围查询
SELECT * FROM account WHERE id > 5 AND id < 15 FOR UPDATE;
-- 加锁范围(Next-Key Lock):
(5, 10] ← 间隙锁 + 行锁
(10, 15) ← 间隙锁
锁定的间隙:
(-∞, 5] 不锁
(5, 10] 锁(间隙 + id=10的行)
(10, 15) 锁(只锁间隙)
[15, +∞) 不锁
效果:
-- 事务B尝试插入
INSERT INTO account (id, balance) VALUES (7, 500); -- 阻塞(在间隙5-10内)
INSERT INTO account (id, balance) VALUES (12, 500); -- 阻塞(在间隙10-15内)
INSERT INTO account (id, balance) VALUES (20, 500); -- 成功(不在间隙内)
阿西噶阿西:"间隙锁是RR隔离级别防止幻读的关键!"
🛠️ 如何排查死锁?
方法1:SHOW ENGINE INNODB STATUS
SHOW ENGINE INNODB STATUS\G
-- 查看LATEST DETECTED DEADLOCK部分
LATEST DETECTED DEADLOCK
------------------------
2024-10-07 03:15:23 0x7f8b8c001700
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 123, query id 5678 updating
UPDATE account SET balance = balance - 100 WHERE user_id = 10086
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `shop`.`account`
trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 5 PHYSICAL RECORD: n_fields 5;
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 124, query id 5679 updating
UPDATE account SET balance = balance - 100 WHERE user_id = 10087
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `shop`.`account`
trx id 12346 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `shop`.`account`
trx id 12346 lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (2)
关键信息:
TRANSACTION 12345:事务A的IDWAITING FOR THIS LOCK TO BE GRANTED:等待的锁HOLDS THE LOCK(S):持有的锁WE ROLL BACK TRANSACTION (2):MySQL回滚了事务2
方法2:开启死锁日志
-- 开启InnoDB监控
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 死锁信息会写入错误日志
-- 查看错误日志位置
SHOW VARIABLES LIKE 'log_error';
方法3:查看当前锁等待
-- 查看当前锁等待(MySQL 8.0)
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
-- MySQL 5.7
SELECT * FROM information_schema.INNODB_LOCKS;
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
🛡️ 死锁预防的10条军规
阿西噶阿西:"总结一下,如何避免死锁?"
军规1:统一加锁顺序
// ✅ 按ID排序后加锁
List<Long> ids = Arrays.asList(100L, 50L, 200L);
Collections.sort(ids); // 排序:50, 100, 200
for (Long id : ids) {
accountMapper.selectByIdForUpdate(id); // 统一从小到大加锁
}
军规2:缩小事务范围
// ❌ 错误:事务太大
@Transactional
public void processOrder(Order order) {
// 查询用户信息(慢)
User user = userService.getUser(order.getUserId());
// 发送短信(慢,2秒)
smsService.send(user.getPhone(), "订单创建成功");
// 更新订单
orderMapper.updateStatus(order.getId(), 1);
} // 事务持有2秒,锁等待严重
// ✅ 正确:缩小事务
public void processOrder(Order order) {
User user = userService.getUser(order.getUserId()); // 事务外
smsService.send(user.getPhone(), "订单创建成功"); // 事务外
// 只有核心操作在事务内
updateOrderInTransaction(order.getId());
}
@Transactional
private void updateOrderInTransaction(Long orderId) {
orderMapper.updateStatus(orderId, 1);
} // 事务只持有0.01秒
军规3:避免大事务
-- ❌ 错误
UPDATE `order` SET status = 1; -- 更新100万行,锁表几分钟
-- ✅ 正确(分批)
UPDATE `order` SET status = 1 LIMIT 1000; -- 循环执行
军规4:使用索引避免锁升级
-- ❌ 错误(索引失效,锁整张表)
UPDATE `order` SET status = 1 WHERE order_no = 100001; -- 类型不匹配
-- ✅ 正确(走索引,只锁一行)
UPDATE `order` SET status = 1 WHERE order_no = '100001';
军规5:降低隔离级别(谨慎)
-- RR隔离级别有间隙锁,容易死锁
-- 改成RC(没有间隙锁)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
注意:失去可重复读的保证。
军规6:用乐观锁代替悲观锁
-- 悲观锁(容易死锁)
SELECT * FROM account WHERE id = 1 FOR UPDATE;
-- 乐观锁(不会死锁)
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = #{oldVersion};
军规7:合理设置锁等待超时
-- 查看锁等待超时时间
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
+----------------------------+-------+
| Variable_name | Value |
+----------------------------+-------+
| innodb_lock_wait_timeout | 50 | -- 默认50秒
+----------------------------+-------+
-- 设置超时时间(避免长时间等待)
SET GLOBAL innodb_lock_wait_timeout = 10; -- 10秒
军规8:及时提交或回滚事务
// ❌ 错误(长时间不提交)
@Transactional
public void processOrder(Order order) {
orderMapper.insert(order);
// 调用外部接口,可能很慢
externalService.notify(order); // 5秒
} // 事务持有5秒
// ✅ 正确
public void processOrder(Order order) {
Long orderId = createOrderInTransaction(order); // 事务内
externalService.notify(order); // 事务外
}
@Transactional
private Long createOrderInTransaction(Order order) {
orderMapper.insert(order);
return order.getId();
}
军规9:避免在事务中进行用户交互
// ❌ 错误
@Transactional
public void processPayment(Long orderId) {
// 锁住订单
Order order = orderMapper.selectByIdForUpdate(orderId);
// 调用支付接口(可能需要用户输入密码,几分钟)
paymentService.pay(order); // 事务持有几分钟
}
// ✅ 正确
public void processPayment(Long orderId) {
// 先调用支付(事务外)
PaymentResult result = paymentService.pay(orderId);
// 支付成功再更新订单(事务内)
if (result.isSuccess()) {
updateOrderInTransaction(orderId);
}
}
军规🔟:监控死锁,定期分析
-- 查看死锁次数
SHOW STATUS LIKE 'Innodb_deadlocks';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| Innodb_deadlocks | 127 | ← 累计死锁次数
+------------------+-------+
-- 定期分析死锁日志,找出高频死锁SQL
🎓 面试标准答案
题目:什么是死锁?如何避免?
答案:
死锁定义:两个或多个事务互相等待对方释放锁,导致都无法继续执行。
经典场景:
- 相反顺序加锁(A→B vs B→A)
- 间隙锁冲突(并发插入相同范围)
- 批量更新顺序不一致
- 索引失效导致锁升级
避免死锁的方法:
- 统一加锁顺序
- 缩小事务范围
- 避免大事务
- 使用索引(避免锁升级)
- 降低隔离级别(RC没有间隙锁)
- 用乐观锁代替悲观锁
- 设置锁等待超时
- 及时提交事务
排查方法:
SHOW ENGINE INNODB STATUS- 查看错误日志(
innodb_print_all_deadlocks=ON) performance_schema.data_lock_waits
🎉 结束语
早上7点,哈吉米终于修复了死锁问题。
哈吉米:"原来是转账业务相反顺序加锁导致的死锁!"
南北绿豆:"对,改成统一按user_id排序加锁,就不会死锁了。"
阿西噶阿西:"记住:统一加锁顺序、缩小事务范围、使用索引,这三条最重要。"
哈吉米:"还有要监控死锁次数,定期分析高频死锁SQL。"
南北绿豆:"对,预防永远比解决重要!"
记忆口诀:
死锁本质互等待,相反顺序是祸根
统一加锁按顺序,缩小事务减范围
间隙锁防幻读好,并发插入易死锁
索引失效锁升级,乐观锁来替悲观
希望这篇文章能帮你理解死锁的原理和避免方法!记住:预防死锁比解决死锁重要,统一加锁顺序是关键!💪