0 前言
前段时间在个人项目的数据库操作场景中,遇到了一些预期之外的死锁问题,进一步暴露了之前我个人对 mysql 锁机制理解得不够透彻. 于是乎择日不如撞日,就借着这个契机针对 mysql innodb 锁相关的原理内容进行一轮总结梳理,沉淀下来和大家一起交流探讨.
有关本篇内容的目录大纲展示如下:
章节 | 小节 | 备注 |
1 概念串讲 | 1.1 锁的定义 | 什么是锁?lock 与 batch 的区别? |
1.2 锁的占有模式 | 共享锁与排它锁 | |
1.3 锁的类型 | 行锁;自增长键锁;间隙锁、临键锁;表级意向锁;插入意向锁 | |
1.4 死锁 | 成因;探测机制 | |
2 流程解析 | 2.1 事务隔离级别 | r-uc;r-c;r-r;串行化(沿 r-r 重点展开) |
2.2 一致性非锁定读(MVCC) | 如何实现普通 select 查询操作的高性能与数据一致 | |
2.3 一致性锁定读(LOCK) | 不同检索条件下的加锁粒度分析 | |
2.4 insert 插入流程 | 插入流程详细加锁步骤 | |
2.5 delete 删除流程 | 删除流程详细加锁步骤 | |
2.6 更新丢失问题 | 实践常见的隐晦BUG;针对该问题的解决方案 |
有关本篇涉及到的技术细节,很大程度上参考了 《mysql技术内幕(第2版) 》的内容:
https://book.douban.com/subject/24708143/
1 概念串讲
1.1 锁的定义
锁的作用是限制临界资源的并发访问. 而在今天,我们所探讨的 mysql innodb 中,广义上的“锁”工具实际上包含下述两种类型:
• lock(锁): 限制数据库内容并发访问的锁工具
• latch(闩): 限制 mysql 程序中内存数据结构访问的锁工具
两者核心点对比如下表:
lock(锁) | latch(闩) | |
由谁执行 | 事务 | 线程 |
保护对象 | 数据库内容 | 内存数据结构 |
持续时间 | 整个事务过程 | 临界资源访问过程 |
模式 | 行锁、表锁... | 读写锁、互斥量 |
死锁探测机制 | waits-for graph、time out 机制 | 无 |
本篇内容围绕数据库内容的加锁行为展开,因此我们所重点探讨的是上述的 lock(锁) 而非 latch(闩).
1.2 占有模式
类似于并发编程中使用的读写锁工具,innodb 中的 lock 从占有模式上也可以分为:
• 共享锁 Share Lock(简称 S Lock) :S Lock 之间可以共享
• 排它锁 Exclusive Lock(简称 X Lock) :必须保证独占,和其他 Lock 均为互斥关系
两种 Lock 的兼容关系示意如下:
X | S | |
X | 不兼容 | 不兼容 |
S | 不兼容 | 兼容 |
以 innodb 中常用的行锁 Record Lock 为例,加共享锁的方式是通过在 SELECT 操作中加上关键字 LOCK IN SHARE MODE,示意如下:
SELECT * FROM `table` WHERE `id` = 1 LOCK IN SHARE MODE;
涉及针对行记录加排它锁的操作包括插入 INSERT、更新 UPDATE、删除 DELETE 以及显式带 FOR UPDATE 关键字的 SELECT 操作,示意如下:
SELECT * FROM `table` WHERE `id` = 1 FOR UPDATE;
INSERT INTO `table` (`id`,`data`) VALUES (1,'a');
UPDATE `table` SET `data` = 'b' WHERE `id` = 1;
DELETE FROM `table` WHERE `id` = 1;
1.3 锁的类型
下面针对 innodb 中涉及到的不同锁类型(依据锁的粒度分类)进行一一梳理:
• 行锁 Record Lock: 锁定具体行记录,依附于索引存在
• 间隙锁 Gap Lock: 针对行记录之间的空隙加锁
• 临键锁 Next-Key Lock: 本质上是间隙锁加行锁形成的组合
• 意向锁 Intention Lock: 在具体操作行为前进行意向声明
1.3.1 行锁
行锁 Record Lock 锁定的对象是具体的行记录,行锁会分为共享和独占两种模式,对应的语法可参见 1.2 小节中的介绍.
这里有一点需要额外强调的是,行锁的施加需要依附于索引而存在,因此触发加锁行为的 SQL 语句是否走到索引就尤为关键,倘若检索条件未命中任何索引,那么锁的粒度就会由行锁上升为表锁.
此外,需要明白 innodb 中的表结构是强依赖于主键(一级索引)的,因此在通过非主键索引加行锁时,在锁住索引的同时也会针对行记录对应的主键加锁.
1.3.2 自增长键锁
为了更好地契合 mysql 中的 b+ 树索引结构,我们通常会选择将表中的主键设置为自增长模式 ——AUTO_INCREMENT.
在此模式下,当 INSERT 语句未显式指定自增长列的值时,则会遵循预设好的增长趋势为其自动赋值. 此处我们想探讨的正是在并发 INSERT 场景下,自增长列的取值方式.
在老版本中,自增长值的获取是通过查询一个表粒度的关键字 auto_inc_col 来实现的,因此为了保证并发场景下的状态一致性,每次获取自增长值时都需要加上表级别的 X Lock:
SELECT MAX(`auto_inc_col`) FROM `table` FOR UPDATE
上述这种方式被称作 AUTO-INC-LOCKING,虽然使用到了表锁,但通过特殊的优化机制,使得加锁的生命周期仅限于获取这个状态值的动作,取到即可提前解锁,而不需要等待整个事务运行结束. 但尽管如此,表锁的粗粒度仍然使得 AUTO-INC-LOCKING 机制成为批量并发 INSERT 流程中的性能瓶颈点所在.
在 mysql 5.1.22 版本后,引入了更轻便高效的互斥量 Mutex 机制,通过在内存维护的 mutex 数据结构(通过 latch 限制并发访问)作为自增长计数器,用于取缔 AUTO-INC-LOCKING 的表锁机制.
此后,增设了参数 innodb_autoinc_lock_mode,来选用不同的自增长值获取策略. innodb_autoinc_lock_mode 的默认值为 1,对应的策略是:
• 针对 insert 插入行数可提前确定的 simple inserts 类型,会启用 Mutex 机制
• 针对 insert 插入行数无法提前确定的 bulk inserts 类型,仍保留使用 AOTU-INC LOCKING 机制
需要注意的是,上述两个机制之间也会产生互斥效应,即存在 bulk inserts 启用了 AOTU-INC LOCKING 时,simple inserts 操作也需要等待其表锁释放后,才能使用 Mutex.
不同 innodb_autoinc_lock_mode 值对应的执行策略如下表所示:
innodb_autoinc_lock_mode | 策略 |
0 | 统一采用 AUTO-INC LOCKING 机制.性能较差,不推荐 |
1(默认启用) | simple inserts 采用 MUTEX 机制;bulk inserts 采用 AUTO-INC LOCKING 机制 |
2 | 统一采用 MUTEX 机制.性能高,但可能存在自增长值不连续问题 |
下面针对一个常见的问题进行解答:
为什么设定了自增长模式后,插入记录的主键不连续?
• 查看自增长步长设置:
通过下述的语法可以查看到自增长列的步长设置,判断是否符合预期:
SELECT @@auto_increment_increment;
• 自增长计数锁生命周期:
如前文所说,不论是 AUTO-INC LOCKING 还是 MUTEX 机制,申请自增长值的行为与事务生命周期都是相对独立的,因此就会存在下面两种情况:
• 自增长值占用成功,事务执行失败,形成空隙: 事务执行过程中,insert 行为的申请到了自增长值,但随后的操作执行失败导致回滚,此时占用的自增长值是不会归还的,最终就会形成空隙
• Mutex机制下并发占用自增长值,产生交错: 针对不确定插入行数的 bulk insert 操作,如果启用 Mutex 机制,可能会出现多个事务并发加 latch ,穿插交错着取自增长值的情况,这样新生成记录中的自增长列也会出现不连续的情况
1.3.4 间隙与临键锁
间隙锁 Gap Lock 指的是针对行记录之间的间隙范围上锁,其存在本质上为了规避幻读问题(Phantom Problem),因此间隙锁是不涉及所谓共享或者排它的概念的,它的目标集中在拦截间隙范围内即将到来的 insert 行为,除此之外它不会阻塞其它任何的流程.
有关间隙锁的底层具体实现细节,网上很难找到精确的描述,我目前还没有涉猎到 mysql 底层的实现源码(后续补上),但是从间隙锁与插入意向锁之间的联动机制,我这里产生了能够令我自己能够逻辑自洽的思路推断:
在底层实现上,不存在所谓“间隙”这样的数据结构. 因此间隙锁和行锁类似,也是依附于索引而存在,具体载体就是间隙所在范围右边界遇到的第一条行记录对应的索引. 倘若右边界不存在行记录,则会构造一条表示 ﹢∞ 的虚拟记录进行补齐.
正如上文所说,间隙锁是专为拦截间隙中的 insert 行为而生,因此其生效的前提依赖于 insert 行为的配合. 具体来说就是,执行 insert 操作时,会定位到拟插入记录所属的范围,然后检查右边界第一条记录的索引,判断是否存在间隙锁标识. 例如,间隙锁的范围是 index ∈ (3,4) ,那么间隙锁标识信息会依附于索引 index = 4 而存在.
证明上述推断的另一个说明是 innodb 中关于临键锁 Next-Key Lock 的设计与使用,它本质上就是由间隙锁 Gap Lock + 右边界首条记录的行锁 Record Lock 形成的组合锁,因此锁定的统一是左开右闭的区间,如 index ∈ (3,4].
在后文的介绍中,为了便于理解会对内容表述进行简化,针对临键锁 Next-Key Lock 统一描述成 Gap Lock + Record Lock 形成的组合.
1.3.5 表级意向锁
下面来介绍一下有关意向锁 Intention Lock 的概念. 意向锁本质上是为了提高粗粒度锁性能而设置的一种预判机制,在实现上会发起一个实际资源的锁申请行为之前,先针对其从属的更粗的资源发生加锁意向的声明,这样可以更好地提升不同粒度的锁资源之间的协调关系与性能表现.
我知道上面的描述非常抽象,下面我们结合具体的案例加以说明:
时刻 | 事务 A | 事务 B |
1 | BEGIN;// 开启事务 | BEGIN;// 开启事务 |
2 | SELECT * FROM table WHERE id = 99999999 FOR UPDATE;// 针对表中 id = 99999999 的记录加 X Lock | |
3 | SELECT * FROM table LOCK IN SHARE MODE;// 尝试针对整张表加 S Lock. 先检查是否存在表级 X Lock,如果不存在,还需要逐行扫描是否存在行级 X Lock,当扫描到第 99999999 行时发现 X Lock 存在,因此陷入阻塞等待 |
在上述场景中,事务 A 已经于时刻 2 对表中的某行记录加了 X Lock(行锁),另一个事务 B 需要对整张表加 S Lock(表锁),此时如果没有意向锁的辅助,那么事务 B 就需要逐行扫描表的每一个位置,确保其不存在与表级 S Lock 冲突的更细粒度的行级 X Lock,这样才能加上表锁. 这个全表扫表的过程就是一个很大的可优化点.
为了优化上述问题,mysql 中引入了意向锁 Intention Lock 的机制,保证在针对细粒度资源加锁之前,需要遵循自外向内的顺序,事先申请好更粗粒度资源的意向锁.
比如在对表中一行记录加锁之前,需要先申请数据库级别意向锁、表级别意向锁、页级别意向锁,前置操作都处理成功后,最后再对具体的行记录加行锁.
上面的说法偏理论,在 innodb 的具体实现中,针对意向锁的设计比较简练,就将其设定在表级的粒度,分为共享和独占式两种占有模式:
• 表级意向共享锁(IS Lock):声明有事务要获得表中某几行的共享锁 S Lock
• 表级意向排它锁(IX Lock):声明有事务要获得表中某几行的排它锁 X Lock
innodb 中,所有申请行锁的操作都需要实现申请到相同占有模式下的表级意向锁. 比如针对一行记录加 X Lock,则需要先申请得到该行所在的表级 IX Lock.
innodb 中的表级意向锁事实上不会阻塞表锁之外的任何流程,其与表锁之间的兼容性关系如下表:(横轴表示已获得的锁,纵轴表示拟获得的锁)
IS | IX | S(表级别) | X(表级别) | |
IS | 兼容 | 兼容 | 兼容 | 不兼容 |
IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
S(表级别) | 兼容 | 不兼容 | 兼容 | 不兼容 |
X(表级别) | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
有了表级意向锁的辅助,我们再对前文中提及的案例重新加以梳理,可以看到时刻 3 中,事务B的表锁行为得到了大幅的减负:
时刻 | 事务 A | 事务 B |
1 | BEGIN;// 开启事务 | BEGIN;// 开启事务 |
2 | SELECT * FROM table WHERE id = 99999999 FOR UPDATE;// 1 获得表的意向锁 IX Lock// 2 获得 id = 99999999 的行 X Lock | |
3 | SELECT * FROM table LOCK IN SHARE MODE;// 查看发现表的意向锁 IX Lock 已被占用. 因此无需逐行检索,直接阻塞等待 |
1.3.6 插入意向锁
接下来介绍的是另一类意向锁——插入意向锁 Insert Intention Lock,其作用于新记录的 INSERT 流程中,我个人倾向于将其理解成一种逻辑意义上的 “插入前置校验步骤”.
如 1.3.4 小节中所描述的,插入意向锁是配合着间隙锁使用的:在插入记录前,需要前置检查其所属的范围是否存在间隙锁. 按照我在前文中的推断,判断的方式就是通过查看范围右边界的首条记录中,是否存在间隙锁标识.
有关 INSERT 插入流程的详细加锁机制是具有一定复杂性的,尤其是在表中可能存在唯一键冲突(Duplicate Key Conflict)的场景中,这部分我们放在本文 2.4 小节中详细拆解.
1.4 死锁
所谓死锁 Dead Lock,指的是在两个以上事务的执行过程中,针对锁资源形成循环依赖关系,最终造成互相等待的现象. 这种情况下,若无外力作用,整个流程将永久 hang 死.
在 innodb 中,针对死锁问题制定了两种对策:
• 超时 timeout 机制:
产生死锁的直接导火索就是等锁行为,那么最简单粗暴的方式就是遇到长时间等待直接回滚. 具体来说就是给阻塞等锁行为设置一个时间阈值,一旦超时就直接回滚.
• 等待图 wait-for graph 机制:
这是 innodb 中另一种主动探测死锁的方式. 针对事务的等锁依赖关系构造出一条链表,如果链表形成环状,则代表存在死锁问题. 此时 innodb 会选择事务进行回滚,进而破坏等锁链表回路. 在回滚事务时,会选择对权重最小的事务作为目标,此处提到的权重值指的是事务涉及修改和锁住的行记录数量.
至此,我们把可能涉及到的理论概念做了一轮铺垫,从第 2 章开始,我们更多地以流程实战的视角切入,和大家一起深入探讨 innodb 锁机制的实现细节.
2 流程解析
后续内容会以原理结合案例的方式加以说明,在正式开始之前,我们先提前准备好对应的测试数据源. 下面是测试表的建表SQL语句:
CREATE TABLE IF NOT EXISTS `test`
(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`key` varchar(255) NOT NULL COMMENT '唯一键',
`index` varchar(255) NOT NULL COMMENT '普通索引',
`data` bigint(20) unsigned NOT NULL COMMENT '普通数据列',
PRIMARY KEY (`id`) COMMENT '主键 id',
UNIQUE KEY `uni_key` (`key`) COMMENT '唯一键',
KEY `idx_index` (`index`) COMMENT '普通索引'
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
该表中分别包含自增主键 id 、唯一键 key、普通索引 index 和普通数据列 data:
列 | 类型 | 索引类型 |
id | int | 主键;自增模式 |
key | string | 唯一键 |
index | string | 普通索引 |
data | int | 普通数据列 |
接下来在测试表中插入一些初始数据,用于后续的测试:
INSERT INTO `test`
(`key`,`index`,`data`)
VALUES
('c','C',2),
('g','G',7),
('j','J',10);
执行完上述 INSERT 语句后,此刻表中的初始数据展示如下:
id | key | index | data |
1 | c | C | 2 |
2 | g | G | 7 |
3 | j | J | 10 |
2.1 事务隔离级别
加锁行为是发生在事务中的,只不过有着隐式事务和显式事务的区别. 在此我们先针对 innodb 下的几个事务隔离级别以及对应存在的问题做个梳理:
解决脏读****Dirty Read | 解决不可重复读****Unrepeatable Read | 解决幻读****Phantom Problem | |
读未提交****Read Uncommitted | ❌ | ❌ | ❌ |
读已提交****Read Committed | ✅ | ❌ | ❌ |
可重复读****Repeatable Read | ✅ | ✅ | ✅ |
串行化Serialization | ✅ | ✅ | ✅ |
这里有两个点需要和大家作个强调:
• 上表展示的是 innodb 中的具体实现版本,和传统数据库的区别在于,innodb 在可重复读级别下,通过MVCC机制额外解决了幻读 Phantom Problem 的问题
• 此处提到的数据不一致问题都是针对不带加锁行为的普通 SELECT 语句而言的,如果一旦对某行记录采取了锁定读,那么会固定读取到该行最新一次提交版本对应的数据,问题也就不复存在
2.1.1 读未提交
在事务隔离性最弱的读未提交(Read-Uncommitted,简称 R-UC)级别下,可能存在脏读问题.
所谓脏读(Dirty Read),指的就是一个事务读取到其他事务尚未提交的草稿内容,从而造成视角和信息的混乱失真.
下面进入实战案例演示环节,首先操作开始前,数据表中原始数据状况如下:
id | key | index | data |
1 | c | C | 2 |
2 | g | G | 7 |
3 | j | J | 10 |
我们将会话的事务隔离级别设置为读未提交:
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
下面演示具体的并发事务操作行为,可以看到在事务 B 将在时刻 3 读取到事务 A 修改的临时数据,即发生了脏读问题:
时刻 | 事务A | 事务B |
1 | BEGIN; | BEGIN; |
2 | UPDATE test SET data = 8 WHERE id = 2; | |
3 | SELECT * FROM test WHERE id = 2;// data = 8 # 脏读 | |
4 | ROLLBACK; | |
5 | SELECT * FROM test WHERE id = 2;// data = 7 # 过一会儿再看就变了 | |
6 | COMMIT; |
2.1.2 读已提交
针对于读已提交(Read-Committed,简称 RC) 级别,能够规避脏读问题,但是会存在不可重复读和幻读的问题.
所谓不可重复读(Unrepeatable Read) ,指的是在事务执行期间,因为其他事务提交了更改行为,导致前后两次读取同一份数据得到的结果有所不同.
针对不可重复读问题,下面通过实战案例加以演示:
操作开始前,数据表中原始数据状况如下:
id | key | index | data |
1 | c | C | 2 |
2 | g | G | 7 |
3 | j | J | 10 |
首先将会话的事务隔离级别设置为读已提交:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
下面演示具体的并发事务操作行为. 可以看到,由于事务 A 在时刻 3-4 提交了数据修改操作,导致事务 B 于时刻 2、5 针对同一笔数据读取到截然不同的两个结果,产生不可重复读的问题.
时刻 | 事务A | 事务B |
1 | BEGIN; | BEGIN; |
2 | SELECT * FROM test WHERE id = 2;// data = 7 | |
3 | UPDATE test SET data = 8 WHERE id = 2; | |
4 | COMMIT; | |
5 | SELECT * FROM test WHERE id = 2;****// data = 8 # 不可重复读 | |
6 | COMMIT; |
所谓幻读(Phantom Problem) 指的是事务执行期间,因为其他事务执行了记录的插入、删除或修改操作,导致前后两次执行相同范围查询条件时得到的数据记录数有所不同.
针对幻读问题,下面通过实战案例加以演示:
操作开始前,数据表中原始数据状况如下:
id | key | index | data |
1 | c | C | 2 |
2 | g | G | 8 |
3 | j | J | 10 |
同样保证会话下的事务隔离级别保持为读已提交:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
可以看到,由于事务 A 在时刻 3-4 往表中插入了一笔新的数据记录,导致事务 B 在执行过程中,于时刻 2、5 针对同一个检索范围读取到不同的数据记录数,产生幻读问题.
时刻 | 事务A | 事务B |
1 | BEGIN; | BEGIN; |
2 | SELECT * FROM test WHERE data > 6;// id = 2、id = 3 | |
3 | INSERT INTO test (key ,index ,data ) VALUES ('k','K',11); | |
4 | COMMIT; | |
5 | SELECT * FROM test WHERE data > 6;****// id = 2、id = 3、id = 4 # 幻读 | |
6 | COMMIT; |
innodb 中针对读已提交的事务隔离级别通过多版本并发控制 MVCC 实现,我们在 2.2 小节展开.
2.1.3 可重复读
在 innodb 的实现中,可重复读 (Repeatable Read,简称RR) 能够规避不可重复读和幻读的问题,实现上通过在多版本并发控制 MVCC 中采用一致性视图 Consistent Read View 加以保证. 具体的实现细节我们放在 2.2 小节中详细展开.
此外,RR 是 innodb 默认的事务隔离级别机制,也是最常用的策略,后续 2.3-2.5 小节的内容介绍中,我们都围绕着 RR 进行内容展开.
2.1.4 串行化
串行化(Serialization) 模式下,所有读写操作串行执行,能够保证数据的强一致性,但是性能较差,实践场景中适用范围比较有限.
2.2 一致性非锁定读(MVCC)
2.2.1 实现原理
针对读已提交和可重复读的事务隔离级别,innodb 采用多版本并发控制 Multi-Version Concurrent Control(简称 MVCC) 作为应对策略.
MVCC 在实现上可以拆分为 【版本链结构实现】 和 【版本选择策略】 两个部分:
• 版本链结构: 每当事务修改一行记录时,会基于写时复制(Copy-On-Write)策略生成一份副本,并通过指针指向上一个版本的数据记录. 如此一来,依据修改的先后顺序,各行记录会形成一条链表状的数据结构
• 版本选择策略: 针对普通 SELECT 语句的查询行为,本质上就是遍历版本链,然后挑选合适的版本数据,以此保证查询视角的一致性
因此,不同的事务隔离级别实际上就是版本选择策略有所不同. 下面我们就分别沿上述子话题展开探讨.
首先,介绍一下 MVCC 中版本链的底层数据结构. 在 innodb 的行记录中,会包含如下三个隐藏列:
列名 | 是否必须 | 大小 | 备注 |
row_id | 否 | 6B | 行ID |
transaction_id | 是 | 6B | 事务ID. (版本) |
roll_pointer | 是 | 7B | 回滚指针. (指针) |
• row_id: 因为 innodb 采用聚簇索引,所以必须存在主键(一级索引)作为真实数据的存储载体. 倘若表中未显式声明主键,则会隐藏字段 row_id 作为一级索引
• transaction_id: 标识一个行记录版本是由哪个事务修改生成的. 事务 id 是全局唯一且递增的分布式 id.
• roll_pointer: 指向上一个版本数据的指针
于是,版本链的拓扑结构就很清楚了,一次修改行为会生成一个记录的一个版本,会通过 transaction_id 标识其由哪个事务修改生成. 这样的一个版本就是链表中的一个节点,节点之间通过回滚指针 roll_pointer 串联形成单向链表.
在版本链中,一个版本根据其从属的事务是否已经成功提交,分为正式数据和草稿数据两类. 需要意识到,由于有行 X Lock 的存在,因此同一时刻至多只能有一个事务对一行数据记录进行修改.
此外值得一提的是,innodb 中,为了支持事务回滚操作启用了 undo log 机制,因此天然就形成对应的版本链机制,在 MVCC 实现中可以直接进行复用,不存在额外的成本开销. 只不过为了保证 MVCC 中数据视图的一致性,针对 undo log 中老版本数据的回收时机(purge) 需要适当延后,保证直到不存在更小的活跃事务 id 存在时才能进行回收.
聊完版本链的形成结构,接下来介绍版本选择策略的部分.
innodb 会提供一个查询视图 Read View,其中包含如下几部分信息:
• trx_list: 当前处于活跃状态的事务 ID 列表(活跃的意思就是未提交,用于遍历版本链时判断该版本数据是正式版本还是草稿副本)
• up_limit_id: trx_list 中的最小活跃事务 ID(undo log 版本链中,对于事务 id < up_limit_id 的老版本数据可以进行回收)
• low_limit_id:分配给下一个事务的 id. 保证全局唯一且递增
基于以上,在可重复读和读已提交两个事务隔离级别中,在执行普通 SELECT 语句时都会获取 Read View,并针对行记录的版本链进行遍历,并遵循如下规则:
1)如果遇到某个版本的事务 id 等于当前事务 id,直接选取该版本(同一个事务修改的内容,哪怕是草稿态也要读取到)
2)遍历找到首个事务 id 不在 trx_list 的事务(代表该版本是已提交的最新版数据)作为选取的版本
而可重复读和读已提交的区别就在于:
• 【读已提交】 会在每次执行 SELECT 查询时,实时获取最新的 Read View 视图
• 【可重复读】 只在事务开启时获取一次 Read View,并在整个生命周期进行复用. 同时针对上述第 2)条,还需要保证选取版本的事务 id < low_limit_id. 这样就能屏蔽事务开启后其它并发事务的一切修改行为,进而保证当前事务视角的一致性
2.2.2 实战案例
下面通过实战案例,加以说明:
操作开始前,测试表中的数据状况如下:
id(主键) | key(唯一键) | index(普通索引) | data(数据列) |
1 | c | C | 2 |
2 | g | G | 7 |
3 | j | J | 10 |
将会话的事务隔离级别设置为可重复读:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
第一个案例演示的是规避不可重复读的效果:
接下来是实际的操作演示. 可以看到,在时刻 5 时,虽然事务 A 此时已经更新并提交了行记录,但事务 B 读取到结果仍然和时刻 2 保持一致,正是由于一致性视图起的作用,此时在事务 B 持有的 Read View trx_list 中,事务 A 仍存在于活跃事务列表 trx_list 中,因此不会读取其修改的数据版本.
时刻 | 事务A | 事务B |
1 | BEGIN; | BEGIN; |
2 | SELECT * FROM test WHERE index = 'C';// data = 2 | |
3 | UPDATE test SET data = 3 WHERE index = 'C'; | |
4 | COMMIT; | |
5 | SELECT * FROM test WHERE index = 'C';****// data = 2 # 规避不可重复读 | |
6 | SELECT * FROM test WHERE index = 'C' FOR UPDATE;****// data = ? | |
7 | COMMIT; |
此处针对时刻 6 额外指出一个点,就是此时,倘若事务 B 针对采用 For Update(X Lock) 模式进行 SELECT 查询,那么就不会走到 MVCC,而是直接读取到该行最新的提交数据版本,即读到的结果是 data = 3.
第二个案例演示的是规避幻读的效果:
其实现原理和和第一个案例一致的. 在时刻 5 执行范围查询时,事务 B 会沿用事务启动之初的 ReadView,因此会把事务 A 当作活跃事务,那么其插入的行记录中整个版本链都不存在符合条件的正式数据,会直接将该行记录视为不存在,所以也就不会产生幻读问题.
时刻 | 事务A | 事务B |
1 | BEGIN; | BEGIN; |
2 | SELECT * FROM test WHERE data > 5;// data = 7、data = 10 | |
3 | INSERT INTO test (key ,index ,data ) VALUES ('k','K',11); | |
4 | COMMIT; | |
5 | SELECT * FROM test WHERE data > 5;****// data = 7、data = 10 # 规避幻读 | |
6 | COMMIT; |
2.3 一致性锁定读(LOCK)
2.2 小节中,MVCC 针对的是不涉及加锁的普通 SELECT 操作,而本小节中针对带加锁行为的读操作进行介绍,包含行记录的 X Lock(FOR UPDATE) 和 S Lock(LOCK IN SHARE MODE) . 一旦带上加锁行为,就会保证读取到行记录为最新版的正式数据,因此数据一致性并不是本小节探讨的重点,此处真正要和大家聊的是不同检索条件下对应的加锁粒度.
一致性锁定读语法如下:
• 加共享锁:
SELECT * FROM `table` WHERE `id` = 1 LOCK IN SHARE MODE;
• 加排它锁:
SELECT * FROM `table` WHERE `id` = 1 FOR UPDATE;
下面结合实战案例的演示,进行结论和原理的介绍:
首先,确保会话的事务隔离级别为可重复读:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
2.3.1 命中唯一键
首先,在一致性锁定读时,如果检索条件命中了主键或者唯一键,此时会进一步分为两种情况:
• 情况一:如果目标记录存在,则只会针对这一行记录加行锁
• 情况二:如果目标记录不存在,则会针对所处范围加间隙锁. 如果左右有缺口,则通过 ±∞ 补齐
下面提供具体的实战案例加以说明:
• 案例 I:目标记录存在
在开始操作前,数据表中的初始数据状况如下:
id(主键) | key(唯一键) | index(普通索引) | data(数据列) |
1 | c | C | 3 |
2 | g | G | 7 |
3 | j | J | 10 |
4 | k | K | 11 |
具体执行 SQL 如下表,时刻 2 事务 B 锁住唯一键 key = c,,时刻 3 事务 A 在其相邻位置插入 key = b 的记录,并且成功插入没有发生阻塞.
上述现象本质上是因为唯一键的特殊性,能够通过 Duplicate Key 校验而非间隙锁来避免 key 值相同的记录插入,因此最终仅施加行锁即可.
时刻 | 事务A | 事务B |
1 | BEGIN; | BEGIN; |
2 | SELECT * FROM test WHERE key = 'c' FOR UPDATE;****// ok. 该记录存在,针对 key = c 加行级别 X Lock | |
3 | INSERT INTO test (key ,index ,data ) VALUES ('b','B',2);****// 插入成功,证明唯一键加锁粒度为行锁 | |
4 | ROLLBACK; | |
5 | COMMIT; |
• 案例 II:目标记录不存在
在开始操作前,数据表中的初始数据状况如下:
id(主键) | key(唯一键) | index(普通索引) | data(数据列) |
1 | c | C | 3 |
2 | g | G | 7 |
3 | j | J | 10 |
4 | k | K | 11 |
具体执行 SQL 如下表,时刻 2 事务 B 锁住的唯一键 key = a,但由于记录不存在,所以只能对其所处的范围(-∞,c)加间隙锁,这样才能避免在持有锁期间,其他并发事务完成 key = a 的记录插入,进而规避幻读问题.
时刻 | 事务A | 事务B |
1 | BEGIN; | BEGIN; |
2 | SELECT * FROM test WHERE key = 'a' FOR UPDATE;****// ok. 该记录不存在,针对 (-∞,c) 加间隙锁 | |
3 | INSERT INTO test (key ,index ,data ) VALUES ('b','B',2);****// 阻塞,申请插入意向锁失败,等待事务B 释放 (-∞,c) 间隙锁 | |
4 | COMMIT; | |
5 | ROLLBACK; |
2.3.2 命中普通索引
在一致性锁定读时,如果检索条件命中了普通索引,此时除了会用行锁锁住记录本身,还会通过间隙锁锁定左右相邻空隙.
之所以需要额外施加间隙锁,就是为了避免在持有锁期间,有其他并发事务插入相同索引值对应的行记录,从而导致幻读问题的发生.
• 案例 I:普通索引单点查询
在开始操作前,数据表中的初始数据状况如下:
id(主键) | key(唯一键) | index(普通索引) | data(数据列) |
1 | c | C | 3 |
2 | g | G | 7 |
3 | j | J | 10 |
4 | k | K | 11 |
演示案例对应执行 SQL 如下:时刻 2 事务 B 对 index = C 加锁,同时锁住其相邻间隙,进而导致时刻 3 事务 A 也无法完成 index = D 的插入动作.
时刻 | 事务A | 事务B |
1 | BEGIN; | BEGIN; |
2 | SELECT * FROM test WHERE index = 'C' FOR UPDATE;****// ok. 针对 index = c 加行 XLock 以及左右间隙锁 (-∞,C) (C,G) | |
3 | INSERT INTO test (key ,index ,data ) VALUES ('d','D',4);****// 阻塞. 申请插入意向锁时被间隙锁 (C,G) 拦截 | |
4 | COMMIT; | |
5 | ROLLBACK; |
• 案例 II:普通索引范围查询
范围检索的思路其实是类似的,除了锁住已存在的行记录之外,还需要对相邻空隙统统加上间隙锁,这样才能严格意义上避免幻读现象的产生.
在开始操作前,数据表中的初始数据状况如下:
id(主键) | key(唯一键) | index(普通索引) | data(数据列) |
1 | c | C | 3 |
2 | g | G | 7 |
3 | j | J | 10 |
4 | k | K | 11 |
下面是 SQL 演示案例. 时刻 2 事务 B 对 index > C 范围加锁,包含已存在的记录和相邻间隙都会被锁住,进而导致时刻 3 事务 A 也无法完成 index = D 的插入动作,幻读问题得以避免.
时刻 | 事务A | 事务B |
1 | BEGIN; | BEGIN; |
2 | SELECT * FROM test WHERE index > 'C' FOR UPDATE;****// ok. 针对 (C,G)、G、(G,J)、J、(J,K)、K、(K,﹢∞) 加锁 | |
3 | INSERT INTO test (key ,index ,data ) VALUES ('d','D',4);****// 阻塞. (C,G) 存在间隙锁 | |
4 | COMMIT; | |
5 | ROLLBACK; |
2.3.3 未命中索引
最后是针对检索条件未命中索引的场景,此时加锁的粒度一律会由行锁上升为表锁. 此时因为行锁是依附于索引存在的,如果不走索引就没有可用的载体,只有针对全表加锁才能严格避免不可重复读和幻读问题的产生.
接下来是演示案例环节,时刻 2 事务 B 基于普通列作为检索条件加锁,最终上升为表级锁,此后针对表中任何位置的插入或者加锁操作,都会被阻塞.
时刻 | 事务A | 事务B |
1 | BEGIN; | BEGIN; |
2 | SELECT * FROM test WHERE data = 7 FOR UPDATE;****// ok. 针对整张表加锁. | |
3 | INSERT INTO test (key ,index ,data ) VALUES ('l','L',12);****// 阻塞. 全表被上锁. 实际上在获取表级意向锁环节 IX LOCK 就会阻塞 | |
4 | COMMIT; | |
5 | ROLLBACK; |
实际生产环境中,需要尽量避免表锁的出现,因此一致性锁定读的检索条件需要合理命中索引.
2.4 insert 插入流程
2.4.1 流程介绍
针对于事务当中的一笔 insert 操作,过程中的加锁步骤遵循下述流程:
• 1)申请插入意向锁: 本质上是去检查,插入位置所处范围是否存在间隙锁
• 2)唯一键冲突校验: 校验插入记录是否会和已存在记录发生唯一键冲突(Duplicate Key Conflict)
• 3)插入记录并加锁: 若没有唯一键冲突,则插入记录(草稿态),然后对其加行 X Lock
• 4)针对冲突记录加锁: 若发生唯一键冲突,则对引起冲突的行记录左右空隙加间隙锁,并申请该行记录的 S Lock
• 5)冲突记录双重校验: 步骤4)成功后,需要 double check 冲突记录的合法性,是的话返回唯一键冲突错误;否则流转到步骤3)
这里解释一下为什么需要执行步骤 4)和 5)的加锁 double check 机制. 这是为了确认引起唯一键冲突的行记录是正常数据而非草稿态或者删除态. 之所以不光要加行 S Lock 还要加左右间隙锁,就是为了避免在高并发场景下,恰好有其它事务在并发插入相同唯一键的行记录,导致引起校验流程的混乱.
2.4.2 死锁案例
基于上述流程,下面分享一个因为 INSERT 操作而引发死锁的案例.
在操作开始前,数据表初始的数据状况如下表所示:
id(主键) | key(唯一键) | index(普通索引) | data(数据列) |
1 | c | C | 3 |
2 | g | G | 7 |
3 | j | J | 10 |
4 | k | K | 11 |
具体执行 SQL 如下表,在时刻 3 事务 B 插入 key = n 时因遭遇唯一键冲突,会对记录加左右间隙锁,并因为申请冲突记录的 S Lock 而陷入阻塞;而事务 A 在时刻 4 尝试插入 key = m 时则会因为事务 B 施加的间隙锁而陷入阻塞,最终形成死锁:
事务 A -> 等待事务 B 释放 key = n 的左右间隙锁
事务 B -> 等待事务 A 释放 key = n 的 X Lock
时刻 | 事务A | 事务B |
1 | BEGIN; | BEGIN; |
2 | INSERT INTO test (key ,index ,data ) VALUES ('n','N',14);// 插入成功,针对 key = 'n' 加行级别 XLock | |
3 | INSERT INTO test (key ,index ,data ) VALUES ('n','N',14);****// 阻塞. 尝试针对唯一键 key = 'n' 加行 S Lock 以及左右间隙锁. 加间隙锁成功,加行 S Lock 失败,阻塞等待事务 A 的 XLock | |
4 | INSERT INTO test (key ,index ,data ) VALUES ('m','M',13);****// 获取插入意向锁失败,阻塞等待事务 B 释放 (k,n) 的间隙锁**// 发生死锁** | |
5 | DEAD LOCK | DEAD LOCK |
2.4.3 案例延伸探讨
针对上述死锁案例,我们额外展开探讨一个细节点:
案例中形成死锁的一个重要原因在于,事务 B 遭遇唯一键冲突后,选择先加间隙锁,再申请冲突记录的 S Lock,正是这样的加锁顺序才导致事务 A 后续的 INSERT 操作发生阻塞,最后引起锁资源的循环依赖.
试想一下,事务 B 在唯一键冲突后不申请间隙锁而是仅加冲突记录的行 S Lock(猜想一) ,亦或是把加锁顺序调整为先加行 S Lock,再加间隙锁(猜想二) ,这样是否就能够规避死锁问题呢?
针对猜想一,我们论证一下加间隙锁的必要性.
通过下述 SQL 我们演示一个不加间隙锁而导致的 badcase:
在时刻 3 事务 B 阻塞等待冲突记录行 S Lock,随后时刻 4 事务 A 回滚,因此事务 B 成功获得该行的 S Lock,并且发现行记录已经被删除,判断唯一键冲突问题已解;但与此同时,一个并发执行的事务 C 在此时见缝插针,又插入了一条唯一键相同的记录,这样就会导致事务 B 针对唯一键冲突的校验结果失准.
时刻 | 事务A | 事务B | 事务C |
1 | 开启事务 | 开启事务 | 开启事务 |
2 | 插入唯一键 key = n 的记录,加 X Lock | ||
3 | 插入唯一键 key = n 的记录,发生唯一键冲突,针对冲突记录加 S Lock,阻塞等待 | ||
4 | ROLLBACK | 插入唯一键 key = n 的记录,加 X Lock | |
5 | 因为事务 A 回滚,所以加 S Lock 成功,但是锁的是已失效的行级记录版本 | **插入唯一键 key = n 的记录,加 X Lock.**因事务 A 回滚,所以插入成功 | |
查看到行记录状态为已删除. 误以为唯一键冲突问题已经不存在 |
针对猜想二,其实存在的问题和猜想一是一样的,就是倘若事务 A 回滚,此时没有有效的手段拦截第三方并发事务的插入行为,因此事务 B 才需要先加间隙锁,阻止其他事务的并发插入行为,再进行后续的加行锁和校验操作.
基于 SQL 展示的反例如下:
时刻 | 事务A | 事务B | 事务C |
1 | 开启事务 | 开启事务 | 开启事务 |
2 | 插入唯一键 key = n 的记录,加 X Lock | ||
3 | 插入唯一键 key = n 的记录,发生唯一键冲突,针对冲突记录加 S Lock,阻塞等待 | ||
4 | ROLLBACK | 插入唯一键 key = n 的记录,加 X Lock | |
5 | 因为事务 A 回滚,所以加 S Lock 成功,但是锁的是已失效的行级记录版本 | **插入唯一键 key = n 的记录,加 X Lock.**因事务 A 回滚,所以插入成功 | |
针对相邻空隙加间隙锁 | |||
查看到行记录状态为已删除. 误以为唯一键冲突问题已经不存在 |
2.5 delete 删除流程
针对删除流程的加锁机制和 2.2 小节中介绍的 一致性锁定读比较类似.
根据筛选条件命中索引的不同情况,加锁粒度也会有所区别.
下面结合实战案例的演示,进行结论和原理的介绍:
首先,确保会话的事务隔离级别为可重复读:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
2.5.1 命中唯一键
DELETE 操作中,如果检索条件命中了主键或者唯一键,会根据目标记录是否存在分成两种情况:
• 目标记录存在:针对单行记录加排它锁
• 目标记录不存在:对所处范围加间隙锁
下面通过具体案例加以演示,在开始操作之前,数据表初始数据状况如下:
id(主键) | key(唯一键) | index(普通索引) | data(数据列) |
1 | c | C | 3 |
2 | g | G | 7 |
3 | j | J | 10 |
4 | k | K | 11 |
下面通过 SQL 展示目标记录存在和不存在的两个场景:
• 目标记录存在,加行 X Lock
事务A | 事务B |
BEGIN; | BEGIN; |
DELETE FROM test WHERE key = 'g';****// ok. 记录存在,针对 key = g 加行级别 X Lock | |
INSERT INTO test (key ,index ,data ) VALUES ('h','H',8);****// ok. 证明事务 A 只针对 key = g 加行级 X Lock | |
ROLLBACK; | |
ROLLBACK; |
• 目标记录不存在,对所处范围加间隙锁
事务A | 事务B |
BEGIN; | BEGIN; |
DELETE FROM test WHERE key = 'i';****// ok. 记录不存在,针对 (g,j) 加间隙锁 | |
INSERT INTO test (key ,index ,data ) VALUES ('h','H',8);****// 阻塞. 申请插入意向锁失败,等待事务 A 释放 (g,j) 间隙锁 | |
ROLLBACK; | |
ROLLBACK; |
2.5.2 命中普通索引
DELETE 操作命中普通索引时,对行记录左右相邻空隙加间隙锁,并加行记录本身加 X Lock.
下面是具体的演示案例:
事务A | 事务B |
BEGIN; | BEGIN; |
DELETE FROM test WHERE index = 'G';****// ok. 针对 (C,G) 和 (G,J) 加间隙锁, 针对 C 加行 X Lock. | |
INSERT INTO test (key ,index ,data ) VALUES ('f','F',6);****// 阻塞. 申请插入意向锁失败,等待事务 A 释放 (C,G) 间隙锁 | |
ROLLBACK; | |
ROLLBACK; |
2.5.3 未命中索引
最后,如果 DELETE 操作未命中索引,同样会对整张表加表级 X Lock:
对应 case 展示如下:
事务A | 事务B |
BEGIN; | BEGIN; |
DELETE FROM test WHERE data = 10;****// ok. 针对整张表加锁 | |
INSERT INTO test (key ,index ,data ) VALUES ('b','B',2);****// 阻塞. 申请表级意向锁失败,等待事务 A 释放表级 X Lock | |
ROLLBACK; | |
ROLLBACK; |
2.6 更新丢失问题
2.6.1 问题描述
接下来要介绍的是在数据库交互过程中,比较常见的 “更新丢失”问题. 首先我们针对该问题发生成因以一个案例的形式进行介绍:
如上图所示,有两个会话并发地针对数据中的同一行记录进行更新操作,但由于需要根据记录原始内容进行一些逻辑上的校验和判断工作,因此操作步骤会分为:I 查询记录 II 本地执行逻辑 III 本地执行记录更新 IV 更新提交数据库.
于是,在并发场景下,可能发生如下情况:
1)会话 A 从数据库查询记录,缓存在本地内存
2)会话 B 从数据库查询记录,缓存在本地内存
3)会话 A 修改这行记录,并更新到数据库
4)会话 B 修改这行记录,并更新到数据库
上述场景存在的问题在于,步骤 4)并没有感知到步骤 3)的存在,所以因为更新覆盖操作在数据层面把步骤 3)的更新内容给回滚了,所以从结果上来看,就是步骤 A 的内容莫名其妙地“丢失”了.
这是个常见且易犯的错误,关键点在于:
• 逻辑迷惑性: 查询+更新流程在逻辑上是通顺的,如果欠缺对并发场景和异常边界的梳理能力,容易忽略这类情况
• 不稳定复现: 只会在高并发场景中零星、随机的出现,不利于问题的排查
2.6.2 解决方案
导致“更新丢失”问题的核心原因其实是会话在将数据库记录查询到本地内存后,缺少有效的手段保证内容的实时性和一致性.
因此,对应的解决方案也很简单,就是把查询记录的普通 SELECT 操作改为带 X Lock 的一致性锁定读,这样就能阻断其它会话的并发修改行为:
该解决方案对应的 SQL 演示案例如下:
时刻 | 会话 A | 会话 B |
1 | BEGIN; | BEGIN; |
2 | SELECT data FROM table WHERE id = 1 FOR UPDATE; | |
3 | SELECT data FROM table WHERE id = 1 FOR UPDATE;// 阻塞等待 | |
... | ... | ... |
... | 内存操作:data = data + 2 = 4 | |
m | UPDATE table SET data = 4 WHERE id = 1; | |
m+1 | COMMIT; | // 从阻塞中恢复 成功读取到最新的数据,显示 data = 4 |
m+2 | 内存操作:data = data + 1 = 5 | |
m+3 | UPDATE data SET data = 5 FROM table WHERE id = 1; | |
m+4 | COMMIT; |
下面以 go 语言通过 gorm 访问 mysql 的方式,给大家提供示例,介绍如何在具体的业务代码层面实现上述方案:
import (
"context"
// 引入 gorm
"gorm.io/gorm"
)
// 数据源对象
type DAO struct {
db *gorm.DB
}
// 构造器函数
func NewDAO(db *gorm.DB)*DAO{
return &DAO{
db:db,
}
}
// 基于 po 记录加 xlock 后,再执行闭包函数逻辑
func (d *DAO) DoWithLock(ctx context.Context, id uint, do func(context.Context, *DAO, *PO) error) error {
// 开启事务
return t.db.Transaction(func(tx *gorm.DB) error {
// select ... for update 的实现
var po PO
if err := tx.Set("gorm:query_option", "FOR UPDATE").WithContext(ctx).First(&po, id).Error; err != nil {
return err
}
// 成功加上 xlock 后,执行闭包函数逻辑
return do(ctx, NewDAO(tx), &po)
})
}
有关 go 语言中最流行的 orm 框架—— gorm 的具体使用方案和底层原理,可以参见我之前分享过的内容:
3 总结
本文和大家介绍了有关 mysql innodb 锁机制的实现原理,下面对本文涉及知识点进行总结梳理:
• 锁的定义:对临界资源的并发访问限制工具. mysql 中针对数据库内容使用 lock,针对内存数据结构使用 latch
• 占有模式:分为共享模式 S Lock 和排它模式 X Lock
• 锁的类型:
• 行锁(行粒度的锁,依附于索引存在,分为共享和排它模式)
• 自增长键锁(表级自增长键锁,分为表锁和 Mutex 两种方式)
• 间隙锁(对记录之间的空隙加锁,避免因插入行为导致幻读)
• 临键锁(间隙锁+记录锁,左开右闭)
• 表级意向锁(提高粗粒度锁行为的效率)
• 插入意向锁(配合间隙锁使用,避免因插入行为导致幻读)
• 死锁:不同事务间的锁循环依赖. 探测方式分为超时探测和等待图
• innodb 事务隔离级别:
• 读未提交:存在脏读、不可重复读、幻读问题
• 读已提交:存在不可重复读、幻读问题
• 可重复读:通过 MVCC 规避上述问题(innodb默认采用)
• 一致性非锁定读:应用于普通SELECT操作. 通过版本链(undo log)结合一致性读视图实现
• 一致性锁定读:根据索引类型,存在不同的加锁粒度;检索条件需要至少命中普通索引,否则走表锁
• insert 锁机制:申请插入意向锁 -> 校验唯一键冲突情况 -> 插入记录并加行排它锁
• delete 锁机制:根据索引类型,存在不同的加锁粒度;检索条件需要至少命中普通索引,否则走表锁
• 更新丢失问题:针对不走 update 操作,而是 select 查询并在内存更新的场景,需要加独占锁规避因并发行为导致的更新被动回滚问题