13 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
2026-05-16 00:00 |
死锁与排查
概述
死锁是两个或多个事务互相等待对方释放锁资源的环形依赖。MySQL InnoDB 内置了死锁检测机制,会自动选择其中一个事务回滚来解除死锁。理解死锁的成因和排查方法是后端工程师的必备技能。
常见死锁场景
[!TIP] 死锁的本质:环形等待 所有死锁都可以归结为同一个模式:每个事务都持有对方需要的资源,同时又在等待对方持有的资源。 想象两个人从桥的两端相向而行,桥只能容一人通过——谁也不退让,就卡住了。数据库里的"退让"就是回滚一个事务。
场景一:交叉顺序加锁
最经典、最容易复现的场景。 核心原因是两个事务对相同资源的访问顺序不一致。
-- 事务 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 升序):
-- 统一按照 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 将不复存在。
-- 假设表中有 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 引擎的优化器可能会根据统计信息、成本模型或执行计划改变行锁定的实际顺序,这与你的预期并不一致。
-- 批量删除 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,把全量死锁写入错误日志。
这是排查死锁的第一入口:
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] 逐行拆解法 按以下顺序阅读,避免被大量信息干扰:
- 看时间:
2026-05-16 10:30:45.123→ 确认是不是当前线上问题- 定位两个事务:
*** (1)和*** (2)- 分别看各自主张:"持有的锁" + "等待的锁"
- 画等价图:T1 holds A → wants B, T2 holds B → wants A → 环形依赖成立
- 找根因:哪个操作触发了什么锁?为什么需要那个锁?
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 的运行时锁视图:
-- 查看当前阻塞链(谁在等谁)
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)来从根本上消除冲突:
-- 悲观锁:先加锁再判断
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%),乐观锁的性能远超悲观锁——因为它不需要数据库层面的锁开销。但一旦冲突变频繁,回滚和重试的成本会超过锁本身。
排查工作流
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 → 回滚事务 → 重试。
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 | 确定性顺序 | 低 |
| 索引优化 | 减少范围扫描 | 中 |
| 死锁检测超时设置 | 缩短失败等待时间 | 低 |
# 相关配置
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 中数据库事务的最佳实践