MySQL死锁的场景
在 MySQL(尤其是使用 InnoDB 存储引擎)中,死锁(Deadlock) 是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。
MySQL 底层有一套死锁检测机制(wait-for-graph 算法),一旦发现死锁,会选择回滚代价最小的那个事务,让另一个事务成功执行。
虽然 MySQL 会自动处理,但死锁会导致业务请求报错、吞吐量下降。以下是生产环境中最经典的 4 种死锁场景及底层原理拆解。
前提要先知道 共享锁S、排他锁 X (读读兼容,读写排斥,写写排斥),因为这些场景最终的原理都是这两个锁。
| 共享锁 (S 锁 - 读锁) | 排他锁 (X 锁 - 写锁) | |
|---|---|---|
| 共享锁 (S 锁) | 🟢 兼容 (允许) | ❌ 冲突 (阻塞) |
| 排他锁 (X 锁) | ❌ 冲突 (阻塞) | ❌ 冲突 (阻塞) |
# 场景一:相反顺序加锁(最经典的死锁)
这是最常见、也最容易理解的死锁场景。两个事务由于代码逻辑问题,以交叉相反的顺序去更新两条记录。
# 1. 场景重现:
假设有用户 A(id=1)和用户 B(id=2),他们同时向对方转账。
| 时间点 (T) | 事务 A (SQL) | 事务 B (SQL) | 底层锁状态与死锁成因 |
|---|---|---|---|
| T1 | BEGIN; | BEGIN; | 两个事务各自开启。 |
| T2 | UPDATE account SET balance=800 WHERE id=1; | 加锁:事务 A 成功获取了 id=1 的排他锁 (X锁)。 | |
| T3 | UPDATE account SET balance=700 WHERE id=2; | 加锁:事务 B 成功获取了 id=2 的排他锁 (X锁)。 | |
| T4 | UPDATE account SET balance=600 WHERE id=2; | 冲突点 1:事务 A 尝试修改 id=2,但被事务 B 占有的 X 锁堵住,事务 A 进入挂起等待状态。 | |
| T5 | UPDATE account SET balance=500 WHERE id=1; | 冲突点 2:事务 B 尝试修改 id=1,被事务 A 占有的 X 锁堵住。至此双方互等,MySQL 立刻检测到死锁,选择回滚其中一个事务。 |
# 场景二:锁升级死锁(S 锁尝试升级为 X 锁)
原理:两个事务为了防止别人改数据,先各自获取了同一条记录的共享锁(S锁,读锁)。随后,两个事务又同时尝试通过 UPDATE 将其升级为排他锁(X锁,写锁)。
| 时间点 (T) | 事务 A (SQL) | 事务 B (SQL) | 底层锁状态与死锁成因 |
|---|---|---|---|
| T1 | BEGIN; | BEGIN; | 两个事务各自开启。 |
| T2 | SELECT balance FROM account WHERE id=1 LOCK IN SHARE MODE; | 加锁:事务 A 获取了 id=1 的共享锁 (S锁)。 | |
| T3 | SELECT balance FROM account WHERE id=1 LOCK IN SHARE MODE; | 加锁:事务 B 也成功获取了 id=1 的共享锁 (S锁)(因为 S 锁与 S 锁是相互兼容的,所以不阻塞)。 | |
| T4 | UPDATE account SET balance=800 WHERE id=1; | 冲突点 1:事务 A 想要修改数据,必须把 S 锁升级为 X锁。但 X 锁要求别人不能有锁,而事务 B 正拿着 S 锁不放,事务 A 被迫排队。 | |
| T5 | UPDATE account SET balance=700 WHERE id=1; | 冲突点 2:事务 B 此时也执行了修改,同样想要升级为 X 锁,必须等事务 A 释放 S 锁。双方都在等对方放开读锁,触发死锁。 |
如果 事务T3 - 事务B 加的是X锁,那么它就只能阻塞,等待事务A释放,此时 T4-事务A 执行更新结束,事务B就能获取到锁了。
# 场景三:间隙锁与插入意向锁冲突(并发插入死锁)
原理:仅在 Repeatable Read(可重复读)隔离级别下,两个事务查询了同一个不存在的记录,各自获得了间隙锁(Gap Lock)。随后它们又同时在该区间内进行 INSERT,插入动作引发的插入意向锁被对方的间隙锁死死堵住。
(注:假设当前表里只有 id=1 和 id=10 的数据,区间 (1, 10) 是空虚的)
| 时间点 (T) | 事务 A (SQL) | 事务 B (SQL) | 底层锁状态与死锁成因 |
|---|---|---|---|
| T1 | BEGIN; | BEGIN; | 两个事务各自开启。 |
| T2 | SELECT * FROM account WHERE id=5 FOR UPDATE; | 加锁:因为 id=5 不存在,InnoDB 在区间 (1, 10) 上加了 间隙锁 (Gap Lock),防止别人插入引发幻读。 | |
| T3 | SELECT * FROM account WHERE id=6 FOR UPDATE; | 加锁:事务 B 同样加了区间 (1, 10) 的 间隙锁 (Gap Lock)。(注意:间隙锁之间是允许并存的,不冲突) | |
| T4 | INSERT INTO account(id, balance) VALUES(3, 300); | 冲突点 1:事务 A 尝试插入。插入动作需要获取 (1,10) 的插入意向锁。但插入意向锁被事务 B 的间隙锁排斥,事务 A 陷入排队。 | |
| T5 | INSERT INTO account(id, balance) VALUES(4, 400); | 冲突点 2:事务 B 尝试插入,其插入意向锁被事务 A 的间隙锁排斥。互相卡死在路口,触发死锁。 |
# 场景四:唯一索引冲突回滚(Unique Key 导致的死锁)
原理:这是生产环境中最诡异的死锁。当三个事务同时并发插入一条相同唯一键(如 name 唯一)的记录时,由于第一个事务的回滚,会导致后两个事务同时将持有的 S 锁尝试升级为 X 锁。
(注:假设 name 字段是唯一索引,目前表中无 Jack)
| 时间点 (T) | 事务 A (SQL) | 事务 B (SQL) | 事务 C (SQL) | 底层锁状态与死锁成因 |
|---|---|---|---|---|
| T1 | BEGIN; INSERT INTO users(name) VALUES('Jack'); | 事务 A 插入成功。此时事务 A 持有了 Jack 这一行的 排他锁 (X锁)。 | ||
| T2 | BEGIN; INSERT INTO users(name) VALUES('Jack'); | 事务 B 发现唯一键冲突。因为事务 A 还没提交,事务 B 只能原地排队,并转而申请 共享锁 (S锁)。 | ||
| T3 | BEGIN; INSERT INTO users(name) VALUES('Jack'); | 事务 C 同样发现冲突,开始排队申请 共享锁 (S锁)。 | ||
| T4 | ROLLBACK; | 关键转折点:事务 A 回滚,隐式释放了它的 X 锁。此时,事务 B 和事务 C 同时成功获取了该行的 S 锁。 | ||
| T5 | 瞬间醒来,尝试完成插入 | 瞬间醒来,尝试完成插入 | 事务 B 和 C 抢到读锁后,必须把 S 锁升级为 X锁 才能完成 INSERT。因为对方也拿着 S 锁,升级动作在 T5 瞬间双双卡死,直接爆发死锁。 |
INSERT/UPDATE/DELETE 默认加X锁,默认读select 不加锁所以不阻塞,因为是快照读
# 💡 终极复盘与避坑圣经
把这四个表格对比着看,你会发现两个非常有意思的数据库定律:
- 间隙锁(Gap Lock)是万恶之源:场景三之所以会发生,是因为 MySQL 的 RR 隔离级别为了防止幻读而引入了间隙锁。如果你把隔离级别降到
Read Committed (RC),间隙锁不复存在,场景三的死锁也就自动消失了。 - 写操作必须走索引:如果你的
UPDATE语句由于没有建索引而触发了全表扫描,那么 MySQL 会把整张表的所有记录和所有间隙全部加上锁。此时,只要有任何第二个并发事务在做写操作,几乎 100% 触发死锁灾难。