搜狐技术产品

这死锁,不打个招呼就来了

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

哎?居然死锁了。

Image

先介绍一下业务场景: 消费kafka消息,根据id依次更新评论库的字段。OK,那就看看具体日志:

第一台服务器收到消息:{ 文章1:[ 评论A, 评论B, 评论C] } 。

第二台服务器收到消息:{ 文章1:[ 评论C, 评论A, 评论B] } 。

两条消息间隔几毫秒。根据这两条消息,基本就可以确定死锁的原因了。

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

那就复现一下

数据库版本: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.准备表数据:


Image

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锁的分类

按模式划分:
  • 共享锁(S): 也叫读锁(行级,表级)。允许多个事务同时获取同一资源。事务获得共享锁后,其他事务可以加共享锁,但不能加排他锁。
  • 排他锁(X): 也叫写锁(行级,表级)。一个事务获取某个资源的排他锁后,其他事务不能获取该资源的任何锁。主要用于修改数据(INSERT, UPDATE, DELETE)。
  • 意向锁(IS/IX): 也叫意向读(写)锁 (只有表级)。快速判断表中是否有行级锁存在,避免逐行检查。
    ▲意向共享锁(IS):事务准备对某些行加 S 锁前,先加表级 IS 锁。
    ▲意向排他锁(IX):事务准备对某些行加 X 锁前,先加表级 IX 锁。
按粒度划分:
  • 全局锁:对整个数据库实例加锁,主要用于数据库备份。
  • 表锁:对表进行加锁,在更细粒度的锁出现之前,表锁是常用控制并发的方式。
  • 行锁:细粒度锁,对一条或多条记录加锁,更好的控制并发。

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

到底是咋加锁的 (可重复读隔离级别)

加锁基本单位

加锁的基本单位是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)
等值查询(=)
非唯一索引
存在
不降级(Next-Key Lock)
所有命中行及相邻间隙
范围查询(>, <等)
任意索引
-
不降级(Next-Key Lock)
所有扫描行及间隙
-
无索引/索引失效
-
-
所有行及间隙(相当于表锁)

07

常见行锁死锁场景

  1. 不同顺序更新不同行 (交叉更新顺序)

    事务 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)。
    关键点: 这是最基础、最普遍的死锁类型(如文章开始的案例)。
  2. 唯一键冲突 (插入引发的死锁)

    假设表有一个唯一索引 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 是这种死锁的常见诱因。
  3. 索引扫描顺序不一致

    表有一个非唯一二级索引 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 ...) 的加锁顺序(由索引扫描顺序决定)与单点操作不一致。
  4. 间隙锁冲突 (并发插入到同一间隙)

    假设表当前有记录 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),而插入意向锁与对方持有的间隙锁互斥,形成互相等待。
    关键点: 发生在 可重复读 隔离级别(该级别使用间隙锁)。串行化 级别也会发生。读提交 级别通常没有间隙锁,此场景不易发生。
  5. 更新与删除/更新的锁升级冲突

    事务 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 操作互相等待对方持有的不同行的锁。
  6. 隐式锁与显式锁的转换 (插入与删除/更新)

    事务 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

那咋避免死锁呢

InnoDB 死锁的核心原因是循环锁依赖,常见于事务顺序不当、间隙锁滥用、外键约束冲突等场景。

通过优化事务逻辑、索引设计和隔离级别,可有效降低死锁概率:

  • 保持一致的访问顺序:  确保应用程序中所有事务都以相同的逻辑顺序访问表和行。这是最有效的预防方法(尤其针对场景1)。

  • 减少事务粒度/时间:  尽量让事务短小精悍,尽快提交或回滚。持有锁的时间越短,发生冲突的机会越小。

  • 合理使用索引:  确保高效使用索引,避免全表扫描(全表扫描会升级为表锁,更容易冲突)。索引也能帮助固定加锁顺序。

  • 使用较低的隔离级别:  如果业务允许,使用 读已提交 隔离级别。该级别通常不使用间隙锁(除了外键约束和重复键检查),可以避免很多由间隙锁引发的死锁。但需注意可能带来的幻读问题。

  • 重试机制: 在应用层捕获死锁错误 (SQL Error: 1213),并进行安全的重试。

09

死锁监测

主要是这俩参数:
参数作用默认值版本差异
innodb_deadlock_detect
控制是否启用 InnoDB 死锁检测机制
ON (启用)
MySQL 5.7.15+
 引入,之前版本无此参数(强制启用检测)。8.0+ 默认启用,可显式关闭以提升高并发性能。
innodb_lock_wait_timeout
控制事务等待行锁的超时时间(秒)
50 (秒)
5.5 及更早
:同时控制表锁和行锁等待。**5.6+:作用于行锁等待。8.0+**:行为更严格,仅影响行锁等待。

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 的值也不能设置太小,否则会有对正常事务误杀的情况。