This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/MySQL/27-死锁与排查.md
T
2026-05-17 00:06:11 +08:00

317 lines
13 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 内置了死锁检测机制,会自动选择其中一个事务回滚来解除死锁。理解死锁的成因和排查方法是后端工程师的必备技能。
## 常见死锁场景
> [!TIP] 死锁的本质:环形等待
> 所有死锁都可以归结为同一个模式:**每个事务都持有对方需要的资源,同时又在等待对方持有的资源**。
> 想象两个人从桥的两端相向而行,桥只能容一人通过——谁也不退让,就卡住了。数据库里的"退让"就是回滚一个事务。
### 场景一:交叉顺序加锁
**最经典、最容易复现的场景。** 核心原因是两个事务对相同资源的访问顺序不一致。
```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 阻塞
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 → 死锁
```
> [!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)
```
### 场景二:范围查询 + 间隙锁
> [!WARNING] RC vs RR 的关键差异
> 在 **REPEATABLE READ(RR)** 隔离级别下,InnoDB 会启用 Gap Lock。这意味着范围查询"锁定了一段区间"——不仅锁了存在的记录,还锁了它们之间的"空隙"。
> 如果你不需要可重复读保证,**考虑降级到 READ COMMITTED(RC)**,Gap Lock 将不复存在。
```sql
-- 假设表中有 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 阻塞
-- → 死锁!
```
> [!NOTE] 为什么间隙锁会导致死锁?
> 很多人不理解:`SELECT ... FOR UPDATE` 只是查数据,为什么要锁"不存在的记录"?
> InnoDB 的设计哲学是:**防止幻读**。如果 T_A 查到 2~9 之间没有 id=7 的记录,等它稍后想插入时却被告知"已有人占了",这就是幻读。所以它在 5 和 10 之间也加了一把"虚拟钥匙"。
>
> 这个机制在大多数 OLTP 业务中是过度保护——换成 RC 就能彻底消除这类死锁。
### 场景三:批量操作的非确定性顺序
> [!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:
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
*** (1) WAITING FOR THIS LOCK to be granted:
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
*** (2) TRANSACTION:
TRANSACTION 123457, ACTIVE 3 sec inserting
mysql tables in use 1, locked 1
*** (2) HOLDING THE LOCK(S):
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) WAITING FOR THIS LOCK to be granted:
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
*** WE ROLL BACK TRANSACTION (2)
```
### 解读模板
> [!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,
r.trx_mysql_thread_id AS waiting_thread,
r.req_query AS waiting_query,
b.trx_id AS blocking_trx_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;
```
> [!NOTE] performance_schema vs SHOW ENGINE
> - `SHOW ENGINE`:**事后分析**——死锁已经发生、已经被回滚后,只能看到历史记录
> - `performance_schema`:**实时监控**——可以看到正在发生的锁等待链,帮你提前发现即将爆发的问题
>
> 生产环境建议两者配合:`innodb_print_all_deadlocks` 兜底 + 监控脚本轮询 `data_lock_waits`。
### 乐观锁替代方案
并非所有场景都需要排他锁。考虑使用乐观锁(Optimistic Locking)来从根本上消除冲突:
```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/26-锁机制总览]] — 各种锁类型及其交互规则
- [[hhs/MySQL/25-MVCC 原理]] — MVCC 如何通过一致性读避免不必要的锁
- [[hhs/DEV/Go-Database]] — Go 中数据库事务的最佳实践