MySQL 死锁案例分析:5 种常见场景与解决方案

MySQL 死锁案例分析:5 种常见场景与解决方案

MySQL 死锁案例分析:5 种常见场景与解决方案

引言

死锁(Deadlock) 是并发数据库操作中常见的问题,当两个或多个事务互相等待对方释放锁时,就会形成死锁,导致这些事务永远无法继续执行。

在 MySQL InnoDB 引擎中,死锁检测机制会自动发现并解决这一问题,通常的做法是回滚其中一个事务,让其他事务继续执行。然而,了解死锁的成因和场景,对于优化数据库设计和提高系统稳定性至关重要。

本教程将通过 5 种典型的死锁场景,帮助你深入理解 MySQL 的锁机制,并提供实用的解决方案。

适用读者: 中级 MySQL 开发者、后端工程师、数据库管理员

场景一:两行互锁(Row Lock Conflict)

死锁描述

这是最常见的死锁类型,发生在两个事务以不同的顺序锁定相同的两行记录时。

场景演示

假设有如下表结构和初始数据:

CREATE TABLE accounts (
    id INT PRIMARY KEY,
    balance INT
);

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

死锁发生过程

时刻 T1:
事务 A: BEGIN;
       UPDATE accounts SET balance = 900 WHERE id = 1;  -- 获取行 1 的行锁

事务 B: BEGIN;
       UPDATE accounts SET balance = 800 WHERE id = 2;  -- 获取行 2 的行锁

时刻 T2:
事务 A: UPDATE accounts SET balance = 850 WHERE id = 2; -- 等待事务 B 释放行 2
事务 B: UPDATE accounts SET balance = 950 WHERE id = 1; -- 等待事务 A 释放行 1

结果:死锁!

死锁日志分析

*** (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 100, OS thread handle 140234567890, query id 234567 localhost 127.0.0.1 user updating
UPDATE accounts SET balance = 850 WHERE id = 2

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS record lock, index PRIMARY of table `accounts`
Lock modes: EX

*** (2) TRANSACTION:
TRANSACTION 12346, 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 101, OS thread handle 140234567891, query id 234568 localhost 127.0.0.1 user updating
UPDATE accounts SET balance = 950 WHERE id = 1

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS record lock, index PRIMARY of table `accounts`
Lock modes: EX

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS record lock, index PRIMARY of table `accounts`
Lock modes: EX
Waiting for transaction 12346...

*** WE ROLL BACK TRANSACTION (1)

解决方案

方案 1:统一事务内的操作顺序

-- 所有事务都按照相同顺序更新
BEGIN;
UPDATE accounts SET balance = 900 WHERE id = 1;
UPDATE accounts SET balance = 850 WHERE id = 2;
COMMIT;

方案 2:使用锁表或锁升级

-- 获取表级锁(谨慎使用)
LOCK TABLES accounts WRITE;
UPDATE accounts SET balance = 900 WHERE id = 1;
UPDATE accounts SET balance = 850 WHERE id = 2;
UNLOCK TABLES;

方案 3:使用乐观锁

-- 添加版本号字段
ALTER TABLE accounts ADD COLUMN version INT DEFAULT 0;

UPDATE accounts 
SET balance = 900, version = version + 1 
WHERE id = 1 AND version = 5;

场景二:间隙锁冲突(Gap Lock Conflict)

死锁描述

间隙锁用于防止幻读,当事务在范围内查询并加锁时,其他事务无法在间隙中插入数据。

场景演示

CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50),
    age INT
);

INSERT INTO users (name, age) VALUES 
('Alice', 20),
('Bob', 25),
('Charlie', 30);

死锁发生过程

时刻 T1:
事务 A: BEGIN;
       SELECT * FROM users WHERE age > 20 FOR UPDATE; -- 加间隙锁 (20, +∞)

事务 B: BEGIN;
       INSERT INTO users (name, age) VALUES ('David', 22); -- 被间隙锁阻塞

时刻 T2:
事务 A: INSERT INTO users (name, age) VALUES ('Eve', 28); -- 与事务 B 冲突
事务 B: 等待事务 A 提交

结果:死锁(取决于执行顺序)

死锁分析

当使用 `FOR UPDATE` 或 `SHARE LOCK` 查询时,InnoDB 会:

  1. 锁定满足条件的记录
  2. 锁定记录之间的间隙
  3. 锁定间隙之前的前一条记录
  4. 解决方案

    方案 1:避免在间隙锁范围内插入

    -- 避免使用范围查询
    SELECT * FROM users WHERE age = 25 FOR UPDATE;
    

    方案 2:使用快照隔离

    -- 使用 READ COMMITTED 隔离级别
    SET SESSION transaction_isolation = 'READ-COMMITTED';
    
    -- 此时不会使用间隙锁
    SELECT * FROM users WHERE age > 20 FOR UPDATE;
    

    方案 3:调整查询范围

    -- 精确匹配而非范围查询
    SELECT * FROM users WHERE id BETWEEN 1 AND 10 FOR UPDATE;
    

    场景三:插入意向锁冲突(Insert Intension Lock)

    死锁描述

    插入意向锁是一种特殊的间隙锁,当多个事务尝试在同一间隙插入数据时会发生冲突。

    场景演示

    CREATE TABLE orders (
        id INT PRIMARY KEY,
        user_id INT,
        amount DECIMAL(10,2)
    );
    
    INSERT INTO orders VALUES 
    (1, 100, 100.00),
    (5, 101, 200.00);
    

    死锁发生过程

    事务 A: BEGIN;
           INSERT INTO orders (id, user_id, amount) 
           VALUES (3, 102, 150.00); -- 等待...
    
    事务 B: BEGIN;
           INSERT INTO orders (id, user_id, amount) 
           VALUES (4, 103, 180.00); -- 等待...
    
    死锁原因:
    
    • 事务 A 在 (3, 5) 间隙加插入意向锁
    • 事务 B 在 (3, 5) 间隙加插入意向锁
    • 两者互相等待

    解决方案

    方案 1:确保插入顺序一致

    -- 所有事务按 id 顺序插入
    BEGIN;
    INSERT INTO orders VALUES (1, ...);
    INSERT INTO orders VALUES (2, ...);
    INSERT INTO orders VALUES (3, ...);
    COMMIT;
    

    方案 2:使用自增主键

    -- 避免手动指定主键
    CREATE TABLE orders (
        id INT PRIMARY KEY AUTO_INCREMENT,
        user_id INT,
        amount DECIMAL(10,2)
    );
    

    方案 3:降低事务并发度

    -- 在应用层进行限流控制
    -- 或使用消息队列串行处理插入
    

    场景四:唯一索引更新死锁(Unique Index Update)

    死锁描述

    当更新唯一索引列导致索引重排时,可能引发死锁。

    场景演示

    CREATE TABLE employees (
        id INT PRIMARY KEY,
        employee_no VARCHAR(20) UNIQUE,
        department_id INT
    );
    
    INSERT INTO employees VALUES 
    (1, 'E001', 10),
    (2, 'E002', 20);
    

    死锁发生过程

    时刻 T1:
    事务 A: BEGIN;
           UPDATE employees 
           SET employee_no = 'E003' 
           WHERE id = 1; -- 锁定 (E001) 并添加新索引 (E003)
    
    事务 B: BEGIN;
           UPDATE employees 
           SET employee_no = 'E001' 
           WHERE id = 2; -- 锁定 (E002) 并尝试插入 (E001)
    
    时刻 T2:
    事务 A: 需要获取 E001 的锁(被事务 B 持有)
    事务 B: 需要获取 E003 的锁(被事务 A 持有)
    
    结果:死锁!
    

    死锁日志关键信息

    ERROR 1213 (40001): Deadlock found when trying to get lock
    *** (1) HOLDS THE LOCK(S):
    RECORD LOCKS record lock, index employee_no
    Lock modes: EX
    
    *** (2) HOLDS THE LOCK(S):
    RECORD LOCKS record lock, index employee_no
    Lock modes: EX
    
    *** WE ROLL BACK TRANSACTION (2)
    

    解决方案

    方案 1:批量更新同一索引

    -- 在应用层收集要更新的记录
    -- 按相同顺序批量执行
    

    方案 2:使用非唯一索引

    -- 如果业务允许,使用非唯一索引
    ALTER TABLE employees DROP INDEX employee_no;
    ALTER TABLE employees ADD INDEX idx_employee_no (employee_no);
    

    方案 3:先删除再插入

    BEGIN;
    DELETE FROM employees WHERE id = 1;
    INSERT INTO employees (id, employee_no, department_id) 
    VALUES (1, 'E003', 10);
    COMMIT;
    

    场景五:外键约束死锁(Foreign Key Constraint)

    死锁描述

    外键约束会在父表和子表之间建立锁依赖关系,可能导致跨表死锁。

    场景演示

    CREATE TABLE departments (
        id INT PRIMARY KEY,
        name VARCHAR(50)
    );
    
    CREATE TABLE employees (
        id INT PRIMARY KEY,
        name VARCHAR(50),
        dept_id INT,
        FOREIGN KEY (dept_id) REFERENCES departments(id)
    );
    
    INSERT INTO departments VALUES (1, '研发部');
    INSERT INTO employees VALUES (1, '张三', 1);
    

    死锁发生过程

    时刻 T1:
    事务 A: BEGIN;
           DELETE FROM departments WHERE id = 1; -- 锁定部门 1
           -- 等待删除员工
    
    事务 B: BEGIN;
           DELETE FROM employees WHERE dept_id = 1; -- 锁定员工 1
           -- 等待删除部门
    
    时刻 T2:
    事务 A: 等待事务 B 释放员工记录锁
    事务 B: 等待事务 A 释放部门记录锁
    
    结果:死锁!
    

    解决方案

    方案 1:先删除子表数据

    BEGIN;
    DELETE FROM employees WHERE dept_id = 1;
    DELETE FROM departments WHERE id = 1;
    COMMIT;
    

    方案 2:使用级联删除(谨慎)

    -- 创建表时添加 ON DELETE CASCADE
    CREATE TABLE employees (
        id INT PRIMARY KEY,
        name VARCHAR(50),
        dept_id INT,
        FOREIGN KEY (dept_id) REFERENCES departments(id)
        ON DELETE CASCADE
    );
    

    方案 3:禁用外键检查(仅限特殊场景)

    -- 批量操作时临时关闭外键检查
    SET FOREIGN_KEY_CHECKS = 0;
    -- 执行操作
    SET FOREIGN_KEY_CHECKS = 1;
    

    预防措施与解决方案

    1. 数据库配置优化

    -- 启用死锁检测
    SET GLOBAL innodb_print_all_deadlocks = ON; -- 记录所有死锁
    
    -- 调整死锁检测策略
    SET GLOBAL innodb_deadlock_detect = ON; -- 默认开启
    
    -- 调整锁等待超时
    SET GLOBAL innodb_lock_wait_timeout = 50; -- 50 秒
    

    2. 事务设计最佳实践

    -- 1. 保持事务简短
    BEGIN;
    -- 只包含必要的操作
    COMMIT;
    
    -- 2. 统一资源访问顺序
    BEGIN;
    UPDATE table_a WHERE id = 1;
    UPDATE table_b WHERE id = 1;
    COMMIT;
    
    -- 3. 使用合适的隔离级别
    SET SESSION transaction_isolation = 'READ-COMMITTED';
    

    3. 索引优化

    -- 确保查询使用索引
    EXPLAIN SELECT * FROM users WHERE age = 20 FOR UPDATE;
    
    -- 避免覆盖索引导致额外锁
    CREATE INDEX idx_age ON users(age, id);
    

    4. 监控与告警

    -- 查看当前锁状态
    SELECT * FROM information_schema.innodb_locks;
    SELECT * FROM information_schema.innodb_lock_waits;
    
    -- 查看死锁统计
    SHOW ENGINE INNODB STATUS;
    

    5. 应用层防护

    “`python
    import pymysql
    import time

    def safe_transaction_execute(conn, max_retries=3):
    for attempt in range(max_retries):
    try:
    conn.begin()
    # 执行业务逻辑
    conn.commit()
    return True
    except pymysql.Error as e:
    if ‘Deadlock found’ in str(e) and attempt < max_retries - 1: time.sleep(0.1 * (attempt + 1)) # 指数退避 continue else: raise e ``` ---

    总结

    通过本文的 5 个死锁场景分析,我们掌握了:

    核心要点回顾

    1. 两行互锁:统一事务内的操作顺序是最佳解决方案
    2. 间隙锁冲突:使用合适的隔离级别避免间隙锁
    3. 插入意向锁:避免在同一间隙并发插入
    4. 唯一索引更新:批量更新或避免索引重排
    5. 外键约束:先删除子表再删除父表
    6. 预防策略

      • ✅ 保持事务简短
      • ✅ 统一资源访问顺序
      • ✅ 使用合适的隔离级别
      • ✅ 优化索引设计
      • ✅ 实施监控和告警

      调试工具

      • `SHOW ENGINE INNODB STATUS`:查看死锁详情
      • `information_schema.innodb_locks`:监控当前锁
      • `information_schema.innodb_lock_waits`:查看等待关系

      记住: 最好的死锁处理方案是预防。理解 MySQL 的锁机制,从设计层面避免死锁场景,才能构建更加稳定和高效的数据库系统。

      *本文档最后更新时间:2026 年 04 月 27 日*
      *作者:creator | 适用 MySQL 5.7+ / 8.0+*

标签

发表评论