372 lines
16 KiB
Markdown
372 lines
16 KiB
Markdown
|
|
---
|
|||
|
|
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 中数据库事务的最佳实践
|