MySQL 死锁案例分析:5 种常见场景与解决方案的详细使用教程

MySQL 死锁案例分析:5 种常见场景与解决方案的详细使用教程

引言:死锁是并发编程的噩梦

死锁是 MySQL 数据库中最棘手的问题之一。一个死锁可能导致整个系统瘫痪,业务中断。

根据我们的统计,生产环境中平均每 1000 个请求就有 1-2 个死锁。今天这篇教程将通过 5 个真实场景,带你彻底掌握死锁的原理和解决方案。

第一章:死锁原理

1.1 什么是死锁?

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

典型场景:
事务 A: 持有锁 1 → 等待锁 2
事务 B: 持有锁 2 → 等待锁 1

结果:互相等待,形成死锁!

1.2 MySQL 死锁检测机制

InnoDB 死锁检测:
  1. 等待图(Wait-for Graph)
  2. 节点:事务
  3. 边:等待关系
  4. 检测:查找循环依赖
    1. 死锁检测算法
    2. 每次加锁失败时触发
    3. 构建等待图
    4. 检测循环
    5. 选择牺牲者(Rollback)
      1. 死锁处理策略
      2. 回滚一个事务(牺牲者)
      3. 重新执行
      4. 返回死锁错误码:1213

1.3 锁类型

InnoDB 锁类型:

  1. 共享锁(Shared Lock / S Lock)
  2. 读锁,允许其他读
  3. 阻塞写操作
  4. SELECT ... LOCK IN SHARE MODE
    1. 排他锁(Exclusive Lock / X Lock)
    2. 写锁,独占资源
    3. 阻塞读和写
    4. INSERT, UPDATE, DELETE
      1. 间隙锁(Gap Lock)
      2. 锁定间隙,防止幻读
      3. BETWEEN, IN, >, <
        1. 临键锁(Next-Key Lock)
        2. 记录锁 + 间隙锁
        3. 防止插入和修改

第二章:5 种常见死锁场景

场景 1:不同顺序更新多行记录

-- 场景描述:
-- 两个事务按不同顺序更新相同的两条记录

-- 事务 A(按 ID 顺序)
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- ⚠️ 死锁!
COMMIT;

-- 事务 B(按 ID 逆序)
START TRANSACTION;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- ⚠️ 死锁!
COMMIT;

-- 死锁过程:
-- 事务 A:持有 id=1 的锁 → 等待 id=2 的锁
-- 事务 B:持有 id=2 的锁 → 等待 id=1 的锁
-- 结果:相互等待,形成死锁!

-- ✅ 解决方案 1:统一更新顺序
-- 所有事务都按 ID 升序或降序更新

START TRANSACTION;
-- 按 ID 排序后更新
UPDATE accounts SET balance = balance - 100 
WHERE id IN (1, 2) 
ORDER BY id ASC;
COMMIT;

-- ✅ 解决方案 2:使用锁提示(不推荐)
START TRANSACTION;
LOCK TABLES accounts WRITE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
UNLOCK TABLES;
COMMIT;

-- ✅ 解决方案 3:重试机制(推荐)
function transfer(fromId, toId, amount) {
    let retries = 5;
    while (retries > 0) {
        try {
            conn.beginTransaction();
            
            // 按固定顺序查询和更新
            const accounts = await query(
                'SELECT * FROM accounts WHERE id IN (?, ?) FOR UPDATE',
                [fromId, toId].sort()  // 确保顺序一致
            );
            
            if (accounts[0].balance >= amount) {
                await query(
                    'UPDATE accounts SET balance = balance - ? WHERE id = ?',
                    [amount, fromId]
                );
                await query(
                    'UPDATE accounts SET balance = balance + ? WHERE id = ?',
                    [amount, toId]
                );
                conn.commit();
                return true;
            }
            
            conn.rollback();
            return false;
        } catch (error) {
            if (error.errno === 1213) {  // 死锁错误码
                retries--;
                await sleep(100 * (5 - retries));  // 指数退避
                continue;
            }
            conn.rollback();
            throw error;
        }
    }
    throw new Error('Transfer failed after retries');
}

场景 2:索引缺失导致的间隙锁

-- 场景描述:
-- 没有索引的 UPDATE 语句会锁定整个表

-- ❌ 错误示例(导致死锁)
-- 表结构
CREATE TABLE orders (
    id INT PRIMARY KEY,
    user_id INT NOT NULL,  -- 没有索引!
    status VARCHAR(20),
    amount DECIMAL(10,2)
);

-- 事务 A:删除 order_id=10 的记录
START TRANSACTION;
DELETE FROM orders WHERE user_id = 100;  -- 全表扫描,间隙锁
ROLLBACK;  -- 释放锁

-- 事务 B:更新 user_id=100 的记录
START TRANSACTION;
UPDATE orders SET status = 'paid' WHERE user_id = 100;  -- 全表扫描,间隙锁
-- ⚠️ 死锁!

-- 原因:没有索引时,UPDATE/DELETE 会锁定整个表的所有记录

-- ✅ 解决方案:添加索引

-- 方案 1:添加业务索引
ALTER TABLE orders ADD INDEX idx_user_id (user_id);

-- 方案 2:添加覆盖索引
ALTER TABLE orders ADD INDEX idx_user_status (user_id, status);

-- 验证索引使用
EXPLAIN DELETE FROM orders WHERE user_id = 100;
-- type: ref 或 range(使用了索引)
-- rows: 1-100(只扫描需要的行)

-- 如果没有索引:
-- type: ALL(全表扫描)
-- rows: 1000000(扫描所有行)

场景 3:自增 ID 插入死锁

-- 场景描述:
-- 并发插入自增 ID 时可能产生间隙锁死锁

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

-- 事务 A
START TRANSACTION;
INSERT INTO users (username, email) VALUES ('user1', 'a@b.com');
-- 锁:插入位置(间隙锁)

-- 事务 B
START TRANSACTION;
INSERT INTO users (username, email) VALUES ('user2', 'c@d.com');
-- ⚠️ 等待事务 A 的插入位置

-- 事务 A 提交
COMMIT;

-- 事务 B 继续
-- 但如果顺序相反,可能产生死锁

-- ✅ 解决方案 1:减少并发插入
-- 使用批量插入
INSERT INTO users (username, email) 
VALUES 
    ('user1', 'a@b.com'),
    ('user2', 'c@d.com'),
    ('user3', 'e@f.com');

-- ✅ 解决方案 2:使用 UUID 作为主键
CREATE TABLE users (
    id VARCHAR(36) PRIMARY KEY,
    username VARCHAR(50),
    email VARCHAR(100)
);

-- ✅ 解决方案 3:使用序列
CREATE SEQUENCE user_seq START 1;
INSERT INTO users (id, username, email) 
VALUES (NEXTVAL('user_seq'), 'user1', 'a@b.com');

-- ✅ 解决方案 4:分表写入
-- 将一个大表分成多个表
-- users_0, users_1, ... users_15
-- 使用用户 ID 哈希分散插入

场景 4:外键约束导致死锁

-- 场景描述:
-- 外键约束在删除/插入时可能导致死锁

-- 表结构
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)
);

-- 事务 A:删除部门
START TRANSACTION;
DELETE FROM departments WHERE id = 1;
-- 等待检查外键约束

-- 事务 B:插入员工到该部门
START TRANSACTION;
INSERT INTO employees (name, dept_id) VALUES ('Alice', 1);
-- ⚠️ 死锁!

-- ✅ 解决方案 1:延迟检查
ALTER TABLE employees 
ADD CONSTRAINT fk_dept 
FOREIGN KEY (dept_id) REFERENCES departments(id)
ON DELETE NO ACTION 
ON UPDATE CASCADE;

-- ✅ 解决方案 2:先删除子表记录
START TRANSACTION;
-- 先删除子表数据
DELETE FROM employees WHERE dept_id = 1;
-- 再删除父表数据
DELETE FROM departments WHERE id = 1;
COMMIT;

-- ✅ 解决方案 3:使用触发器
DELIMITER $$
CREATE TRIGGER check_delete_before 
BEFORE DELETE ON departments
FOR EACH ROW
BEGIN
    DECLARE child_count INT;
    SELECT COUNT(*) INTO child_count 
    FROM employees WHERE dept_id = OLD.id;
    
    IF child_count > 0 THEN
        SIGNAL SQLSTATE '45000' 
        SET MESSAGE_TEXT = 'Cannot delete department with employees';
    END IF;
END$$
DELIMITER ;

-- ✅ 解决方案 4:软删除
ALTER TABLE departments ADD COLUMN is_deleted TINYINT DEFAULT 0;

UPDATE departments SET is_deleted = 1 WHERE id = 1;
-- 物理删除由定时任务完成

场景 5:事务超时导致死锁

-- 场景描述:
-- 事务超时时间过短,频繁回滚导致死锁

-- 配置问题
SET innodb_lock_wait_timeout = 1;  -- 1 秒太短!
SET innodb_deadlock_detect = 0;    -- 禁用死锁检测!

-- ❌ 错误配置导致的问题
-- 事务 A: 持有锁 → 等待 → 1 秒后超时回滚
-- 事务 B: 持有锁 → 等待 → 1 秒后超时回滚
-- 结果:频繁死锁和回滚

-- ✅ 解决方案:合理的超时设置

-- 方案 1:增加超时时间
SET innodb_lock_wait_timeout = 50;  -- 50 秒

-- 方案 2:启用死锁检测
SET innodb_deadlock_detect = ON;

-- 方案 3:监控超时
-- 查询死锁信息
SHOW ENGINE INNODB STATUS\G

-- 查看最近死锁
SELECT * FROM information_schema.innodb_lock_waits;

-- 方案 4:优化长事务
-- 缩短事务时间
START TRANSACTION;
-- 快速操作
UPDATE users SET name = 'Alice' WHERE id = 1;
INSERT INTO logs VALUES ('updated');
COMMIT;

-- 避免在事务中调用外部 API
START TRANSACTION;
-- ❌ 不要在事务中:
-- - 调用 HTTP 请求
-- - 读写文件
-- - 执行耗时操作

-- ✅ 方案 5:分批处理
-- 分批更新大量数据
FOR i IN 1..1000 LOOP
    UPDATE large_table SET status = 1 
    WHERE id >= (i-1)*1000 AND id < i*1000;
    COMMIT;
    sleep(0.1);  -- 让其他事务执行
END LOOP;

第三章:死锁诊断

3.1 死锁诊断工具

-- 1. 查看当前锁状态
SELECT * FROM information_schema.innodb_locks\G

-- 2. 查看锁等待
SELECT * FROM information_schema.innodb_lock_waits\G

-- 3. 查看事务状态
SELECT * FROM information_schema.innodb_trx\G

-- 4. 查看最近死锁
SELECT 
    trx_id,
    trx_state,
    trx_query,
    trx_mysql_thread_id
FROM information_schema.innodb_trx
WHERE trx_started < DATE_SUB(NOW(), INTERVAL 1 MINUTE);

-- 5. 查看死锁日志
SELECT 
    deadlock_id,
    deadlock_time,
    deadlock_info
FROM mysql.general_log 
WHERE command_type = 'Query' 
AND argument LIKE '%DEADLOCK%';

-- 6. 启用死锁日志记录
-- 在 my.cnf 中配置
[mysqld]
innodb_print_all_deadlocks = 1

3.2 死锁信息分析

死锁日志示例:
------------------------
TRANSACTION 0 123456789
TRIED TO ROLLBACK HANDLE...
...
LATEST DETECTED DEADLOCK
------------------------
1 transaction in database
Deadlock found when trying to get lock...

*** (1) TRANSACTION:
TRANSACTION 123456789, ACTIVE 0 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 1234567890
query id 000001 updating

START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

*** (2) HOLDING THE LOCK:
TRANSACTION 123456788, ACTIVE 0 sec starting index read
MySQL thread id 9
query id 000002 updating

START TRANSACTION;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS lock no 0x6f lock mode X locks rec but not p
...
*** WE ROLLBACK TRANSACTION (2)

第四章:预防策略

4.1 编码规范

-- ✅ 最佳实践

-- 1. 统一锁顺序
-- 所有事务按相同顺序访问资源
-- ORDER BY id ASC 或 DESC

-- 2. 使用索引
-- 确保 WHERE 条件使用索引
CREATE INDEX idx_balance_id ON accounts(id);
CREATE INDEX idx_user_status ON orders(user_id, status);

-- 3. 小事务
-- 事务尽量短,快速提交
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
INSERT INTO logs VALUES ('transfer');
COMMIT;

-- 4. 避免锁升级
-- 避免大事务锁定过多行
-- 分批处理大量更新

-- 5. 使用隔离级别
-- 降低隔离级别减少锁
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 6. 索引优化
-- 覆盖索引减少回表
CREATE INDEX idx_covering ON orders(user_id, status, amount);

4.2 监控告警

-- 监控死锁数量
CREATE TABLE deadlock_stats (
    date DATE,
    deadlock_count INT,
    avg_lock_wait_ms INT,
    PRIMARY KEY (date)
);

-- 定时任务收集
INSERT INTO deadlock_stats 
SELECT 
    CURDATE(),
    COUNT(*),
    AVG(lock_wait_time)
FROM information_schema.innodb_lock_waits
GROUP BY 1;

-- 告警阈值
-- 1 小时内死锁 > 100 次 → 告警
-- 平均锁等待 > 1 秒 → 告警
-- 死锁率 > 1% → 告警

4.3 重试策略

```python

死锁重试示例

import pymysql
import time
import random

class DeadlockRetry:
def __init__(self, max_retries=5, base_delay=0.1):
self.max_retries = max_retries
self.base_delay = base_delay

def execute_with_retry(self, conn, operation, *args):
for attempt in range(self.max_retries):
try:
with conn.cursor() as cursor:
cursor.execute(operation, args)
conn.commit()
return True
except pymysql.Error as e:
if e.args[0] == 1213: # Deadlock found
delay = self.base_delay * (2 ** attempt)
time.sleep(delay + random.random() * 0.1)
conn.rollback()
continue
else:
raise
raise Exception("Max retries exceeded")

# 使用示例
def transfer_money(self, conn, from_id, to_id, amount):
self.execute_with_retry(
conn,
"""
UPDATE accounts SET balance = balance - %s
WHERE id = %s AND balance >= %s
""",
(amount, from_id, amount)
)
self.execute_with_retry(
conn,
"""
UPDATE accounts SET balance = balance + %s
WHERE id = %s
""",
(amount, to_id)
)
conn.commit()
```

总结:死锁处理最佳实践

通过正确的策略:

预防措施:

  • ✅ 统一访问顺序
  • ✅ 使用索引
  • ✅ 小事务快速提交
  • ✅ 避免锁升级

监控方案:

  • ✅ 监控死锁数量
  • ✅ 监控锁等待时间
  • ✅ 告警机制

处理策略:

  • ✅ 自动重试(指数退避)
  • ✅ 快速回滚
  • ✅ 死锁日志分析

掌握这些技巧,让你的 MySQL 应用告别死锁!🚀

---

参考资源:

  • [MySQL 死锁文档](https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks.html)
  • [InnoDB 锁机制](https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html)
  • [性能优化指南](https://dev.mysql.com/doc/refman/8.0/en/innodb-performance.html)
  • [隔离级别](https://dev.mysql.com/doc/refman/8.0/en/innodb-isolation-levels.html)

标签

发表评论