Skip to content
清晨的一缕阳光
返回

MySQL 死锁分析与解决

核心概念

死锁(Deadlock)是指两个或多个事务在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法推进。

事务 A 持有资源 1,请求资源 2
事务 B 持有资源 2,请求资源 1
→ 循环等待,形成死锁

死锁产生的四个必要条件

  1. 互斥条件:资源一次只能被一个事务占用
  2. 请求与保持:事务持有资源的同时请求新资源
  3. 不剥夺条件:已获得的资源不能被强制剥夺
  4. 循环等待:存在事务等待的循环链

死锁底层原理

InnoDB 死锁检测机制

InnoDB 使用**等待图(Wait-For Graph)**检测死锁:

等待图结构:
- 节点:事务
- 边:事务 A → 事务 B 表示 A 等待 B 持有的锁

检测时机:
- 每次事务请求锁被阻塞时
- 遍历等待图,检测是否存在环
- 发现环则选择牺牲一个事务(回滚)

死锁检测配置

-- 开启死锁检测(默认开启)
SET GLOBAL innodb_deadlock_detect = ON;

-- 死锁检测超时(秒)
SET GLOBAL innodb_lock_wait_timeout = 50;

-- 查看当前配置
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

牺牲者选择策略

当检测到死锁时,InnoDB 会选择回滚一个事务(牺牲者),选择标准:

  1. 回滚代价最小的事务
  2. 持有锁数量较少的事务
  3. 修改数据量较少的事务
  4. 如果代价相同,随机选择

常见死锁场景

场景 1:交叉更新(最典型)

-- 表结构
CREATE TABLE accounts (
    id INT PRIMARY KEY,
    balance DECIMAL(10, 2)
);

INSERT INTO accounts VALUES (1, 1000), (2, 2000);

-- 事务 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 持有 id=1 的锁
-- 此时事务 B 启动...
UPDATE accounts SET balance = balance - 200 WHERE id = 2;  -- 请求 id=2 的锁

-- 事务 B
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;   -- 持有 id=2 的锁
-- 此时事务 A 已持有 id=1 的锁...
UPDATE accounts SET balance = balance - 30 WHERE id = 1;   -- 请求 id=1 的锁

-- 死锁!
-- 事务 A:持有 id=1,等待 id=2
-- 事务 B:持有 id=2,等待 id=1

解决方案:固定访问顺序

-- 所有事务都按 id 升序访问
-- 事务 A 和 事务 B 都执行:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance - 200 WHERE id = 2;
COMMIT;

场景 2:间隙锁导致的死锁

-- 表结构
CREATE TABLE orders (
    id INT PRIMARY KEY,
    user_id INT,
    status INT,
    INDEX idx_user_id (user_id)
);

INSERT INTO orders VALUES 
(1, 100, 1), (5, 100, 1), (10, 100, 1);

-- 事务 A
BEGIN;
SELECT * FROM orders WHERE user_id = 100 AND id < 5 FOR UPDATE;
-- 锁定:user_id=100 的间隙 (1, 5)

-- 事务 B
BEGIN;
SELECT * FROM orders WHERE user_id = 100 AND id > 5 FOR UPDATE;
-- 锁定:user_id=100 的间隙 (5, 10)

-- 事务 A:尝试插入 id=6
INSERT INTO orders VALUES (6, 100, 1);  -- 等待事务 B 的间隙锁

-- 事务 B:尝试插入 id=4
INSERT INTO orders VALUES (4, 100, 1);  -- 等待事务 A 的间隙锁

-- 死锁!

解决方案:

-- 方案 1:使用 READ COMMITTED 隔离级别
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 方案 2:优化为等值查询
SELECT * FROM orders WHERE user_id = 100 AND id = 5 FOR UPDATE;

-- 方案 3:添加唯一索引,避免间隙锁
ALTER TABLE orders ADD UNIQUE INDEX idx_user_unique (user_id, id);

场景 3:外键约束导致的死锁

-- 父表
CREATE TABLE users (
    id INT PRIMARY KEY,
    name VARCHAR(50)
);

-- 子表(有外键)
CREATE TABLE orders (
    id INT PRIMARY KEY,
    user_id INT,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

-- 事务 A:删除父记录
BEGIN;
DELETE FROM users WHERE id = 1;  -- 需要等待子表锁

-- 事务 B:插入子记录
BEGIN;
INSERT INTO orders (id, user_id) VALUES (100, 1);  -- 检查外键,等待父表锁

-- 死锁!
-- 事务 A:等待子表锁以检查外键
-- 事务 B:等待父表锁以检查外键

解决方案:

-- 方案 1:先删除子记录,再删除父记录
BEGIN;
DELETE FROM orders WHERE user_id = 1;
DELETE FROM users WHERE id = 1;
COMMIT;

-- 方案 2:调整外键约束
ALTER TABLE orders 
DROP FOREIGN KEY orders_ibfk_1,
ADD CONSTRAINT orders_ibfk_1 
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;

场景 4:唯一索引冲突导致的死锁

-- 表结构
CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(100) UNIQUE,
    name VARCHAR(50)
);

-- 事务 A
BEGIN;
INSERT INTO users (email, name) VALUES ('test@example.com', 'Alice');
-- 持有 email='test@example.com' 的排他锁

-- 事务 B
BEGIN;
INSERT INTO users (email, name) VALUES ('test@example.com', 'Bob');
-- 等待唯一索引锁

-- 事务 A:尝试插入另一条(可能因唯一键冲突或其他原因等待)
INSERT INTO users (email, name) VALUES ('other@example.com', 'Charlie');

-- 事务 B:也在等待...
-- 死锁!

解决方案:

-- 方案 1:使用 INSERT ... ON DUPLICATE KEY UPDATE
INSERT INTO users (email, name) 
VALUES ('test@example.com', 'Alice')
ON DUPLICATE KEY UPDATE name = VALUES(name);

-- 方案 2:应用层先检查是否存在
SELECT id FROM users WHERE email = 'test@example.com';
-- 如果不存在再插入

-- 方案 3:捕获死锁异常,重试机制

场景 5:多表更新顺序不一致

-- 事务 A
BEGIN;
UPDATE users SET status = 1 WHERE id = 1;     -- 持有 users 表 id=1 的锁
UPDATE orders SET status = 1 WHERE user_id = 1;  -- 请求 orders 表的锁

-- 事务 B
BEGIN;
UPDATE orders SET status = 1 WHERE user_id = 1;  -- 持有 orders 表的锁
UPDATE users SET status = 1 WHERE id = 1;     -- 请求 users 表 id=1 的锁

-- 死锁!

解决方案:固定多表访问顺序

-- 所有事务都按相同顺序访问表
BEGIN;
UPDATE users SET status = 1 WHERE id = 1;
UPDATE orders SET status = 1 WHERE user_id = 1;
COMMIT;

死锁排查方法

1. 查看死锁日志

-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS\G

-- 输出示例
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136 bytes
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 1 page no 3 n bits 72 index PRIMARY of table `test`.`accounts` 
trx id 12345 lock_mode X
rec bit mask 1
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1 page no 5 n bits 72 index PRIMARY of table `test`.`accounts` 
trx id 12345 lock_mode X
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 8 sec starting index read
mysql tables in use 1, locked 1
2 lock struct(s), heap size 1136 bytes
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 1 page no 5 n bits 72 index PRIMARY of table `test`.`accounts` 
trx id 12346 lock_mode X
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1 page no 3 n bits 72 index PRIMARY of table `test`.`accounts` 
trx id 12346 lock_mode X
*** WE ROLL BACK TRANSACTION (1)

2. 查看锁等待信息

-- MySQL 5.7+ 查看锁等待
SELECT 
    request_trx_id,
    requested_lock_id,
    blocking_trx_id,
    blocking_lock_id
FROM performance_schema.data_lock_waits;

-- MySQL 8.0+ 使用 sys 库
SELECT * FROM sys.innodb_lock_waits;

-- 查看完整的事务和锁信息
SELECT 
    r.trx_id waiting_trx_id,
    r.trx_mysql_thread_id waiting_thread,
    r.trx_query waiting_query,
    b.trx_id blocking_trx_id,
    b.trx_mysql_thread_id blocking_thread,
    b.trx_query blocking_query
FROM information_schema.INNODB_LOCK_WAITS w
INNER JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id;

3. 查看当前持有的锁

-- 查看当前所有锁
SELECT 
    engine_transaction_id,
    lock_table,
    lock_index,
    lock_type,
    lock_mode,
    lock_data
FROM performance_schema.data_locks;

-- 查看特定事务的锁
SELECT * FROM performance_schema.data_locks 
WHERE engine_transaction_id = 12345;

4. 监控死锁频率

-- 查看死锁统计
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';

-- 持续监控(每 5 秒记录一次)
DELIMITER $$
CREATE PROCEDURE monitor_deadlocks()
BEGIN
    DECLARE prev_count INT DEFAULT 0;
    DECLARE curr_count INT;
    
    SELECT VARIABLE_VALUE INTO prev_count 
    FROM information_schema.GLOBAL_STATUS 
    WHERE VARIABLE_NAME = 'Innodb_deadlocks';
    
    WHILE TRUE DO
        DO SLEEP(5);
        SELECT VARIABLE_VALUE INTO curr_count 
        FROM information_schema.GLOBAL_STATUS 
        WHERE VARIABLE_NAME = 'Innodb_deadlocks';
        
        IF curr_count > prev_count THEN
            SELECT CONCAT(NOW(), ': Deadlock detected! Count: ', curr_count) AS alert;
            SET prev_count = curr_count;
        END IF;
    END WHILE;
END$$
DELIMITER ;

死锁解决方案

1. 应用层优化

固定访问顺序

// 错误示例:顺序不一致
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    // 不同调用可能顺序不同
    updateAccount(fromId, amount.negate());
    updateAccount(toId, amount);
}

// 正确示例:固定按 id 升序
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    Long first = Math.min(fromId, toId);
    Long second = Math.max(fromId, toId);
    
    updateAccount(first, first.equals(fromId) ? amount.negate() : amount);
    updateAccount(second, second.equals(fromId) ? amount.negate() : amount);
}

重试机制

@Transactional
public void updateWithRetry(Account account, int maxRetries) {
    int retryCount = 0;
    while (retryCount < maxRetries) {
        try {
            accountRepository.update(account);
            return;
        } catch (DeadlockLoserDataAccessException e) {
            retryCount++;
            if (retryCount >= maxRetries) {
                throw e;
            }
            // 指数退避
            Thread.sleep(100 * (1 << retryCount));
        }
    }
}

批量操作分批处理

// 错误示例:大批量更新
public void batchUpdate(List<Long> ids) {
    // 一次性更新所有,锁持有时间长
    accountRepository.updateAll(ids);
}

// 正确示例:分批处理
public void batchUpdate(List<Long> ids) {
    int batchSize = 100;
    for (int i = 0; i < ids.size(); i += batchSize) {
        List<Long> batch = ids.subList(i, Math.min(i + batchSize, ids.size()));
        accountRepository.updateAll(batch);
        // 每批之间提交,释放锁
    }
}

2. 数据库层优化

调整隔离级别

-- 使用 READ COMMITTED 减少间隙锁
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 全局设置
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

调整锁等待超时

-- 缩短锁等待时间,快速失败
SET SESSION innodb_lock_wait_timeout = 5;

-- 全局设置
SET GLOBAL innodb_lock_wait_timeout = 50;

添加必要索引

-- 无索引导致全表锁,容易死锁
UPDATE orders SET status = 1 WHERE user_id = 100;

-- 添加索引,使用行锁
CREATE INDEX idx_user_id ON orders(user_id);

3. 架构层优化

队列化串行处理

// 使用消息队列串行处理同一资源的更新
@Component
public class AccountUpdateService {
    
    @Resource
    private SendMessageTemplate messageQueueTemplate;
    
    public void updateAccount(Long accountId, BigDecimal amount) {
        // 同一账户的更新进入同一队列,串行执行
        messageQueueTemplate.send(
            "account_update_" + accountId,
            new AccountUpdateMessage(accountId, amount)
        );
    }
}

分布式锁

// 使用 Redis 分布式锁
@Component
public class AccountService {
    
    @Resource
    private RedisTemplate<String, String> redisTemplate;
    
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        String lockKey = "lock:account:" + Math.min(fromId, toId);
        
        RLock lock = redisson.getLock(lockKey);
        if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
            try {
                // 执行业务逻辑
                doTransfer(fromId, toId, amount);
            } finally {
                lock.unlock();
            }
        }
    }
}

生产环境死锁案例

案例 1:电商库存扣减死锁

问题描述: 大促期间,库存扣减频繁死锁,导致订单创建失败。

原始代码:

-- 事务 A:扣减商品 A 库存
UPDATE stock SET quantity = quantity - 1 WHERE product_id = 100;
UPDATE stock SET quantity = quantity - 1 WHERE product_id = 101;

-- 事务 B:扣减商品 B 库存
UPDATE stock SET quantity = quantity - 1 WHERE product_id = 101;
UPDATE stock SET quantity = quantity - 1 WHERE product_id = 100;

解决方案:

-- 方案 1:按 product_id 排序后更新
-- 方案 2:使用队列串行处理同一商品
-- 方案 3:Redis 预扣减 + 异步同步到 MySQL

案例 2:账户余额更新死锁

问题描述: 转账业务频繁死锁,尤其在并发高峰期。

原始代码:

@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
    Account fromAccount = accountMapper.selectById(from);
    Account toAccount = accountMapper.selectById(to);
    
    fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
    toAccount.setBalance(toAccount.getBalance().add(amount));
    
    accountMapper.update(fromAccount);
    accountMapper.update(toAccount);
}

解决方案:

@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
    // 固定按 id 升序更新
    Long first = Math.min(from, to);
    Long second = Math.max(from, to);
    
    Account firstAccount = accountMapper.selectById(first);
    Account secondAccount = accountMapper.selectById(second);
    
    if (first.equals(from)) {
        firstAccount.setBalance(firstAccount.getBalance().subtract(amount));
        secondAccount.setBalance(secondAccount.getBalance().add(amount));
    } else {
        firstAccount.setBalance(firstAccount.getBalance().add(amount));
        secondAccount.setBalance(secondAccount.getBalance().subtract(amount));
    }
    
    accountMapper.update(firstAccount);
    accountMapper.update(secondAccount);
}

案例 3:唯一索引插入死锁

问题描述: 用户注册时,并发插入同一邮箱导致死锁。

原始代码:

try {
    userMapper.insert(new User(email, name));
} catch (DuplicateKeyException e) {
    throw new BusinessException("邮箱已存在");
}

解决方案:

// 使用 INSERT ... ON DUPLICATE KEY UPDATE
userMapper.insertOrUpdate(email, name);

// 或者先查询再插入(加锁)
User existing = userMapper.selectByEmail(email);
if (existing != null) {
    throw new BusinessException("邮箱已存在");
}
userMapper.insert(new User(email, name));

死锁预防最佳实践

开发规范

  1. 固定访问顺序:多表/多行更新时,按固定顺序(如主键升序)
  2. 缩短事务:事务中只包含必要的数据库操作
  3. 避免大事务:不在事务中执行 RPC、HTTP 请求
  4. 批量分批:大批量操作分批提交
  5. 索引优化:确保 WHERE 条件使用索引

监控告警

-- 创建死锁监控表
CREATE TABLE deadlock_monitor (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    occur_time DATETIME,
    trx1_id VARCHAR(50),
    trx2_id VARCHAR(50),
    query1 TEXT,
    query2 TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 定期采集死锁信息
INSERT INTO deadlock_monitor (occur_time, trx1_id, trx2_id, query1, query2)
SELECT NOW(), ... FROM information_schema.INNODB_TRX;

配置建议

# my.cnf 配置建议
[mysqld]
# 开启死锁检测
innodb_deadlock_detect = ON

# 锁等待超时(秒)
innodb_lock_wait_timeout = 50

# 自增锁模式(高并发场景)
innodb_autoinc_lock_mode = 2

# 隔离级别(根据业务选择)
transaction-isolation = READ-COMMITTED

总结

核心要点

  1. 死锁本质:循环等待资源
  2. 检测机制:等待图算法,发现环即回滚
  3. 常见场景
    • 交叉更新(最常见)
    • 间隙锁冲突
    • 外键约束
    • 唯一索引冲突
    • 多表顺序不一致
  4. 排查方法
    • SHOW ENGINE INNODB STATUS
    • performance_schema.data_lock_waits
    • sys.innodb_lock_waits
  5. 解决方案
    • 应用层:固定顺序、重试机制、分批处理
    • 数据库层:调整隔离级别、添加索引
    • 架构层:队列化、分布式锁

预防策略

参考资料


分享这篇文章到:

上一篇文章
Go 并发安全与陷阱
下一篇文章
HashMap 完整系列详解