死锁预防的10条军规

凌晨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的ID
  • WAITING 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

🎓 面试标准答案

题目:什么是死锁?如何避免?

答案

死锁定义:两个或多个事务互相等待对方释放锁,导致都无法继续执行。

经典场景

  1. 相反顺序加锁(A→B vs B→A)
  2. 间隙锁冲突(并发插入相同范围)
  3. 批量更新顺序不一致
  4. 索引失效导致锁升级

避免死锁的方法

  1. 统一加锁顺序
  2. 缩小事务范围
  3. 避免大事务
  4. 使用索引(避免锁升级)
  5. 降低隔离级别(RC没有间隙锁)
  6. 用乐观锁代替悲观锁
  7. 设置锁等待超时
  8. 及时提交事务

排查方法

  • SHOW ENGINE INNODB STATUS
  • 查看错误日志(innodb_print_all_deadlocks=ON
  • performance_schema.data_lock_waits

🎉 结束语

早上7点,哈吉米终于修复了死锁问题。

哈吉米:"原来是转账业务相反顺序加锁导致的死锁!"

南北绿豆:"对,改成统一按user_id排序加锁,就不会死锁了。"

阿西噶阿西:"记住:统一加锁顺序、缩小事务范围、使用索引,这三条最重要。"

哈吉米:"还有要监控死锁次数,定期分析高频死锁SQL。"

南北绿豆:"对,预防永远比解决重要!"


记忆口诀

死锁本质互等待,相反顺序是祸根
统一加锁按顺序,缩小事务减范围
间隙锁防幻读好,并发插入易死锁
索引失效锁升级,乐观锁来替悲观


希望这篇文章能帮你理解死锁的原理和避免方法!记住:预防死锁比解决死锁重要,统一加锁顺序是关键!💪