什么是死锁?

死锁(Deadlock):两个或多个事务互相等待对方释放锁,形成循环等待,导致所有事务都无法继续执行。

-- 经典死锁场景
事务A:持有锁1,等待锁2
事务B:持有锁2,等待锁1
 互相等待,形成死锁

死锁产生条件

必须同时满足4个条件

  1. 互斥:资源不能被多个事务同时占用
  2. 持有并等待:事务持有锁的同时,等待其他锁
  3. 不可剥夺:已获得的锁不能被强制释放
  4. 循环等待:事务形成循环等待链

死锁示例

示例1:经典死锁

-- 建表
CREATE TABLE account (
    id INT PRIMARY KEY,
    balance INT
);
INSERT INTO account VALUES (1, 1000), (2, 2000);

-- 时间线
┌─────────────────┬─────────────────────────────────┐
 事务A              事务B                            
├─────────────────┼─────────────────────────────────┤
 START TRANSACTION                                 
 UPDATE account                                   
 SET balance=900                                  
 WHERE id=1;                                      
 -- 持有id=1的锁  │                                 │
                  START TRANSACTION                
                  UPDATE account                   
                  SET balance=1800                 
                  WHERE id=2;                      
                  -- 持有id=2的锁                   │
 UPDATE account                                   
 SET balance=800                                  
 WHERE id=2;                                      
 -- 等待id=2的锁  │                                 │
                  UPDATE account                   
                  SET balance=1100                 
                  WHERE id=1;                      
                  -- 等待id=1的锁                   │
                  -- 死锁!                         │
└─────────────────┴─────────────────────────────────┘

死锁检测与处理

-- InnoDB自动检测到死锁
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction

-- InnoDB选择回滚其中一个事务(事务B)
-- 事务A继续执行

示例2:并发插入导致的死锁

-- 建表
CREATE TABLE orders (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT,
    amount INT,
    KEY idx_user (user_id)
);

-- 事务A
START TRANSACTION;
INSERT INTO orders (user_id, amount) VALUES (1, 100);
-- 持有id=1的锁 + user_id=1的间隙锁

-- 事务B
START TRANSACTION;
INSERT INTO orders (user_id, amount) VALUES (1, 200);
-- 等待user_id=1的间隙锁

-- 事务A
INSERT INTO orders (user_id, amount) VALUES (1, 300);
-- 等待自己释放的间隙锁(死锁!)

示例3:间隙锁导致的死锁

-- 建表
CREATE TABLE product (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    stock INT,
    KEY idx_stock (stock)
);
INSERT INTO product VALUES (1, 'A', 10), (5, 'B', 20), (10, 'C', 30);

-- 事务A
START TRANSACTION;
SELECT * FROM product WHERE stock = 15 FOR UPDATE;
-- stock=15不存在,加间隙锁:(10, 20)

-- 事务B
START TRANSACTION;
SELECT * FROM product WHERE stock = 15 FOR UPDATE;
-- 也加间隙锁:(10, 20)(间隙锁不冲突)

-- 事务A
INSERT INTO product (id, name, stock) VALUES (7, 'D', 15);
-- 等待事务B释放间隙锁

-- 事务B
INSERT INTO product (id, name, stock) VALUES (8, 'E', 15);
-- 等待事务A释放间隙锁(死锁!)

死锁检测机制

InnoDB自动检测

wait-for graph(等待图)

-- InnoDB维护等待图
事务A  等待  事务B
事务B  等待  事务A
-- 检测到循环,发生死锁

检测策略

-- 查看死锁检测配置
SHOW VARIABLES LIKE 'innodb_deadlock_detect'; -- 默认ON

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

死锁超时

-- 锁等待超时时间
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout'; -- 默认50秒

-- 如果50秒后仍未获得锁,报错
ERROR 1205 (HY000): Lock wait timeout exceeded;
try restarting transaction

查看死锁日志

1. 查看最近一次死锁

-- 查看死锁信息
SHOW ENGINE INNODB STATUS\G

-- 输出示例(部分)
*** (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 140123456, query id 100 localhost root updating
UPDATE account SET balance=800 WHERE id=2

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 3 n bits 72 index PRIMARY of table `test`.`account`
trx id 12345 lock_mode X locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 11, OS thread handle 140234567, query id 101 localhost root updating
UPDATE account SET balance=1100 WHERE id=1

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 2 page no 3 n bits 72 index PRIMARY of table `test`.`account`
trx id 12346 lock_mode X locks rec but not gap

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 3 n bits 72 index PRIMARY of table `test`.`account`
trx id 12346 lock_mode X locks rec but not gap waiting

*** WE ROLL BACK TRANSACTION (2)

2. 开启死锁日志

-- MySQL 8.0+
SET GLOBAL log_error_verbosity = 3;

-- 查看错误日志
-- tail -f /var/log/mysql/error.log

死锁解决方案

1. 预防死锁

方案1:固定加锁顺序

-- ❌ 不好:随机加锁顺序
-- 事务A
UPDATE account SET balance=900 WHERE id=1;
UPDATE account SET balance=800 WHERE id=2;

-- 事务B
UPDATE account SET balance=1800 WHERE id=2; -- 先锁id=2
UPDATE account SET balance=1100 WHERE id=1; -- 后锁id=1
-- 可能死锁

-- ✅ 好:固定加锁顺序(按主键升序)
-- 事务A
UPDATE account SET balance=900 WHERE id=1; -- 先锁id=1
UPDATE account SET balance=800 WHERE id=2; -- 后锁id=2

-- 事务B
UPDATE account SET balance=1100 WHERE id=1; -- 先锁id=1(等待事务A)
UPDATE account SET balance=1800 WHERE id=2; -- 后锁id=2
-- 不会死锁

方案2:一次性获取所有锁

-- ✅ 一次性锁住所有需要的行
START TRANSACTION;
SELECT * FROM account WHERE id IN (1, 2) FOR UPDATE; -- 一次性锁住
UPDATE account SET balance=900 WHERE id=1;
UPDATE account SET balance=800 WHERE id=2;
COMMIT;

方案3:缩小事务范围

-- ❌ 不好:事务太大
START TRANSACTION;
SELECT * FROM account WHERE id=1; -- 复杂查询
-- ... 业务逻辑处理(耗时10秒)
UPDATE account SET balance=900 WHERE id=1;
UPDATE account SET balance=800 WHERE id=2;
COMMIT;

-- ✅ 好:事务精简
-- 先查询(不加锁)
SELECT * FROM account WHERE id=1;
-- 业务逻辑处理

-- 再开启事务(快速提交)
START TRANSACTION;
UPDATE account SET balance=900 WHERE id=1;
UPDATE account SET balance=800 WHERE id=2;
COMMIT;

方案4:降低隔离级别

-- REPEATABLE READ:有间隙锁,容易死锁
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- READ COMMITTED:无间隙锁,减少死锁概率
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

2. 死锁处理

方案1:重试机制

# Python示例
import pymysql
import time

def transfer_with_retry(from_id, to_id, amount, max_retries=3):
    for i in range(max_retries):
        try:
            conn = pymysql.connect(...)
            cursor = conn.cursor()

            # 开启事务
            conn.begin()

            # 转账操作
            cursor.execute("UPDATE account SET balance=balance-%s WHERE id=%s", (amount, from_id))
            cursor.execute("UPDATE account SET balance=balance+%s WHERE id=%s", (amount, to_id))

            # 提交事务
            conn.commit()
            print("转账成功")
            return True

        except pymysql.err.OperationalError as e:
            if e.args[0] == 1213:  # 死锁错误码
                print(f"发生死锁,重试第{i+1}次...")
                conn.rollback()
                time.sleep(0.1 * (i + 1))  # 指数退避
            else:
                raise
        finally:
            conn.close()

    print("转账失败,达到最大重试次数")
    return False

方案2:监控与告警

-- 监控死锁频率
-- 查看死锁次数(从服务启动至今)
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';

-- 如果死锁频繁(如每小时>10次),需要优化业务逻辑

实战建议

1. 设计阶段避免死锁

-- ✅ 使用唯一索引替代锁
CREATE UNIQUE INDEX uk_user_product ON orders(user_id, product_id);

-- 插入时自动检测冲突(无需加锁)
INSERT INTO orders (user_id, product_id) VALUES (1, 1001);
-- 如果冲突,直接报错,无死锁风险

2. 固定锁顺序(应用层实现)

// Java示例
public void transfer(int fromId, int toId, int amount) {
    // 按ID升序排列,固定加锁顺序
    int firstId = Math.min(fromId, toId);
    int secondId = Math.max(fromId, toId);

    // 先锁小ID,再锁大ID
    updateBalance(firstId, ...);
    updateBalance(secondId, ...);
}

3. 避免长事务

-- 查看长事务
SELECT * FROM information_schema.INNODB_TRX
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 10;

-- 杀死长事务
KILL <trx_mysql_thread_id>;

4. 监控死锁指标

# Prometheus监控
innodb_deadlocks_total{instance="mysql-01"}

# 告警规则(每小时死锁>10次)
rate(innodb_deadlocks_total[1h]) > 10

常见场景与解决方案

场景1:转账业务

-- 问题:并发转账可能死锁
-- 解决:固定加锁顺序(按账户ID升序)

-- 应用层实现
def transfer(from_id, to_id, amount):
    # 固定顺序
    ids = sorted([from_id, to_id])

    # 一次性锁定
    SELECT * FROM account WHERE id IN (ids[0], ids[1]) FOR UPDATE;

    # 更新余额
    UPDATE account SET balance = balance - amount WHERE id = from_id;
    UPDATE account SET balance = balance + amount WHERE id = to_id;

场景2:秒杀扣库存

-- 问题:高并发扣库存可能死锁
-- 解决:使用CAS(Compare And Swap)

-- ✅ 使用乐观锁(无死锁)
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1001 AND stock > 0 AND version = <old_version>;

-- 如果更新失败(version不匹配),重试

场景3:批量更新

-- 问题:批量更新顺序不一致导致死锁
-- 解决:固定更新顺序(按主键排序)

-- ❌ 不好:随机顺序
UPDATE account SET status = 'INACTIVE' WHERE id IN (10, 5, 15, 2);

-- ✅ 好:固定顺序
UPDATE account SET status = 'INACTIVE' WHERE id IN (2, 5, 10, 15);
-- 或使用
UPDATE account SET status = 'INACTIVE' WHERE id IN (10, 5, 15, 2) ORDER BY id;

常见面试题

Q1: 死锁的4个必要条件是什么?

  • 互斥、持有并等待、不可剥夺、循环等待

Q2: InnoDB如何检测死锁?

  • wait-for graph(等待图),检测循环等待

Q3: 死锁发生后,InnoDB如何处理?

  • 自动选择一个事务回滚(通常选择锁资源少的事务)

Q4: 如何预防死锁?

  • 固定加锁顺序
  • 一次性获取所有锁
  • 缩小事务范围
  • 降低隔离级别

Q5: 为什么间隙锁容易导致死锁?

  • 间隙锁之间不冲突,但都会阻塞INSERT,容易形成循环等待

小结

死锁定义:多个事务互相等待对方释放锁 ✅ 预防方法:固定加锁顺序、一次性获取锁、缩小事务 ✅ 检测机制:wait-for graph自动检测 ✅ 处理策略:自动回滚 + 应用层重试

理解死锁原理和预防方法是编写高并发应用的基础。


📚 相关阅读

  • 下一篇:《乐观锁与悲观锁:应用场景对比》
  • 推荐:《InnoDB行锁:Record Lock、Gap Lock、Next-Key Lock》