vault backup: 2026-05-21 22:11:56

This commit is contained in:
hhs
2026-05-21 22:11:56 +08:00
parent 9fc46eac24
commit c3779073e9
5 changed files with 700 additions and 263 deletions
@@ -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 (