vault backup: 2026-05-21 22:11:56
This commit is contained in:
@@ -7,7 +7,13 @@ create time: 2026-05-16 00:00
|
||||
|
||||
## 概述
|
||||
|
||||
死锁是两个或多个事务互相等待对方释放锁资源的环形依赖。MySQL InnoDB 内置了死锁检测机制,会自动选择其中一个事务回滚来解除死锁。理解死锁的成因和排查方法是后端工程师的必备技能。
|
||||
死锁是两个或多个事务互相等待对方释放锁资源的环形依赖。打个比方:两个人各拿一把钥匙,但都需要对方手里的钥匙才能开门——谁也不松手,就永远卡住了。
|
||||
|
||||
MySQL InnoDB 内置了死锁检测机制,发现死锁后会自动选择一个"牺牲者"回滚来解除死锁。但自动回滚不代表可以不管——**频繁死锁会严重影响吞吐量**,理解死锁的成因和排查方法是后端工程师的必备技能。
|
||||
|
||||
> [!QUESTION] 在往下读之前,先想一个问题
|
||||
> 如果你的线上服务突然出现大量 `Deadlock found when trying to get lock` 报错,你的第一步应该做什么?
|
||||
> 带着这个问题往下读,你会在「排查工作流」部分找到答案。
|
||||
|
||||
## 常见死锁场景
|
||||
|
||||
@@ -15,19 +21,32 @@ create time: 2026-05-16 00:00
|
||||
> 所有死锁都可以归结为同一个模式:**每个事务都持有对方需要的资源,同时又在等待对方持有的资源**。
|
||||
> 想象两个人从桥的两端相向而行,桥只能容一人通过——谁也不退让,就卡住了。数据库里的"退让"就是回滚一个事务。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
T1["事务 A<br/>持有 user_id=1 的锁"] -->|"等待 user_id=2 的锁"| T2["事务 B<br/>持有 user_id=2 的锁"]
|
||||
T2 -->|"等待 user_id=1 的锁"| T1
|
||||
|
||||
style T1 fill:#FF6B6B,color:#fff
|
||||
style T2 fill:#4ECDC4,color:#fff
|
||||
```
|
||||
|
||||
### 场景一:交叉顺序加锁
|
||||
|
||||
**最经典、最容易复现的场景。** 核心原因是两个事务对相同资源的访问顺序不一致。
|
||||
|
||||
```sql
|
||||
-- 事务 A -- 事务 B
|
||||
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 加 X 锁 user_id=1
|
||||
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 尝试加 X 锁 user_id=2 → 被 T2 阻塞
|
||||
-- Step 1: 两个事务各拿一把锁(关键:加锁顺序相反)
|
||||
-- 事务 A
|
||||
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 拿到 user_id=1 的 X 锁
|
||||
-- 事务 B
|
||||
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 拿到 user_id=2 的 X 锁
|
||||
|
||||
UPDATE accounts SET balance = balance + 100 WHERE user_id = 1; -- 尝试加 X 锁 user_id=1 → 被 T1 阻塞
|
||||
UPDATE accounts SET balance = balance - 100 WHERE user_id = 2; -- 永远不会执行
|
||||
|
||||
-- 结果:T1 等 T2 的 user_id=2,T2 等 T1 的 user_id=1 → 死锁
|
||||
-- Step 2: 各自请求对方持有的锁 → 死锁
|
||||
-- 事务 A
|
||||
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 等 user_id=2 → 被事务 B 挡住
|
||||
-- 事务 B
|
||||
UPDATE accounts SET balance = balance + 100 WHERE user_id = 1; -- 等 user_id=1 → 被事务 A 挡住
|
||||
-- → 事务 A 等事务 B,事务 B 等事务 A → 环形等待 → 死锁
|
||||
```
|
||||
|
||||
> [!QUESTION] ❓ 思考一下
|
||||
@@ -42,33 +61,51 @@ UPDATE accounts SET balance = balance - 100 WHERE user_id = 2; -- 永远不会
|
||||
|
||||
### 场景二:范围查询 + 间隙锁
|
||||
|
||||
这个场景比场景一"隐蔽"得多——表面上只是普通的查询和插入,却因为 **Gap Lock(间隙锁)** 撞车了。
|
||||
|
||||
> [!NOTE] 先搞懂:什么是 Gap Lock?
|
||||
> 想象一张表的 id 列有记录 `1, 5, 10`,它们之间存在两个"空隙":`(1,5)` 和 `(5,10)`。
|
||||
>
|
||||
> 在 RR 隔离级别下,当你执行 `SELECT ... FOR UPDATE WHERE id BETWEEN 2 AND 9` 时,InnoDB 不仅锁住 id=5 这条记录本身,还会**锁住它两边的空隙**——这就是 Gap Lock。它的目的是:**防止你在这些空隙里插入新记录(防幻读)**。
|
||||
>
|
||||
> 就像你在图书馆书架上取下第 5 号书,顺手在 4 号和 6 号之间放了块"占位牌"——别人不能在这个位置插入新书。
|
||||
|
||||
> [!WARNING] RC vs RR 的关键差异
|
||||
> 在 **REPEATABLE READ(RR)** 隔离级别下,InnoDB 会启用 Gap Lock。这意味着范围查询"锁定了一段区间"——不仅锁了存在的记录,还锁了它们之间的"空隙"。
|
||||
> 如果你不需要可重复读保证,**考虑降级到 READ COMMITTED(RC)**,Gap Lock 将不复存在。
|
||||
|
||||
```sql
|
||||
-- 假设表中有 id = 1, 5, 10 三条记录
|
||||
下面用一个具体例子演示死锁是如何发生的。假设表中有 id = 1, 5, 10 三条记录:
|
||||
|
||||
-- 事务 A -- 事务 B
|
||||
BEGIN; BEGIN;
|
||||
SELECT * FROM items INSERT INTO items (id, name)
|
||||
WHERE id BETWEEN 2 AND 9 VALUES (5, 'item5');
|
||||
FOR UPDATE; -- 拿到 X 锁(id=5) + Gap(1,5) + Gap(5,10)
|
||||
-- Gap(5,10) 阻塞了下面的插入
|
||||
|
||||
INSERT INTO items (id, name) INSERT INTO items (id, name)
|
||||
VALUES (5, 'test'); VALUES (7, 'new_item');
|
||||
-- 尝试插入 id=5 → -- 尝试插入 id=7 → 落入 Gap(5,10)
|
||||
-- 已被 T_A 的 NX 锁 -- 被 T_A 的 Gap Lock 阻塞!
|
||||
-- T_A 试图插入 id=7 → 被 T_B 阻塞
|
||||
-- → 死锁!
|
||||
```sql
|
||||
-- Step 1: 事务 A 先执行范围查询
|
||||
BEGIN;
|
||||
SELECT * FROM items WHERE id BETWEEN 2 AND 9 FOR UPDATE;
|
||||
-- 结果:查到 id=5 一条记录
|
||||
-- 隐含动作:对 id=5 加 X 锁 + Gap(1,5) + Gap(5,10)
|
||||
-- 这两个"空隙"被锁住,别人不能往里面插数据
|
||||
```
|
||||
|
||||
> [!NOTE] 为什么间隙锁会导致死锁?
|
||||
> 很多人不理解:`SELECT ... FOR UPDATE` 只是查数据,为什么要锁"不存在的记录"?
|
||||
> InnoDB 的设计哲学是:**防止幻读**。如果 T_A 查到 2~9 之间没有 id=7 的记录,等它稍后想插入时却被告知"已有人占了",这就是幻读。所以它在 5 和 10 之间也加了一把"虚拟钥匙"。
|
||||
```sql
|
||||
-- Step 2: 事务 B 尝试往空隙里插入
|
||||
BEGIN;
|
||||
INSERT INTO items (id, name) VALUES (7, 'new_item');
|
||||
-- id=7 落在 Gap(5,10) 范围内 → 被事务 A 的 Gap Lock 阻塞
|
||||
-- 事务 B 拿到一个"插入意向锁"(Insert Intention Lock),在 Gap Lock 前排队
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Step 3: 事务 A 也要往同一个空隙里插
|
||||
INSERT INTO items (id, name) VALUES (5, 'test');
|
||||
-- id=5 已经存在,但 A 自己持有该记录的 X 锁,似乎可以直接插入?
|
||||
-- 问题出在这里:B 的"插入意向锁"和 A 的 Gap Lock 互斥
|
||||
-- A 要等 B 释放意向锁,B 在等 A 释放 Gap Lock → 双方互相等待 → 死锁!
|
||||
```
|
||||
|
||||
> [!QUESTION] ❓ 灵魂拷问
|
||||
> 一个 `SELECT ... FOR UPDATE` 查数据的操作,为什么会和 `INSERT` 冲突?
|
||||
> 答案在于 InnoDB 的设计哲学:**防止幻读**。如果 A 查到 2~9 之间没有 id=7 的记录,等它稍后想插入时却被别人抢了先,这就是幻读。所以 Gap Lock 相当于给整个空隙加了"预约"。
|
||||
>
|
||||
> 这个机制在大多数 OLTP 业务中是过度保护——换成 RC 就能彻底消除这类死锁。
|
||||
> 但在大多数 OLTP 业务中,这种保护是过度的——**换成 RC 隔离级别**就能彻底消除 Gap Lock,从根本上解决这类死锁。
|
||||
|
||||
### 场景三:批量操作的非确定性顺序
|
||||
|
||||
@@ -103,41 +140,54 @@ DELETE FROM orders WHERE id IN (
|
||||
SHOW ENGINE INNODB STATUS\G
|
||||
```
|
||||
|
||||
切换到 `trx` 段(向下滚动),定位到 `LATEST DETECTED DEADLOCK` 区域:
|
||||
切换到 `trx` 段(向下滚动),定位到 `LATEST DETECTED DEADLOCK` 区域。下面逐行拆解一份真实的死锁日志,关键信息已用注释标注:
|
||||
|
||||
```
|
||||
------------------------
|
||||
LATEST DETECTED DEADLOCK
|
||||
------------------------
|
||||
2026-05-16 10:30:45.123
|
||||
*** (1) TRANSACTION:
|
||||
2026-05-16 10:30:45.123 -- ① 死锁发生的时间点
|
||||
|
||||
*** (1) TRANSACTION: -- ② 事务 1 的基本信息
|
||||
TRANSACTION 123456, 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 7890, OS thread handle 140123, query id id 12345
|
||||
-- "ACTIVE 5 sec":事务已经运行了 5 秒
|
||||
-- "LOCK WAIT":正在等待锁
|
||||
|
||||
*** (1) WAITING FOR THIS LOCK to be granted:
|
||||
*** (1) WAITING FOR THIS LOCK to be granted: -- ③ 事务 1 在等什么锁?
|
||||
RECORD LOCKS space id 42 page no 12 n bits 72 index PRIMARY of table `app`.`orders`
|
||||
trx id 123456 lock_mode X locks rec but not gap waiting
|
||||
Record lock, heap no 5 PHYSICAL RECORD: p8: 0x55 length 6: 1001
|
||||
-- "index PRIMARY":锁在主键索引上
|
||||
-- "lock_mode X":要拿排他锁(X锁 = 写锁)
|
||||
-- "rec but not gap":只锁记录本身,不锁间隙
|
||||
-- "heap no 5":物理位置第 5 行,对应记录值 1001
|
||||
|
||||
*** (2) TRANSACTION:
|
||||
*** (2) TRANSACTION: -- ④ 事务 2 的基本信息
|
||||
TRANSACTION 123457, ACTIVE 3 sec inserting
|
||||
mysql tables in use 1, locked 1
|
||||
|
||||
*** (2) HOLDING THE LOCK(S):
|
||||
*** (2) HOLDING THE LOCK(S): -- ⑤ 事务 2 持有哪些锁?
|
||||
RECORD LOCKS space id 42 page no 12 n bits 72 index PRIMARY of table `app`.`orders`
|
||||
trx id 123457 lock mode S locks rec but not gap
|
||||
Record lock, heap no 5 PHYSICAL RECORD: p8: 0x55 length 6: 1001
|
||||
-- 事务 2 持有 id=1001 的共享锁(S锁 = 读锁)
|
||||
|
||||
*** (2) WAITING FOR THIS LOCK to be granted:
|
||||
*** (2) WAITING FOR THIS LOCK to be granted: -- ⑥ 事务 2 在等什么锁?
|
||||
RECORD LOCKS space id 42 page no 13 n bits 72 index idx_status of table `app`.`orders`
|
||||
trx id 123457 lock_mode X locks rec but not gap waiting
|
||||
Record lock, heap no 3 PHYSICAL RECORD: p8: 0x44 length 6: 2001
|
||||
-- 事务 2 在等 idx_status 索引上 id=2001 的排他锁
|
||||
-- 但这个锁被事务 1 持有(日志中省略了这部分)
|
||||
|
||||
*** WE ROLL BACK TRANSACTION (2)
|
||||
*** WE ROLL BACK TRANSACTION (2) -- ⑦ InnoDB 选择回滚事务 2
|
||||
```
|
||||
|
||||
> [!TIP] 快速读懂日志的口诀
|
||||
> **"谁在等什么,谁拿着什么,谁被杀了"** —— 三句话就能说清一次死锁。
|
||||
|
||||
### 解读模板
|
||||
|
||||
> [!CHECKLIST] 逐行拆解法
|
||||
@@ -168,15 +218,17 @@ Transaction 2: 持有的锁 → 等待的锁(将被回滚的那个)
|
||||
```sql
|
||||
-- 查看当前阻塞链(谁在等谁)
|
||||
SELECT
|
||||
r.trx_id AS waiting_trx_id,
|
||||
r.trx_mysql_thread_id AS waiting_thread,
|
||||
r.req_query AS waiting_query,
|
||||
b.trx_id AS blocking_trx_id,
|
||||
r.trx_id AS waiting_trx_id, -- 被阻塞的事务 ID
|
||||
r.trx_mysql_thread_id AS waiting_thread, -- 被阻塞的线程(对应 SHOW PROCESSLIST 中的 Id)
|
||||
r.trx_query AS waiting_query, -- 被阻塞的 SQL(可能为 NULL,如果事务还没发新 SQL)
|
||||
b.trx_id AS blocking_trx_id, -- 持有锁的事务 ID("罪魁祸首")
|
||||
b.trx_mysql_thread_id AS blocking_thread,
|
||||
b.trx_query AS blocking_query
|
||||
FROM performance_schema.data_lock_waits AS w
|
||||
INNER JOIN information_schema.innodb_trx AS b ON w.requesting_engine_tx_id = b.trx_id
|
||||
INNER JOIN information_schema.innodb_trx AS r ON w.blocking_engine_tx_id = r.trx_id;
|
||||
b.trx_query AS blocking_query -- 持有锁的事务正在执行的 SQL
|
||||
FROM performance_schema.data_lock_waits AS w -- 锁等待关系表:记录"谁在等谁"
|
||||
INNER JOIN information_schema.innodb_trx AS b
|
||||
ON w.blocking_engine_transaction_id = b.trx_id -- 通过阻塞关系找到"持有锁的事务"
|
||||
INNER JOIN information_schema.innodb_trx AS r
|
||||
ON w.requesting_engine_transaction_id = r.trx_id; -- 找到"等待锁的事务"
|
||||
```
|
||||
|
||||
> [!NOTE] performance_schema vs SHOW ENGINE
|
||||
@@ -185,21 +237,24 @@ INNER JOIN information_schema.innodb_trx AS r ON w.blocking_engine_tx_id = r
|
||||
>
|
||||
> 生产环境建议两者配合:`innodb_print_all_deadlocks` 兜底 + 监控脚本轮询 `data_lock_waits`。
|
||||
|
||||
> [!QUESTION] ❓ 实际使用技巧
|
||||
> 如果 `waiting_query` 显示为 NULL,说明该事务还在等上一条 SQL 的锁。此时可以结合 `blocking_query` 定位持有锁的 SQL——**找到"持锁者"比找"等待者"更重要**,因为优化"持锁者"才能从根本上释放锁。
|
||||
|
||||
### 乐观锁替代方案
|
||||
|
||||
并非所有场景都需要排他锁。考虑使用乐观锁(Optimistic Locking)来从根本上消除冲突:
|
||||
并非所有场景都需要排他锁。乐观锁的核心思想是:**不提前加锁,而是在提交时检查数据有没有被别人改过**——如果被改了,就重试。
|
||||
|
||||
```sql
|
||||
-- 悲观锁:先加锁再判断
|
||||
-- 悲观锁:先加锁再操作(别人只能排队等)
|
||||
BEGIN;
|
||||
SELECT balance FROM accounts WHERE user_id = 1 FOR UPDATE; -- 直接锁住
|
||||
SELECT balance FROM accounts WHERE user_id = 1 FOR UPDATE; -- 直接锁住,别人进不来
|
||||
UPDATE accounts SET balance = 900 WHERE user_id = 1;
|
||||
COMMIT;
|
||||
|
||||
-- ✅ 乐观锁:先改再校验(基于版本号)
|
||||
-- ✅ 乐观锁:先改再校验(基于版本号,不阻塞任何人)
|
||||
UPDATE accounts SET balance = 900, version = version + 1
|
||||
WHERE user_id = 1 AND version = 5; -- 只有版本仍为 5 时才更新
|
||||
-- affected_rows = 1 → 成功;affected_rows = 0 → 重试
|
||||
-- affected_rows = 1 → 成功;affected_rows = 0 → 说明别人先改了,重试
|
||||
```
|
||||
|
||||
> [!QUESTION] ❓ 什么时候该用乐观锁?
|
||||
@@ -213,27 +268,27 @@ flowchart TD
|
||||
B --> C["提取涉及的事务 SQL"]
|
||||
C --> D["复现死锁场景"]
|
||||
D --> E{"根因分析"}
|
||||
|
||||
|
||||
E -->|"加锁顺序不一致"| F["统一加锁顺序"]
|
||||
E -->|"范围查询间隙锁"| G["缩小 WHERE 范围<br/>或改用 RC"]
|
||||
E -->|"批量操作无序"| H["ORDER BY 主键后再操作"]
|
||||
E -->|"大事务持锁时间长"| I["拆小事务<br/>减少持锁范围"]
|
||||
|
||||
|
||||
F --> J["压测验证"]
|
||||
G --> J
|
||||
H --> J
|
||||
I --> J
|
||||
|
||||
|
||||
J --> K{"死锁消失?"}
|
||||
K -->|是| L["🎉 上线监控"]
|
||||
K -->|是| L["上线监控"]
|
||||
K -->|否| D
|
||||
|
||||
|
||||
style L fill:#00D866,color:#fff
|
||||
```
|
||||
|
||||
## Go 中的死锁处理
|
||||
|
||||
在 Go 应用层应对死锁的核心思路是:**捕获错误码 1213 → 回滚事务 → 重试**。
|
||||
在 Go 应用层应对死锁的核心思路是:**捕获错误码 1213 → 回滚事务 → 指数退避 → 重试**。
|
||||
|
||||
```go
|
||||
import (
|
||||
|
||||
Reference in New Issue
Block a user