Files
cs-note/hhs/MySQL/06-事务与并发控制/27-死锁与排查.md
T
2026-05-24 11:42:38 +08:00

372 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, 死锁, Deadlock, 排查, 等待图]
create time: 2026-05-16 00:00
---
# 死锁与排查
## 概述
死锁是两个或多个事务互相等待对方释放锁资源的环形依赖。打个比方:两个人各拿一把钥匙,但都需要对方手里的钥匙才能开门——谁也不松手,就永远卡住了。
MySQL InnoDB 内置了死锁检测机制,发现死锁后会自动选择一个"牺牲者"回滚来解除死锁。但自动回滚不代表可以不管——**频繁死锁会严重影响吞吐量**,理解死锁的成因和排查方法是后端工程师的必备技能。
> [!QUESTION] 在往下读之前,先想一个问题
> 如果你的线上服务突然出现大量 `Deadlock found when trying to get lock` 报错,你的第一步应该做什么?
> 带着这个问题往下读,你会在「排查工作流」部分找到答案。
## 常见死锁场景
> [!TIP] 死锁的本质:环形等待
> 所有死锁都可以归结为同一个模式:**每个事务都持有对方需要的资源,同时又在等待对方持有的资源**。
> 想象两个人从桥的两端相向而行,桥只能容一人通过——谁也不退让,就卡住了。数据库里的"退让"就是回滚一个事务。
```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
-- 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 锁
-- 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] ❓ 思考一下
> 转账操作为什么容易出现这个问题?因为"扣款方"和"收款方"在两个事务中角色互换了:T_A 先转 1→2,T_B 先转 2→1。如果两笔转账都按 `MIN(user_id)` → `MAX(user_id)` 的顺序操作,就不可能出现循环等待。
**💡 修复方案**:在所有代码路径中统一加锁顺序(比如总是按 user_id 升序):
```sql
-- 统一按照 user_id 从小到大锁定
-- 无论转出还是转入,都是先 lock MIN(a,b),再 lock MAX(a,b)
```
### 场景二:范围查询 + 间隙锁
这个场景比场景一"隐蔽"得多——表面上只是普通的查询和插入,却因为 **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 将不复存在。
下面用一个具体例子演示死锁是如何发生的。假设表中有 id = 1, 5, 10 三条记录:
```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)
-- 这两个"空隙"被锁住,别人不能往里面插数据
```
```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 隔离级别**就能彻底消除 Gap Lock,从根本上解决这类死锁。
### 场景三:批量操作的非确定性顺序
> [!TIP] 经验法则
> **任何涉及多条记录的批量操作,都应当在代码层面显式排序。** SQL 引擎的优化器可能会根据统计信息、成本模型或执行计划改变行锁定的实际顺序,这与你的预期并不一致。
```sql
-- 批量删除 ORDER BY 不确定导致锁顺序不一致
DELETE FROM orders WHERE id IN (1001, 1002, 1003); -- 实际锁顺序可能是 1003, 1001, 1002
DELETE FROM orders WHERE id IN (1003, 1002, 1001); -- 期望顺序 1003, 1002, 1001
-- IN 列表的顺序不一定等于锁定的顺序,尤其当 Optimizer 重排时
-- ✅ 推荐写法:子查询中显式 ORDER BY
DELETE FROM orders WHERE id IN (
SELECT id FROM (
SELECT id FROM orders WHERE status = 'cancelled' ORDER BY id ASC
) AS ordered
);
```
## SHOW ENGINE INNODB STATUS 分析
> [!IMPORTANT] 只保留最近一次
> `SHOW ENGINE INNODB STATUS` **只显示最新的死锁记录**。如果线上频繁发生死锁,旧记录会被覆盖。
> 这就是为什么必须提前开启 `innodb_print_all_deadlocks`,把全量死锁写入错误日志。
这是排查死锁的第一入口:
```sql
SHOW ENGINE INNODB STATUS\G
```
切换到 `trx` 段(向下滚动),定位到 `LATEST DETECTED DEADLOCK` 区域。下面逐行拆解一份真实的死锁日志,关键信息已用注释标注:
```
------------------------
LATEST DETECTED DEADLOCK
------------------------
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 在等什么锁?
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 123457, ACTIVE 3 sec inserting
mysql tables in use 1, locked 1
*** (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 在等什么锁?
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) -- ⑦ InnoDB 选择回滚事务 2
```
> [!TIP] 快速读懂日志的口诀
> **"谁在等什么,谁拿着什么,谁被杀了"** —— 三句话就能说清一次死锁。
### 解读模板
> [!CHECKLIST] 逐行拆解法
> 按以下顺序阅读,避免被大量信息干扰:
> 1. **看时间**:`2026-05-16 10:30:45.123` → 确认是不是当前线上问题
> 2. **定位两个事务**:`*** (1)` 和 `*** (2)`
> 3. **分别看各自主张**:"持有的锁" + "等待的锁"
> 4. **画等价图**:T1 holds A → wants B, T2 holds B → wants A → 环形依赖成立
> 5. **找根因**:哪个操作触发了什么锁?为什么需要那个锁?
```
Transaction 1: 持有的锁 → 等待的锁
Transaction 2: 持有的锁 → 等待的锁(将被回滚的那个)
关键信息提取:
- 哪个索引触发了死锁?(index PRIMARY / idx_status)
- 锁的类型?(X lock, S lock, gap lock)
- 涉及哪条记录?(heap no 5, physical record = 1001)
- 被回滚的是哪个事务?(WE ROLL BACK TRANSACTION 2)
```
## 进阶排查手段
### 使用 performance_schema 实时观察
当 `SHOW ENGINE INNODB STATUS` 不够用时,可以直接查询 InnoDB 的运行时锁视图:
```sql
-- 查看当前阻塞链(谁在等谁)
SELECT
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 -- 持有锁的事务正在执行的 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
> - `SHOW ENGINE`:**事后分析**——死锁已经发生、已经被回滚后,只能看到历史记录
> - `performance_schema`:**实时监控**——可以看到正在发生的锁等待链,帮你提前发现即将爆发的问题
>
> 生产环境建议两者配合:`innodb_print_all_deadlocks` 兜底 + 监控脚本轮询 `data_lock_waits`。
> [!QUESTION] ❓ 实际使用技巧
> 如果 `waiting_query` 显示为 NULL,说明该事务还在等上一条 SQL 的锁。此时可以结合 `blocking_query` 定位持有锁的 SQL——**找到"持锁者"比找"等待者"更重要**,因为优化"持锁者"才能从根本上释放锁。
### 乐观锁替代方案
并非所有场景都需要排他锁。乐观锁的核心思想是:**不提前加锁,而是在提交时检查数据有没有被别人改过**——如果被改了,就重试。
```sql
-- 悲观锁:先加锁再操作(别人只能排队等)
BEGIN;
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 → 说明别人先改了,重试
```
> [!QUESTION] ❓ 什么时候该用乐观锁?
> 记住一个原则:**读多写少用乐观锁,写多读少用悲观锁**。如果你的业务并发写冲突概率很低(比如 < 5%),乐观锁的性能远超悲观锁——因为它不需要数据库层面的锁开销。但一旦冲突变频繁,回滚和重试的成本会超过锁本身。
## 排查工作流
```mermaid
flowchart TD
A["发现死锁告警"] --> B["SHOW ENGINE INNODB STATUS"]
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 -->|否| D
style L fill:#00D866,color:#fff
```
## Go 中的死锁处理
在 Go 应用层应对死锁的核心思路是:**捕获错误码 1213 → 回滚事务 → 指数退避 → 重试**。
```go
import (
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-sql-driver/mysql"
)
func WithRetry(db *sql.DB, retryTimes int, fn func(tx *sql.Tx) error) error {
for i := 0; i < retryTimes; i++ {
tx, err := db.Begin()
if err != nil {
return err
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
// MySQL 错误码 1213 = ER_LOCK_DEADLOCK
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1213 {
// 指数退避:第一次等 50ms,第二次 100ms,避免集体雪崩
time.Sleep(time.Millisecond * time.Duration(50*(1<<uint(i))))
continue // 死锁,重试整个事务
}
return err // 非死锁错误,直接返回
}
if err := tx.Commit(); err != nil {
return err
}
return nil // 成功
}
return fmt.Errorf("deadlock after %d retries", retryTimes)
}
```
**关键设计点:**
| 要点 | 说明 |
|------|------|
| **只重试 1213** | 不是所有 DB 错误都该重试。比如唯一键冲突(1062)重试不会有用,应直接返回 |
| **指数退避** | 两个事务同时死锁后各自立刻重试,可能再次死锁。加随机抖动可以分散重试风暴 |
| **先 Rollback 再判断** | 发生错误后必须先清理事务状态,否则后续操作都会失败 |
| **Commit 也需检查** | Commit 也可能因其他原因失败,不能忽略其返回值 |
> [!TIP] 实际生产建议
> - 重试次数建议 3~5 次,过多说明架构有根本问题
> - 加上 Prometheus/Grafana 监控死锁重试率,超过阈值应当告警而非无限重试
> - 对于支付、库存等敏感业务,考虑引入分布式乐观锁或消息队列串行化,而非依赖重试掩盖问题
## 预防策略汇总
| 策略 | 效果 | 实施难度 |
|------|------|---------|
| **固定加锁顺序** | 🏆 最有效 | 低 |
| **缩小事务范围** | 减少持锁窗口 | 低 |
| **使用 RC 隔离级别** | 消除 Gap Lock | 中(需评估业务影响)|
| **批量操作加 ORDER BY PK** | 确定性顺序 | 低 |
| **索引优化** | 减少范围扫描 | 中 |
| **死锁检测超时设置** | 缩短失败等待时间 | 低 |
```ini
# 相关配置
innodb_deadlock_detect = ON # 开启死锁检测(默认 ON)
innodb_lock_wait_timeout = 50 # 锁等待超时(秒)
innodb_print_all_deadlocks = ON # 记录所有死锁到错误日志
```
> [!WARNING] innodb_print_all_deadlocks
> 默认情况下,只有最新的死锁会在 `SHOW ENGINE INNODB STATUS` 中显示。开启此选项后,所有死锁都会记录到 MySQL 错误日志中——这对分析问题至关重要。
## 关联笔记
- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — 各种锁类型及其交互规则
- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — MVCC 如何通过一致性读避免不必要的锁
- [[hhs/DEV/Go-Database]] — Go 中数据库事务的最佳实践