MySQL死锁检测与破解之道 🔒

21 阅读12分钟

一、开篇故事:两个人的相互等待 🚪

想象两个人在两间房间里:

经典死锁场景

房间A(锁着):里面有钥匙B
房间B(锁着):里面有钥匙A

张三:
  1. 拿到钥匙A,进入房间A
  2. 想进房间B,需要钥匙B
  3. 但钥匙B在房间B里...
  4. 等待李四释放房间B ⏳

李四:
  1. 拿到钥匙B,进入房间B
  2. 想进房间A,需要钥匙A
  3. 但钥匙A在房间A里...
  4. 等待张三释放房间A ⏳

结果:
  → 张三等李四
  → 李四等张三
  → 互相等待,永远无法完成!
  → 这就是死锁!💀

二、什么是死锁? 💀

2.1 死锁定义

两个或多个事务相互持有对方需要的锁,导致所有事务都无法继续执行。

2.2 死锁四个必要条件

1. 互斥条件(Mutual Exclusion)
   → 资源只能被一个事务占用

2. 持有并等待(Hold and Wait)
   → 事务持有资源,同时等待其他资源

3. 不可剥夺(No Preemption)
   → 资源不能被强制剥夺,只能主动释放

4. 循环等待(Circular Wait)
   → 形成环形等待链
   
四个条件同时满足 → 死锁!

2.3 经典死锁案例

案例1:两个事务互相等待

-- 初始数据
CREATE TABLE account (
    id INT PRIMARY KEY,
    balance DECIMAL(10,2)
);

INSERT INTO account VALUES (1, 100), (2, 200);

-- 时间线:
时刻    事务A                           事务B
------------------------------------------------------------
T1     START TRANSACTION;              START TRANSACTION;

T2     UPDATE account                  UPDATE account
       SET balance = balance - 10      SET balance = balance - 20
       WHERE id = 1;                   WHERE id = 2;
       ✅ 获得id=1的锁                  ✅ 获得id=2的锁

T3     UPDATE account                  UPDATE account
       SET balance = balance + 10      SET balance = balance + 20
       WHERE id = 2;                   WHERE id = 1;
       ⏳ 等待id=2的锁(被B持有)        ⏳ 等待id=1的锁(被A持有)

T4     💀 死锁!MySQL检测到死锁,回滚其中一个事务

死锁分析:

事务A:持有锁1,等待锁2
事务B:持有锁2,等待锁1
→ 循环等待
→ 死锁!

案例2:索引导致的死锁

-- 表结构
CREATE TABLE orders (
    id INT PRIMARY KEY,
    user_id INT,
    amount DECIMAL(10,2),
    KEY idx_user (user_id)
);

-- 初始数据
INSERT INTO orders VALUES 
(1, 100, 50),
(2, 100, 80),
(3, 200, 100);

-- 事务A
START TRANSACTION;
DELETE FROM orders WHERE user_id = 100;
-- 1. 在idx_user索引上加锁:锁住user_id=100的范围
-- 2. 回表加锁:锁住id=1, id=2的记录

-- 事务B(同时执行)
START TRANSACTION;
DELETE FROM orders WHERE id = 2;
-- ⏳ 等待id=2的锁(被A持有)

-- 事务A继续
DELETE FROM orders WHERE id = 1;
-- ⏳ 等待id=1的锁
-- 但此时B也在等待...
-- 💀 死锁!

案例3:Gap Lock导致的死锁

-- 表结构(id不连续)
CREATE TABLE test (
    id INT PRIMARY KEY,
    value VARCHAR(50)
);

INSERT INTO test VALUES (1, 'a'), (5, 'b'), (10, 'c');

-- 隔离级别:RR(可重复读)

-- 事务A
START TRANSACTION;
SELECT * FROM test WHERE id = 3 FOR UPDATE;
-- id=3不存在,加Gap Lock:(1, 5)

-- 事务B
START TRANSACTION;
SELECT * FROM test WHERE id = 4 FOR UPDATE;
-- id=4不存在,加Gap Lock:(1, 5)

-- 事务A继续
INSERT INTO test VALUES (3, 'd');
-- ⏳ 等待Gap Lock释放(被B持有)

-- 事务B继续
INSERT INTO test VALUES (4, 'e');
-- ⏳ 等待Gap Lock释放(被A持有)

-- 💀 死锁!

三、MySQL如何检测死锁?🔍

3.1 死锁检测算法:等待图(Wait-for Graph)

原理: 构建事务等待关系的有向图,如果存在环,则存在死锁。

事务A → 事务B → 事务C → 事务A
        ↑                 ↓
        +←←←←←←←←←←←←←←←←+
        
这是一个环 → 死锁!

3.2 检测过程

1. MySQL定期检测(每次加锁时检测)
2. 构建等待图:
   - 节点:事务
   - 边:A等待B → A→B

3. 检测环:
   - 深度优先搜索(DFS)
   - 发现环 → 死锁

4. 选择牺牲者:
   - 回滚代价最小的事务(undo量最少)
   - 返回错误:ERROR 1213 (40001): Deadlock found when trying to get lock

3.3 死锁检测配置

-- 查看死锁检测是否开启(默认开启)
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- innodb_deadlock_detect = ON

-- 关闭死锁检测(不推荐)
SET GLOBAL innodb_deadlock_detect = OFF;

-- 如果关闭,死锁会等到锁超时
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- innodb_lock_wait_timeout = 50(秒)

四、如何查看死锁信息?📊

4.1 查看最近一次死锁

SHOW ENGINE INNODB STATUS;

输出示例:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-01-15 10:30:45 0x7f8c

*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 5 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 10, OS thread handle 140241234567, query id 100 localhost root updating

UPDATE account SET balance = balance + 10 WHERE id = 2

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`account` trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 4; hex 00000002; asc     ;;  ← id = 2
 1: len 6; hex 000000003039; asc     09;;
 2: len 7; hex 27000001410110; asc '   A  ;;
 3: len 8; hex 00000000000000c8; asc         ;;

*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 3 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 11, OS thread handle 140241234568, query id 101 localhost root updating

UPDATE account SET balance = balance + 20 WHERE id = 1

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`account` trx id 12346 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 4; hex 00000002; asc     ;;  ← 持有id=2的锁
 1: len 6; hex 000000003039; asc     09;;
 2: len 7; hex 27000001410110; asc '   A  ;;
 3: len 8; hex 00000000000000c8; asc         ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`account` trx id 12346 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 4; hex 00000001; asc     ;;  ← 等待id=1的锁
 1: len 6; hex 000000003038; asc     08;;
 2: len 7; hex 1f000001400110; asc     @  ;;
 3: len 8; hex 0000000000000064; asc        d;;

*** WE ROLL BACK TRANSACTION (2)  ← MySQL选择回滚事务2

分析:

1. 事务1(12345):
   - 持有id=1的锁
   - 等待id=2的锁

2. 事务2(12346):
   - 持有id=2的锁
   - 等待id=1的锁

3. 结果:
   - MySQL检测到死锁
   - 选择回滚事务2(代价较小)

4.2 死锁日志记录

-- 开启死锁日志
SET GLOBAL innodb_print_all_deadlocks = ON;

-- 死锁信息会记录在MySQL错误日志中
-- 位置:/var/log/mysql/error.log

五、如何避免死锁?🛡️

5.1 按相同顺序访问资源 ⭐⭐⭐⭐⭐

问题

-- 事务A
UPDATE account SET balance = balance - 10 WHERE id = 1;  -- 先锁1
UPDATE account SET balance = balance + 10 WHERE id = 2;  -- 后锁2

-- 事务B
UPDATE account SET balance = balance - 20 WHERE id = 2;  -- 先锁2
UPDATE account SET balance = balance + 20 WHERE id = 1;  -- 后锁1

-- 💀 死锁!

解决方案

-- 统一顺序:总是按id从小到大的顺序加锁

-- 事务A
UPDATE account SET balance = balance - 10 WHERE id = 1;  -- 先锁1
UPDATE account SET balance = balance + 10 WHERE id = 2;  -- 后锁2

-- 事务B
UPDATE account SET balance = balance + 20 WHERE id = 1;  -- 先锁1
UPDATE account SET balance = balance - 20 WHERE id = 2;  -- 后锁2

-- ✅ 不会死锁!

代码实现:

public void transfer(Long fromId, Long toId, BigDecimal amount) {
    // 按id大小排序,确保加锁顺序一致
    Long firstId = fromId < toId ? fromId : toId;
    Long secondId = fromId < toId ? toId : fromId;
    
    // 先锁小id,后锁大id
    Account first = accountMapper.selectForUpdate(firstId);
    Account second = accountMapper.selectForUpdate(secondId);
    
    // 执行转账逻辑
    if (fromId.equals(firstId)) {
        first.setBalance(first.getBalance().subtract(amount));
        second.setBalance(second.getBalance().add(amount));
    } else {
        first.setBalance(first.getBalance().add(amount));
        second.setBalance(second.getBalance().subtract(amount));
    }
    
    accountMapper.updateById(first);
    accountMapper.updateById(second);
}

5.2 减少事务持有锁的时间 ⭐⭐⭐⭐

问题

@Transactional
public void processOrder(Long orderId) {
    // 1. 查询订单(加锁)
    Order order = orderMapper.selectForUpdate(orderId);
    
    // 2. 复杂业务逻辑(耗时操作)
    calculateDiscount(order);      // 100ms
    checkInventory(order);         // 200ms
    sendNotification(order);       // 500ms  ← 持有锁的时间太长!
    
    // 3. 更新订单
    orderMapper.updateById(order);
}

解决方案

public void processOrder(Long orderId) {
    // 1. 复杂业务逻辑放在事务外
    Order order = orderMapper.selectById(orderId);  // 不加锁
    calculateDiscount(order);
    checkInventory(order);
    
    // 2. 只在更新时加锁
    updateOrderInTransaction(order);
    
    // 3. 耗时操作放在事务外
    sendNotification(order);  // 异步发送
}

@Transactional
public void updateOrderInTransaction(Order order) {
    // 只在这里加锁,持有锁的时间很短
    orderMapper.updateById(order);
}

5.3 使用较低的隔离级别 ⭐⭐⭐

-- 问题:RR级别有Gap Lock,容易死锁
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

START TRANSACTION;
SELECT * FROM test WHERE id = 3 FOR UPDATE;  -- Gap Lock: (1, 5)

-- 解决方案:改为RC级别(无Gap Lock)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

START TRANSACTION;
SELECT * FROM test WHERE id = 3 FOR UPDATE;  -- 只锁id=3(如果存在)

注意: RC级别无法解决幻读问题,需要权衡。


5.4 使用乐观锁代替悲观锁 ⭐⭐⭐⭐

悲观锁(易死锁)

@Transactional
public void updateStock(Long productId, int quantity) {
    // SELECT ... FOR UPDATE(加锁)
    Product product = productMapper.selectForUpdate(productId);
    
    if (product.getStock() >= quantity) {
        product.setStock(product.getStock() - quantity);
        productMapper.updateById(product);
    }
}

乐观锁(不易死锁)

@Transactional
public void updateStock(Long productId, int quantity) {
    Product product = productMapper.selectById(productId);  // 不加锁
    
    if (product.getStock() >= quantity) {
        // UPDATE ... WHERE id = ? AND version = ?(乐观锁)
        int rows = productMapper.updateWithVersion(
            productId, 
            product.getStock() - quantity,
            product.getVersion()
        );
        
        if (rows == 0) {
            throw new OptimisticLockException("版本冲突,请重试");
        }
    }
}
-- SQL
UPDATE products 
SET stock = stock - #{quantity}, 
    version = version + 1
WHERE id = #{id} 
  AND version = #{version}
  AND stock >= #{quantity};

5.5 大事务拆分小事务 ⭐⭐⭐⭐

问题

@Transactional
public void batchProcess(List<Long> orderIds) {
    // 一次性处理1000个订单
    for (Long orderId : orderIds) {
        Order order = orderMapper.selectForUpdate(orderId);
        // 处理逻辑...
        orderMapper.updateById(order);
    }
    // ❌ 事务太大,持有锁太多,容易死锁
}

解决方案

public void batchProcess(List<Long> orderIds) {
    // 分批处理,每批100个
    int batchSize = 100;
    for (int i = 0; i < orderIds.size(); i += batchSize) {
        int end = Math.min(i + batchSize, orderIds.size());
        List<Long> batch = orderIds.subList(i, end);
        
        processBatch(batch);  // 每批一个事务
    }
}

@Transactional
public void processBatch(List<Long> batch) {
    for (Long orderId : batch) {
        Order order = orderMapper.selectForUpdate(orderId);
        // 处理逻辑...
        orderMapper.updateById(order);
    }
}

5.6 添加合适的索引 ⭐⭐⭐⭐⭐

问题

-- 表结构
CREATE TABLE orders (
    id INT PRIMARY KEY,
    user_id INT,  -- 无索引
    status INT
);

-- 事务A
UPDATE orders SET status = 1 WHERE user_id = 100;
-- 没有user_id索引,锁住整个表!

-- 事务B
UPDATE orders SET status = 2 WHERE user_id = 200;
-- 也锁整个表
-- 💀 容易死锁!

解决方案

-- 添加索引
CREATE INDEX idx_user_id ON orders(user_id);

-- 现在只锁相关行,不会死锁 ✅

六、死锁处理策略 🚑

6.1 自动处理(MySQL默认)

try {
    // 执行数据库操作
    transferMoney(fromId, toId, amount);
    
} catch (DeadlockLoserDataAccessException e) {
    // MySQL检测到死锁,自动回滚了事务
    log.error("死锁发生,事务已回滚", e);
    
    // 重试(最多3次)
    for (int i = 0; i < 3; i++) {
        try {
            Thread.sleep(100);  // 等待一下
            transferMoney(fromId, toId, amount);
            return;  // 成功
        } catch (DeadlockLoserDataAccessException retry) {
            if (i == 2) {
                throw new BusinessException("操作失败,请稍后重试");
            }
        }
    }
}

6.2 超时处理

-- 设置锁等待超时(默认50秒)
SET innodb_lock_wait_timeout = 10;  -- 改为10秒

-- 如果10秒还拿不到锁,自动超时
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
@Transactional(timeout = 10)  // Spring事务超时
public void processOrder(Long orderId) {
    // 如果10秒未完成,自动回滚
}

6.3 监控和告警

@Component
public class DeadlockMonitor {
    
    @Scheduled(fixedRate = 60000)  // 每分钟检查一次
    public void checkDeadlock() {
        // 查询死锁信息
        String status = jdbcTemplate.queryForObject(
            "SHOW ENGINE INNODB STATUS", 
            String.class
        );
        
        if (status.contains("LATEST DETECTED DEADLOCK")) {
            // 解析死锁信息
            DeadlockInfo info = parseDeadlock(status);
            
            // 发送告警
            alertService.sendAlert(
                "检测到死锁!",
                "表:" + info.getTable() +
                ",SQL:" + info.getSql()
            );
            
            // 记录日志
            log.error("死锁详情:{}", info);
        }
    }
}

七、实战案例:电商秒杀死锁 💼

问题

// 秒杀场景
@Transactional
public void seckill(Long productId, Long userId) {
    // 1. 锁定商品(for update)
    Product product = productMapper.selectForUpdate(productId);
    
    if (product.getStock() > 0) {
        // 2. 减库存
        product.setStock(product.getStock() - 1);
        productMapper.updateById(product);
        
        // 3. 创建订单(可能锁订单号生成器)
        String orderNo = generateOrderNo();  // 锁
        
        Order order = new Order();
        order.setOrderNo(orderNo);
        order.setUserId(userId);
        order.setProductId(productId);
        orderMapper.insert(order);
    }
}

// 并发场景:
// 用户A秒杀商品1
// 用户B秒杀商品2
// 两个事务可能在生成订单号时死锁!

解决方案

// 方案1:使用Redis分布式锁
public void seckill(Long productId, Long userId) {
    String lockKey = "seckill:" + productId;
    
    // Redis分布式锁(串行化,不会死锁)
    boolean locked = redisLock.tryLock(lockKey, 5, TimeUnit.SECONDS);
    
    if (locked) {
        try {
            seckillInTransaction(productId, userId);
        } finally {
            redisLock.unlock(lockKey);
        }
    } else {
        throw new BusinessException("系统繁忙,请稍后重试");
    }
}

@Transactional
public void seckillInTransaction(Long productId, Long userId) {
    Product product = productMapper.selectForUpdate(productId);
    
    if (product.getStock() > 0) {
        product.setStock(product.getStock() - 1);
        productMapper.updateById(product);
        
        // 订单号生成放在事务外
        String orderNo = snowflakeIdGenerator.nextId();  // 雪花算法,不需要锁
        
        Order order = new Order();
        order.setOrderNo(orderNo);
        order.setUserId(userId);
        order.setProductId(productId);
        orderMapper.insert(order);
    }
}

// 方案2:使用消息队列(异步处理)
public void seckill(Long productId, Long userId) {
    // 发送到MQ
    SeckillMessage msg = new SeckillMessage(productId, userId);
    mqProducer.send("seckill_topic", msg);
    
    return "排队中,请稍后查询结果";
}

@RabbitListener(queues = "seckill_queue")
public void processSeckill(SeckillMessage msg) {
    // 消费者串行处理,不会死锁
    seckillInTransaction(msg.getProductId(), msg.getUserId());
}

八、面试高频问题 🎤

Q1: 什么是死锁?如何产生?

答: 死锁是两个或多个事务相互持有对方需要的锁,导致都无法继续执行。产生条件:

  1. 互斥条件:资源只能被一个事务占用
  2. 持有并等待:持有资源,同时等待其他资源
  3. 不可剥夺:资源不能被强制剥夺
  4. 循环等待:形成环形等待链

Q2: MySQL如何检测死锁?

答: 使用等待图(Wait-for Graph)算法:

  1. 构建有向图:节点是事务,边表示等待关系
  2. 检测环:使用深度优先搜索(DFS)
  3. 发现环即死锁,选择回滚代价最小的事务

Q3: 如何避免死锁?

答:

  1. 按相同顺序访问资源(最重要)
  2. 减少事务持有锁的时间
  3. 使用较低的隔离级别(RC代替RR)
  4. 使用乐观锁代替悲观锁
  5. 大事务拆分小事务
  6. 添加合适的索引(减少锁范围)

Q4: 死锁发生后如何处理?

答:

  1. 自动处理:MySQL自动回滚代价小的事务,应用层捕获异常后重试
  2. 超时处理:设置innodb_lock_wait_timeout,超时自动回滚
  3. 监控告警:定期检查SHOW ENGINE INNODB STATUS,发现死锁及时告警
  4. 业务优化:分析死锁日志,优化SQL和业务逻辑

Q5: 为什么按相同顺序访问资源可以避免死锁?

答: 因为破坏了循环等待条件。例如所有事务都按id从小到大加锁:

  • 事务A:锁1 → 锁2
  • 事务B:锁1 → 锁2

事务B等待锁1时,不会同时持有锁2,不会形成循环等待,所以不会死锁。


九、总结口诀 📝

死锁本质相互等,
四个条件缺一不可。
互斥持有不剥夺,
循环等待是关键。

检测使用等待图,
发现环路即死锁。
回滚代价小的事务,
保证系统能运行。

避免死锁有妙招,
统一顺序最重要。
减少持锁时间短,
乐观锁代替悲观锁。

大事务拆成小事务,
索引优化锁范围。
监控告警不能少,
出现问题快定位!

参考资料 📚


下期预告: 150-如何设计一个高性能的订单表结构?📋


编写时间:2025年
作者:技术文档小助手 ✍️
版本:v1.0

愿你的事务永不死锁! 🔓✨