这死锁,不打个招呼就来了
01
一切还得从报错日志说起
01
一切还得从报错日志说起
04:12~28.452 [traceId-] WARN SqlExceptionHelper - SQL Error: 1213, SQLState: 40001
04:12~28.452 [traceId-] ERROR SqlExceptionHelper - Deadlock found when trying to get lock; try restarting transaction
哎?居然死锁了。
先介绍一下业务场景: 消费kafka消息,根据id依次更新评论库的字段。OK,那就看看具体日志:
第一台服务器收到消息:{ 文章1:[ 评论A, 评论B, 评论C] } 。
第二台服务器收到消息:{ 文章1:[ 评论C, 评论A, 评论B] } 。
两条消息间隔几毫秒。根据这两条消息,基本就可以确定死锁的原因了。
02
原来是这样
02
原来是这样
先看下什么是两阶段协议:InnoDB 的两阶段锁协议(Two-Phase Locking, 2PL)是一种用于保证事务隔离性的并发控制机制,将锁的使用分为两个阶段:加锁阶段(Growing Phase)和 解锁阶段(Shrinking Phase)。
核心原则:
加锁阶段:事务只能申请锁,不能释放锁。 解锁阶段:事务只能释放锁,不能申请新锁。 两阶段分界点:事务第一次释放锁后,进入解锁阶段,不能再申请新锁。
简单来说: 行锁是在需要时候才加上,但用完了不会立即释放,而是事务结束再一起释放。
对于文章开头死锁的情况:
第一个事务对行加锁顺序: A → B → C。
第二个事务对行加锁顺序: C → A → B。
事务一拿到A记录的锁开始处理,此时事务二也对C记录加锁处理,处理完C记录之后,发现A的锁已经被占,于是阻塞。此时事务一执行完B记录,发现C的锁被占,也阻塞。事务一拿着A,B 等 C 释放,事务二拿着C 等 A 释放,于是就形成了死锁。
03
那就复现一下
03
那就复现一下
数据库版本:mysql 8.0.32 。
1.准备一张简单表:
CREATETABLE`test` (
`id`bigintunsignedNOTNULL AUTO_INCREMENT,
`content`varchar(100) COLLATE utf8mb4_unicode_ci DEFAULTNULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=34
2.准备表数据:
3.开启两个SQL命令行会话窗口,设置自动提交关闭:
set autocommit=0;
4.在会话窗口1中执行:
begin;
updatetestsetcontent='赵云1'whereid=31;
updatetestsetcontent='张飞1'whereid=32;
5.在会话窗口2中执行:
begin;
updatetestsetcontent='孙策2'whereid=33;
updatetestsetcontent='赵云2'whereid=31;
6.在会话窗口1中执行:
updatetestsetcontent='孙策1'whereid=33;
7.此时查看数据库状态:
showengineinnodbstatus;
可以看到出现死锁:
------------------------
LATEST DETECTED DEADLOCK
------------------------
xxxx-xx-xx xx:xx:xx 0x2200
*** (1) TRANSACTION:
TRANSACTION 10288, ACTIVE 520 sec starting index read
mysql tables in use1, locked1
LOCKWAIT3lockstruct(s), heapsize1128, 2rowlock(s), undolog entries 1
MySQL threadid11, OS thread handle 12248, queryid118 localhost ::1 root updating
updatetestsetcontent='赵云2'whereid=31
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS spaceid49 page no4 n bits 80index PRIMARY oftable`xxx`.`test` trx id10288 lock_mode X locks rec but not gap
Recordlock, heapno8PHYSICALRECORD: n_fields 6; compact format; info bits 0
......
*** (1) WAITING FOR THIS LOCKTO BE GRANTED:
RECORD LOCKS spaceid49 page no4 n bits 80index PRIMARY oftable`xxx`.`test` trx id10288 lock_mode X locks rec but not gap waiting
Recordlock, heapno6PHYSICALRECORD: n_fields 6; compact format; info bits 0
......
*** (2) TRANSACTION:
TRANSACTION 10287, ACTIVE 103 sec starting index read
mysql tables in use1, locked1
LOCKWAIT3lockstruct(s), heapsize1128, 3rowlock(s), undolog entries 2
MySQL threadid10, OS thread handle 16908, queryid120 localhost ::1 root updating
updatetestsetcontent='孙策1'whereid=33
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS spaceid49 page no4 n bits 72index PRIMARY oftable`xxx`.`test` trx id10287 lock_mode X locks rec but not gap
Recordlock, heapno6PHYSICALRECORD: n_fields 6; compact format; info bits 0
......
*** (2) WAITING FOR THIS LOCKTO BE GRANTED:
RECORD LOCKS spaceid49 page no4 n bits 80index PRIMARY oftable`xxx`.`test` trx id10287 lock_mode X locks rec but not gap waiting
Recordlock, heapno8PHYSICALRECORD: n_fields 6; compact format; info bits 0
......
*** WE ROLL BACK TRANSACTION (1)
04
先看下InnoDB锁的分类
04
先看下InnoDB锁的分类
共享锁(S): 也叫读锁(行级,表级)。允许多个事务同时获取同一资源。事务获得共享锁后,其他事务可以加共享锁,但不能加排他锁。 排他锁(X): 也叫写锁(行级,表级)。一个事务获取某个资源的排他锁后,其他事务不能获取该资源的任何锁。主要用于修改数据(INSERT, UPDATE, DELETE)。 意向锁(IS/IX): 也叫意向读(写)锁 (只有表级)。快速判断表中是否有行级锁存在,避免逐行检查。 ▲意向共享锁(IS):事务准备对某些行加 S 锁前,先加表级 IS 锁。 ▲意向排他锁(IX):事务准备对某些行加 X 锁前,先加表级 IX 锁。
全局锁:对整个数据库实例加锁,主要用于数据库备份。 表锁:对表进行加锁,在更细粒度的锁出现之前,表锁是常用控制并发的方式。 行锁:细粒度锁,对一条或多条记录加锁,更好的控制并发。
05
到底啥是行锁
05
到底啥是行锁
行锁是InnoDB 实现高并发事务处理的核心机制之一。
细粒度的控制并发。 实现ACID特性中隔离性的关键手段。 协调访问: 当一个事务修改一行数据时,须先获得该行的排他锁(X Lock),阻止其他事务读取(在严格隔离级别下)或修改这行数据。当读取一行数据时(当前读),它可能需要获得该行的共享锁(S Lock),阻止其他事务修改这行数据(但允许其他事务读取)。
基于索引: InnoDB 的行锁是加在索引记录上的。无论哪种行锁类型,最终都落在表的某个索引(包括聚簇索引和二级索引)的特定记录或记录范围上。所以在操作行时,如果条件字段没有索引,可能会造成行锁锁住了整张表。
记录锁(Record Lock):锁定索引记录本身。
▲作用: 防止脏读(Dirty Read)。 间隙锁(Gap Lock):锁定索引记录之间的范围(间隙),但不包括记录本身。
▲作用: 防止幻读(Phantom Read),阻止其他事务在锁定的间隙中插入新记录。如锁定值在 (10, 20) 之间的间隙,阻止插入值在 10 到 20 之间的新记录。只在 可重复读 和 串行化 隔离级别下使用。 临键锁(Next-Key Lock): 记录锁(Record Lock) + 间隙锁(Gap Lock) 的组合。
▲作用: InnoDB 在 可重复读 隔离级别下默认的行锁类型(加锁基本单位)。它锁定索引记录本身以及该记录之前的间隙。例如,锁定值在 (10, 20] 的范围(前开后闭:包括 20 本身,不包括 10)。它既能防止其他事务修改当前记录,也能防止在记录之前的间隙中插入新记录(从而解决幻读问题)。 插入意向锁(Insert Intention Lock):它是一种特殊类型的间隙锁(行级锁,不是意向锁(IS/IX)别弄混),在 InnoDB 的锁体系中属于独立的类别。
▲作用:协调多个事务在同一个间隙内进行插入操作,允许它们在不冲突的位置并发插入,同时与已存在的间隙锁(Gap Lock)互斥以防止幻读。 ▲兼容规则跟意向锁不同:与 Gap Lock 冲突,与其他 Insert Intention Lock 兼容,与 S Lock 兼容,与 X Lock 冲突。
| 请求锁 \ 已存在锁 | 共享锁 (S) | 排他锁 (X) | 间隙锁 (Gap) | 插入意向锁 (II Gap) |
|---|---|---|---|---|
| 共享锁 (S) | ||||
| 排他锁 (X) | ||||
| 插入意向锁 (II Gap) | ||||
| 间隙锁 (Gap) |
06
到底是咋加锁的 (可重复读隔离级别)
06
到底是咋加锁的 (可重复读隔离级别)
加锁基本单位
加锁的基本单位是next-key lock(前开后闭,加锁就是先加的next-key lock)。
Next-Key Lock 降级规则
场景 1:唯一索引等值查询(命中)
-- id 是唯一索引,且 id=5 存在
SELECT * FROMusersWHEREid = 5FORUPDATE;
降级行为:Next-Key Lock → 记录锁(Record Lock)。仅锁定 id=5 这行,不锁间隙。
原因:唯一索引保证值唯一,无需防止其他事务插入相同值。
场景 2:唯一索引等值查询(未命中)
-- id 是唯一索引,且 id=7 不存在(假设相邻记录 id=5 和 id=10)`
SELECT * FROMusersWHEREid = 7FORUPDATE;
降级行为: Next-Key Lock → 间隙锁(Gap Lock)。锁定间隙 (5, 10),不锁定具体记录。
原因:值不存在时,只需防止其他事务插入 id=7。
场景 3:非唯一索引等值查询(命中)
-- age 是非唯一索引(存在 age=20 的多条记录,假设相邻记录 age=25)
SELECT * FROMusersWHERE age = 20FORUPDATE;
锁定行为:保持 Next-Key Lock。锁定所有 age=20 的记录及相邻间隙,如 (15,20], (20,25)。
为何不降级:需防止其他事务插入相同值age=20,故必须锁间隙。
场景 4:非唯一索引范围查询
-- age 是非唯一索引
SELECT * FROMusersWHERE age >= 20AND age < 30FORUPDATE;
锁定行为:保持 Next-Key Lock。 锁定所有扫描到的行及间隙(如 [20,25), [25,30) 等)。
来个表格:
| 查询类型 | 索引性质 | 记录是否存在 | 降级结果 | 最终锁定范围 |
|---|---|---|---|---|
等值查询(=) | 记录锁 | id=5) | ||
等值查询(=) | 间隙锁 | (5,10) | ||
等值查询(=) | ||||
范围查询(>, <等) | ||||
07
常见行锁死锁场景
07
常见行锁死锁场景
不同顺序更新不同行 (交叉更新顺序)
事务 A: UPDATE table1 SET ... WHEREid = 1; (获取 id=1 的行锁)
事务 B: UPDATE table1 SET ... WHEREid = 2; (获取 id=2 的行锁)
事务 A: UPDATE table1 SET ... WHEREid = 2; (尝试获取 id=2 的行锁,被事务 B 阻塞)
事务 B: UPDATE table1 SET ... WHEREid = 1; (尝试获取 id=1 的行锁,被事务 A 阻塞)
死锁原因: 两个事务以相反的顺序获取锁(A:1->2, B:2->1),形成循环等待(A 等 B 释放 id=2, B 等 A 释放 id=1)。
关键点: 这是最基础、最普遍的死锁类型(如文章开始的案例)。唯一键冲突 (插入引发的死锁)
假设表有一个唯一索引 uniq_col。
事务 A: INSERTINTOtable (uniq_col, ...) VALUES (100, ...); (尝试插入 uinq_col=100。如果唯一键冲突,InnoDB 会尝试在冲突行上加一个 S(共享)锁。)
事务 B: INSERTINTOtable (uniq_col, ...) VALUES (100, ...); (同样尝试插入 uinq_col=100,同样尝试获取冲突行的 S 锁。S 锁之间不互斥,所以两个事务都成功获得了 S 锁。)
事务 A: INSERTINTOtable (uniq_col, ...) VALUES (100, ...) ONDUPLICATEKEYUPDATE ...; 或者后续尝试 UPDATE 或 DELETE 该冲突行。此时需要将 S 锁 升级为 X(排他)锁。但因为事务 B 也持有该行的 S 锁,升级被阻塞,等待事务 B 释放 S 锁。
事务 B: 同样尝试 ONDUPLICATEKEYUPDATE、UPDATE 或 DELETE 该冲突行,也需要升级为 X 锁。但因为事务 A 持有 S 锁,升级也被阻塞,等待事务 A 释放 S 锁。
死锁原因: 两个事务都持有了冲突行的 S 锁,然后都试图升级为 X 锁,互相等待对方释放 S 锁,形成死锁。
关键点: INSERT 遇到唯一键冲突时获取 S 锁的行为是触发点。ONDUPLICATEKEYUPDATE 是这种死锁的常见诱因。索引扫描顺序不一致
表有一个非唯一二级索引 idx_col。
事务 A: SELECT ... FROMtableWHERE idx_col BETWEEN10AND20FORUPDATE; (按索引顺序,比如先锁 idx_col=10 的记录,再锁 idx_col=11...)
事务 B: DELETEFROMtableWHERE idx_col = 15; (直接定位到 idx_col=15 的记录加锁)
事务 A 在扫描过程中,尝试锁定 idx_col=15 的记录时,发现该记录已被事务 B 锁定(X 锁),于是事务 A 阻塞,等待事务 B 释放锁。
同时,事务 B 在删除 idx_col=15 的记录时,可能需要检查或修改其他相关约束(比如回表更新聚簇索引),这个操作可能需要获取事务 A 已经持有的某个锁(比如在扫描过程中事务 A 已经锁定了聚簇索引上的某行或者间隙锁),导致事务 B 被阻塞,等待事务 A。
死锁原因: 事务 A 按索引顺序(范围)加锁,事务 B 直接定位特定记录加锁,两者加锁路径交叉,最终互相等待对方持有的锁。
关键点: 范围查询 (FOR UPDATE, UPDATE ... WHERE ..., DELETE ... WHERE ...) 的加锁顺序(由索引扫描顺序决定)与单点操作不一致。间隙锁冲突 (并发插入到同一间隙)
假设表当前有记录 id=5, id=10。
事务 A: SELECT * FROMtableWHEREid = 7FORUPDATE; (因为 id=7 不存在,InnoDB 会加一个 间隙锁 (Gap Lock),锁住 (5, 10) 这个区间)。
事务 B: SELECT * FROMtableWHEREid = 8FORUPDATE; (同样因为 id=8 不存在,也试图在 (5, 10) 区间加间隙锁。间隙锁之间不冲突,所以事务 B 也成功加锁)。
事务 A: INSERTINTOtable (id, ...) VALUES (7, ...); (尝试在间隙 (5,10) 中插入 id=7。插入需要获取该位置的 插入意向锁 (Insert Intention Lock)。插入意向锁与已有的间隙锁 冲突。事务 A 阻塞,等待事务 B 释放其间隙锁)。
事务 B: INSERTINTOtable (id, ...) VALUES (8, ...); (同样尝试在间隙 (5,10) 中插入 id=8。需要获取插入意向锁,与事务 A 持有的间隙锁冲突。事务 B 阻塞,等待事务 A 释放间隙锁)。
死锁原因: 两个事务都持有了同一个间隙上的间隙锁(Gap Lock),然后都尝试在该间隙内插入数据,需要获取插入意向锁(Insert Intention Lock),而插入意向锁与对方持有的间隙锁互斥,形成互相等待。
关键点: 发生在 可重复读 隔离级别(该级别使用间隙锁)。串行化 级别也会发生。读提交 级别通常没有间隙锁,此场景不易发生。更新与删除/更新的锁升级冲突
事务 A: UPDATEtableSET col2 = ... WHEREid = 1; (获取 id=1 的行锁 X 锁)
事务 B: DELETEFROMtableWHEREid = 1; (尝试获取 id=1 的 X 锁,被事务 A 阻塞)
事务 A: 执行另一个语句,比如 UPDATEtableSET ... WHEREid = 2; (尝试获取 id=2 的行锁 X 锁)
事务 B: 在等待 id=1 的锁期间,已经持有 id=2 的行锁(可能因为之前的操作,比如 SELECT ... FORUPDATE 或者 UPDATE 了 id=2)。此时事务 A 尝试获取 id=2 的锁会被事务 B 阻塞。
死锁原因: 事务 A 持有 id=1 锁,等待 id=2 锁;事务 B 持有 id=2 锁,等待 id=1 锁。形成循环等待。关键在于事务 B 在等待 id=1 时,它持有的其他锁(如 id=2)并没有释放。
变种: 同样适用于两个 UPDATE 操作互相等待对方持有的不同行的锁。隐式锁与显式锁的转换 (插入与删除/更新)
事务 A: INSERTINTOtable(id, ...) VALUES(100, ...); (成功插入。在插入完成时,新行有一个 隐式锁 保护它)。
事务 B:DELETEFROMtableWHEREid=100; (尝试删除新插入的 id=100。此时需要检查该行是否存在、可见性等。为了检查,事务 B 会尝试获取该行的 S 锁。因为该行有事务 A 的隐式锁,InnoDB 会为事务 A 创建一个 显式的 X 锁,然后事务 B 的 S 锁请求会被这个新创建的显式 X 锁阻塞)。
事务 A: 在提交之前,需要执行其他操作,比如 UPDATE table SET ... WHERE id = 200; (尝试获取 id=200 的 X 锁)。
事务 B: 在等待 id=100 的锁期间,已经持有 id=200 的行锁(例如之前 SELECT ... FORUPDATE过)。事务 A 尝试获取 id=200的锁被事务 B 阻塞。
死锁原因: 事务 A 持有id=100(通过隐式锁转换来的显式X锁),等待id=200;事务 B 持有id=200,等待 id=100。形成循环等待。
核心点:在于插入行的隐式锁在遇到其他事务的读/写操作时被转换为显式锁。
08
那咋避免死锁呢
08
那咋避免死锁呢
InnoDB 死锁的核心原因是循环锁依赖,常见于事务顺序不当、间隙锁滥用、外键约束冲突等场景。
通过优化事务逻辑、索引设计和隔离级别,可有效降低死锁概率:
保持一致的访问顺序: 确保应用程序中所有事务都以相同的逻辑顺序访问表和行。这是最有效的预防方法(尤其针对场景1)。
减少事务粒度/时间: 尽量让事务短小精悍,尽快提交或回滚。持有锁的时间越短,发生冲突的机会越小。
合理使用索引: 确保高效使用索引,避免全表扫描(全表扫描会升级为表锁,更容易冲突)。索引也能帮助固定加锁顺序。
使用较低的隔离级别: 如果业务允许,使用 读已提交 隔离级别。该级别通常不使用间隙锁(除了外键约束和重复键检查),可以避免很多由间隙锁引发的死锁。但需注意可能带来的幻读问题。
重试机制: 在应用层捕获死锁错误 (SQL Error: 1213),并进行安全的重试。
09
死锁监测
| 参数 | 作用 | 默认值 | 版本差异 |
|---|---|---|---|
| innodb_deadlock_detect | MySQL 5.7.15+ | ||
| innodb_lock_wait_timeout | 5.5 及更早 |
innodb_deadlock_detect 开启后,当通过锁等待图(Wait-for Graph)检测到循环依赖死锁时,会选择回滚代价最小的事务进行回滚(undo log 量最小的事务,如文章开头案例状态日志最后一行:WE ROLL BACK TRANSACTION (1)),并释放其持有的所有锁,从而打破死锁。
innodb_deadlock_detect 关闭后,主要就依赖 innodb_lock_wait_timeout 超时,好处是避免一直死锁监测的cpu开销(适用于高并发写入场景),但也存在风险:如果出现死锁,可能导致大量事务锁等待超时,系统的吞吐量降低。 另外 innodb_lock_wait_timeout 的值也不能设置太小,否则会有对正常事务误杀的情况。