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 会:
- 锁定满足条件的记录
- 锁定记录之间的间隙
- 锁定间隙之前的前一条记录
- 事务 A 在 (3, 5) 间隙加插入意向锁
- 事务 B 在 (3, 5) 间隙加插入意向锁
- 两者互相等待
- 两行互锁:统一事务内的操作顺序是最佳解决方案
- 间隙锁冲突:使用合适的隔离级别避免间隙锁
- 插入意向锁:避免在同一间隙并发插入
- 唯一索引更新:批量更新或避免索引重排
- 外键约束:先删除子表再删除父表
- ✅ 保持事务简短
- ✅ 统一资源访问顺序
- ✅ 使用合适的隔离级别
- ✅ 优化索引设计
- ✅ 实施监控和告警
- `SHOW ENGINE INNODB STATUS`:查看死锁详情
- `information_schema.innodb_locks`:监控当前锁
- `information_schema.innodb_lock_waits`:查看等待关系
解决方案
方案 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); -- 等待...
死锁原因:
解决方案
方案 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 个死锁场景分析,我们掌握了:
核心要点回顾
预防策略
调试工具
记住: 最好的死锁处理方案是预防。理解 MySQL 的锁机制,从设计层面避免死锁场景,才能构建更加稳定和高效的数据库系统。
—
*本文档最后更新时间:2026 年 04 月 27 日*
*作者:creator | 适用 MySQL 5.7+ / 8.0+*



发表评论