灯下哥谭 灯下哥谭
首页
关于
  • Hermes Agent 平台
  • Claude Code
  • OpenClaw
  • GPU 推理节点运维
  • DeepSeek Harness
  • MySQL 运维知识地图
  • Elasticsearch 运维知识地图
  • Redis 运维知识地图
  • TiDB 体系
  • DBA 常用 SQL 与命令
  • Nginx 运维知识地图
  • Prometheus 监控
  • Docker
  • Systemd
  • Iptables
  • Firewalld
  • Sshd
  • MySQL8 运维 SOP 手册
  • MySQL 实战 45 讲(读书笔记)
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

灯下哥谭

灯还亮着
首页
关于
  • Hermes Agent 平台
  • Claude Code
  • OpenClaw
  • GPU 推理节点运维
  • DeepSeek Harness
  • MySQL 运维知识地图
  • Elasticsearch 运维知识地图
  • Redis 运维知识地图
  • TiDB 体系
  • DBA 常用 SQL 与命令
  • Nginx 运维知识地图
  • Prometheus 监控
  • Docker
  • Systemd
  • Iptables
  • Firewalld
  • Sshd
  • MySQL8 运维 SOP 手册
  • MySQL 实战 45 讲(读书笔记)
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • MySQL

    • MySQL 运维知识地图:从入门配置到高可用排障
    • MySQL8 配置文件 my.cnf 重要参数解读
    • MySQL 导出 CSV 中文乱码:字符集链路从头讲一遍
    • MySQL 角色管理
    • MySQL网络抓包审计
    • MySQL 性能压测:Sysbench 1.0 实战
    • MySQL Router 实现读写分离
    • Gh-ost重建表,清除表碎片率
    • MySQL MGR配合MySQL-router实现innodb-cluster
    • MySQL 快速分析binlog定位问题
    • MySQL执行计划分析
    • DBA常用SQL和命令整理备查
    • 单表数据同步方案选型:为什么不该用 mysqldump 做「实时同步」
    • MySQL的事务隔离级别
    • MySQL存储过程批量生成数据
    • MySQL insert on duplicate key update,replace into , insert ignore的理解
    • MySQL不同字符集之间的区别和选择
    • MySQL为什么有时候会选错索引
    • MySQL死锁问题
      • 前言
      • 相关概念
        • 并发控制
        • 事务的特性
      • 死锁的定义与危害
        • 什么是死锁
        • 死锁的危害
      • 死锁的典型场景
        • 场景一:互相请求对方持有的锁
        • 场景二:间隙锁导致的死锁
      • 死锁的预防和处理
        • 预防措施
        • 死锁检测和处理
      • 怎么读死锁日志
      • 坑与边界
      • 总结
      • 参考资料
    • MySQL使用SQL语句查重去重
    • MySQLdump逻辑备份
    • MySQL 基于 GTID 主从复制:跳过异常事务的正确姿势
    • MySQL8快速克隆插件使用指南
    • MySQL8双1设置保障安全
    • MySQL锁
    • innodb cluster安装
    • OPTIMIZE TABLE 和 ANALYZE TABLE 的区别:用实测数据说话
    • MySQLReplicaSet 安装
    • MySQL 的 Left join、Right join 和 Inner join 的区别
    • ORDER BY 配合 LIMIT 触发的索引选择陷阱
  • Redis

  • 高性能KV

  • TiDB

  • Elasticsearch

  • 数据管道

  • 其他数据库

  • 数据库
  • MySQL
灯下哥谭
2022-03-12
目录

MySQL死锁问题

# MySQL死锁问题

# 前言

在日常开发中,我们经常会遇到数据库死锁问题。就像交通堵塞一样,当多个事务互相等待对方释放资源时,就形成了死锁。本文将围绕InnoDB存储引擎中的死锁问题,详细介绍相关概念、产生原因、典型场景以及解决方案。

版本说明

本文写于 2022-03。
InnoDB 死锁检测机制与排查思路未变,经 2026-07 复核仍适用。

# 相关概念

# 并发控制

并发控制(Concurrency Control)是数据库管理系统用于保证数据一致性的重要机制。

# 锁的类型

MySQL实现并发控制主要通过两种类型的锁:

  • 共享锁(Shared Lock,S锁):也叫读锁,允许多个事务同时读取同一资源
  • 排他锁(Exclusive Lock,X锁):也叫写锁,一个事务获取了写锁后,其他事务无法获取该资源的读锁或写锁

# 锁粒度

InnoDB存储引擎支持多种粒度的锁:

  • 表锁:锁定整张表,开销小但并发度低
  • 行锁:只锁定涉及的数据行,开销较大但并发度高
  • 间隙锁(Gap Lock):锁定索引记录之间的间隙,防止幻读
  • Next-Key Lock:行锁和间隙锁的组合

# 事务的特性

事务必须满足ACID特性:

  • 原子性(Atomicity):事务是不可分割的工作单位,要么全部执行,要么全部回滚
  • 一致性(Consistency):事务执行前后数据库必须保持一致状态
  • 隔离性(Isolation):事务执行过程中的中间状态对其他事务不可见
  • 持久性(Durability):事务一旦提交,其修改将永久保存在数据库中

# 事务隔离级别

MySQL支持四种事务隔离级别:

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED 不可能 可能 可能
REPEATABLE READ(默认) 不可能 不可能 可能
SERIALIZABLE 不可能 不可能 不可能

(这张表是 SQL 标准的定义。InnoDB 在 RR 下已用 MVCC + next-key lock 堵住了幻读,细节见 MySQL的事务隔离级别。本文关心的是它的副作用:间隙锁正是死锁的主要来源。)

# 死锁的定义与危害

# 什么是死锁

死锁是指两个或多个事务在执行过程中,因争夺资源而造成的一种互相等待的现象。比如:

  • 事务A持有资源1,等待资源2
  • 事务B持有资源2,等待资源1 这样两个事务就陷入了互相等待的死锁状态。

# 死锁的危害

  1. 事务无法完成,系统资源被长期占用
  2. 降低系统性能和吞吐量
  3. 可能引发连锁反应,导致更多事务被阻塞

# 死锁的典型场景

以下示例基于InnoDB存储引擎,隔离级别为REPEATABLE READ。

# 场景一:互相请求对方持有的锁

-- 准备数据
CREATE TABLE `test` (
  `id` int NOT NULL,
  `value` int DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

INSERT INTO test VALUES (1, 10), (2, 20);

-- Session A
START TRANSACTION;
UPDATE test SET value = 11 WHERE id = 1;

-- Session B
START TRANSACTION;
UPDATE test SET value = 21 WHERE id = 2;

-- Session A
UPDATE test SET value = 12 WHERE id = 2;  -- 等待Session B释放锁

-- Session B
UPDATE test SET value = 22 WHERE id = 1;  -- 死锁发生
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 场景二:间隙锁导致的死锁

先说一个常见的错误示例——很多文章会这样写:

-- Session A
START TRANSACTION;
SELECT * FROM orders WHERE status = 1 FOR UPDATE;
-- Session B
START TRANSACTION;
INSERT INTO orders VALUES (5, 1);   -- 被 A 的间隙锁阻塞
-- Session A
INSERT INTO orders VALUES (6, 1);   -- 号称"死锁发生"
1
2
3
4
5
6
7
8

这个例子复现不出来。A 自己插入不会被自己持有的间隙锁挡住(锁是按事务归属的),所以 A 的 INSERT 直接成功,B 继续等,不构成环路——只是单向阻塞。死锁必须是两个事务互相等对方。

真正能稳定复现间隙锁死锁的是下面这个:两个事务先各自拿到同一段间隙的共享间隙锁(S-gap),再各自申请插入意向锁(II-gap)。间隙锁之间是互相兼容的,所以两边都能拿到;但插入意向锁与别人的间隙锁冲突,于是两边都在等对方放手。

CREATE TABLE `orders` (
  `id` int NOT NULL,
  `status` int DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB;

INSERT INTO orders VALUES (1, 1), (10, 1);
-- 隔离级别必须是 RR(RC 没有间隙锁,复现不了)
1
2
3
4
5
6
7
8
9
时序 Session A Session B
① BEGIN;
SELECT * FROM orders WHERE id = 5 FOR SHARE;
→ 空结果,但在 (1,10) 上拿到共享间隙锁
② BEGIN;
SELECT * FROM orders WHERE id = 6 FOR SHARE;
→ 同一间隙,间隙锁互相兼容,也拿到了
③ INSERT INTO orders VALUES (5, 1);
→ 申请插入意向锁,被 B 的间隙锁挡住,开始等
④ INSERT INTO orders VALUES (6, 1);
→ 同样被 A 的间隙锁挡住 → 环路成立,死锁

第 ④ 步会立刻报错:

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
1

这个模式在业务里对应的是最经典的一类写法:「先查有没有,没有就插入」(check-then-insert)。两个并发请求查同一个不存在的键,各自拿到同一段间隙锁,然后各自去插——线上大量「幂等写入」的代码都长这样。

正解不是加重试,而是别用 check-then-insert:给业务键建唯一索引,直接 INSERT ... ON DUPLICATE KEY UPDATE(见 insert on duplicate key update 的理解),让唯一索引去做冲突判定,不要自己先 SELECT 一次。

# 死锁的预防和处理

# 预防措施

  1. 按固定顺序访问表和行

    • 对多个表进行操作时,应该按照相同的顺序
    • 批量更新数据时,按主键或索引顺序进行更新
  2. 合理设计事务

    • 保持事务尽量短小
    • 一次性锁定所需要的所有资源
    • 避免事务中的用户交互
  3. 优化表结构和索引

    • 合理设计索引,避免全表扫描
    • 避免使用过多的锁定范围
  4. 应用层优化

    • 使用乐观锁替代悲观锁
    • 适当的重试机制
    • 合理设置事务隔离级别

# 死锁检测和处理

InnoDB有两种处理死锁的方式:

  1. 等待超时

    • 通过innodb_lock_wait_timeout参数设置
    • 默认值为50秒
  2. 死锁检测

    • 通过innodb_deadlock_detect参数控制
    • 发现死锁后,会回滚代价较小的事务
-- 查看和设置等待超时时间
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
SET GLOBAL innodb_lock_wait_timeout = 50;

-- 查看死锁检测状态
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
1
2
3
4
5
6

# 怎么读死锁日志

前面所有分析都建立在「你知道是哪两条 SQL 撞上了」的前提上。线上死锁是随机发生的,抓现场只有一个办法——读 InnoDB 的死锁记录。

默认只能看到最近一次死锁:

SHOW ENGINE INNODB STATUS\G
1

在输出里找 LATEST DETECTED DEADLOCK 段落:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-08-24 15:02:11 0x7f1c

*** (1) TRANSACTION:
TRANSACTION 4213, ACTIVE 6 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 88, query id 1024 192.0.2.50 app update
INSERT INTO orders VALUES (5, 1)

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 42 page no 4 n bits 72 index PRIMARY of table `demo`.`orders`
trx id 4213 lock_mode X locks gap before rec

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 42 page no 4 n bits 72 index PRIMARY of table `demo`.`orders`
trx id 4213 lock_mode X locks gap before rec insert intention waiting

*** (2) TRANSACTION:
...
*** WE ROLL BACK TRANSACTION (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

按这个顺序读,四步就能定位:

  1. 两个事务各自在执行哪条 SQL(每段 TRANSACTION 下面那条语句)——注意日志只显示当前正在等的那一条,不是整个事务,往往还得结合业务代码看这个事务前面还做了什么;
  2. HOLDS THE LOCK(S) vs WAITING FOR——把两边一对照,环路就出来了;
  3. index XXX of table——撞在哪个索引上。撞在二级索引和撞在主键,改法完全不同;
  4. lock_mode 的措辞决定了是哪一类死锁:
    • lock_mode X locks rec but not gap → 纯行锁冲突,多半是加锁顺序不一致(场景一),按固定顺序访问即可解决
    • locks gap before rec / insert intention waiting → 间隙锁 + 插入意向锁(场景二),是 check-then-insert 模式,改写法或降到 RC
    • lock_mode S → 有共享锁参与,检查是不是有多余的 FOR SHARE 或外键检查(外键约束会隐式加共享锁,是很容易漏掉的一个死锁来源)
  5. WE ROLL BACK TRANSACTION (n)——被回滚的是哪个,对应应用侧哪个请求收到了 1213。

生产环境必须打开全量死锁日志,否则只留最近一次,等你去看的时候现场早被冲掉了:

[mysqld]
innodb_print_all_deadlocks = ON
1
2

打开后每次死锁都会完整写进错误日志(不是慢日志),可以直接接告警。它是动态参数,也可以在线开:

SET GLOBAL innodb_print_all_deadlocks = ON;
1

验证:

SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';
1

预期输出:

+----------------------------+-------+
| Variable_name              | Value |
+----------------------------+-------+
| innodb_print_all_deadlocks | ON    |
+----------------------------+-------+
1
2
3
4
5

然后按上面场景二的时序手工造一次死锁,tail -f 错误日志应该立刻看到新记录。

配套看一眼死锁发生的频率(累计计数):

SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';
1

# 坑与边界

  • 死锁不是故障,超时才是。InnoDB 检测到死锁会立刻回滚一个事务(毫秒级),应用重试即可;真正拖垮系统的是 innodb_lock_wait_timeout(默认 50 秒)的锁等待——它不构成环路,检测不到,只能干等。看到大量连接卡住但没有 1213 报错,要查的是锁等待而不是死锁。
  • innodb_deadlock_detect=OFF 不要在线上关。有些高并发压测建议关掉它(检测本身在高并发热点行上有 O(n²) 开销),但关掉之后死锁不再被发现,会一路等到 innodb_lock_wait_timeout 才报错,故障时间从毫秒变成 50 秒。要关,必须同时把 innodb_lock_wait_timeout 调到 1~2 秒。
  • 回滚的是「代价最小」的事务,不是「后来的」那个。判据是事务在 undo 里累积的修改行数,所以一个刚开始的小事务可能反复被大事务撞掉——业务上表现为「某个接口偶发失败,且总是同一个」。
  • 重试必须重试整个事务,不能只重试报错的那条 SQL。死锁发生时该事务已经被完整回滚,前面执行成功的语句也没了。
  • 没走索引的 UPDATE 会锁住扫到的每一行,把本来毫不相干的两个事务变成互撞。排查死锁时先看两条 SQL 的执行计划有没有走索引(见 MySQL执行计划分析),这一步能解释掉相当一部分「看起来毫无关系却死锁了」的案例。
  • 外键会隐式加锁。父表有外键引用时,子表的写入会去父表加共享锁,死锁日志里会出现业务 SQL 里根本没提到的表。

# 总结

死锁是并发数据库系统中难以完全避免的问题。通过:

  • 理解死锁产生的原因
  • 采用合理的预防措施
  • 正确配置数据库参数
  • 优化应用程序设计

我们可以最大限度地减少死锁的发生,提高系统的可用性和性能。

# 参考资料

  • 《高性能MySQL》第三版
  • MySQL官方文档:https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks.html
#锁与并发#MySQL
上次更新: 9/11/2026

← MySQL为什么有时候会选错索引 MySQL使用SQL语句查重去重→

最近更新
01
DeepSeek Harness 实战 06|学习笔记:插件、工具、技能不在同一个维度上 原创
09-11
02
DeepSeek Harness 实战 05|让两个编码 Agent 共用一份长期记忆 原创
09-09
03
DeepSeek Harness 实战 04|学习笔记:从「已知限制」里读出三处设计张力 原创
09-08
更多文章>
Theme by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式