再谈 MySQL InnoDB 可重复读级别下的锁

InnoDB 是 MySQL 默认的数据库存储引擎, 由于其良好的性能和对事务的支持, InnoDB 也是大多数业务场景下开发者的首选. 可重复读(Repeatable Read)作为 InnoDB 默认的事务隔离级别, InnoDB 研发团队对其进行了大量的设计和优化. 当理解了 InnoDB 在可重复读级别下的事务隔离机制后, 再理解数据库在其他隔离级别下的表现也就容易多了. 作为开发者, 只有当理解了这些设计背后的思想, 才不会在遇到问题时手忙脚乱.

InnoDB RR 隔离级别的实现原理

事务的隔离级别是当存在多个事务同时操作相同数据时, 对事务之间相互影响的要求标准. 可重复读的事务隔离级别要求: 在事务A开启事务之后, 事务B对数据的修改就不再对A可见了. InnoDB 采用了多版本并发控制(Multi-Version Concurrency Control)和两段锁协议(2-Phase-Lock)相结合的技术方案实现了 可重复读 的并发事务管理.

在可重复读的隔离级别下, InnoDB 会在每个事务提交时记录下当前事务的版本号, 并将此变更的记录与变更数据的历史版本相关联. 某事务对数据进行查询时只能查到数据版本 <= 当前事务版本的数据状态.

在事务执行期间, 当事务 A 对数据进行修改(或删除)或向表中插入新数据时, InnoDB 还会在索引及数据加上排它锁(X Lock)并在索引位置的间隙上加入区间锁(Gap Lock) (关于区间锁, 在后面的内容里会讲到). 此时, 如果事务 B 企图修改相同的数据, 那么事务 A 加的锁会将事务 B 阻塞直到事务 A 完成提交.

在 MVCC 和 2PL 的共同作用下, InnoDB 实现了 "读不加锁, 读写不冲突" 的并发控制 (这里说的读指的是快照读, 关于快照读和当前读在后面的内容里会讲到).

幻读问题解决了吗 ?

所谓幻读, 指的是在同一个事务中两次执行同样的查询语句却得到了数量不等的结果集. 所以, 幻读也可以说是不可重复读问题的一种特殊情况. 在标准的数据库事务隔离级别定义中, 幻读在可重复读这个隔离级别中是可能发生的. 而在 InnoDB 的实现中, 幻读问题因为区间锁(Gap Lock)的引入而被解决了.

要正确理解上面这段话, 必须先理解 MVCC 中两个重要的概念, 那就是 快照读当前读. 关于 "InnoDB 下的可重复读不存在幻读问题" 这个说法的很多疑惑和争议都源于没有正确理解快照读和当前读的概念. 另外, 在后文的叙述中除非特别说明, 所指的事务隔离级别均为可重复读.

在 MVCC 中, 数据库中的每次变更都有一个对应的版本号, 事务中的一次普通查询只能查到数据版本 <= 当前事务版本的数据状态, 对当前事务来说, 就好像是在查询事务开始那一刻的数据快照, 这就是 快照读, 比如下面这条 SQL:

SELECT * FROM users WHERE id = 1;

而当我们需要对某条数据进行修改时, 就不得不基于该条数据当前的最新版本了. 所以当我们执行以下SQL语句时, 则相当于执行了对数据的 当前读.

UPDATE * users SET name = "Tom" WHERE id = 1;
DELETE FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
SELECT * FROM users WHERE id = 1 IN SHARE MODE;

不难发现, 如果仅仅依靠 MVCC 的规则, 如果我们在事务 A 中对某条数据执行了一次当前读操作, 之后事务 B 修改了这条数据, 当事务 A 再次对数据执行当前读时得到的结果跟前一次一定是不同的, 可这样一来就不符合可重复读的基本要求了.

正如前面提到的, 除了 MVCC 之外 InnoDB 还采用了加锁的方式对并发的事务进行控制. 当事务 A 对某条数据进行当前读时会在该条数据上加锁(别急, 关于索引上的锁稍后我们会提到), 当事务 B 试图修改该数据时, 将因为无法获取到锁而被阻塞直到事务 A 提交. 这样, 在整个事务 A 中通过当前读取得的数据就始终一致了!

但是, 这样就够了吗? 答案是否定的. 假设我们有这样一张数据表(users).

现在我们开启事务 A, 执行如下语句:

SELECT * FROM users WHERE name = "draveness" FOR UPDATE;

然后开启事务 B, 执行如下语句:

INSERT INTO users(username, age) VALUES ("draveness", 22);

还记得我们前面说, 幻读是指前后读到的数据行数不一致吗? 虽然事务 A 在第一次当前读时在读到的数据上加了锁, 可如果事务 B 之后向数据表中又插入了一条符合条件的新数据, 当事务 A 再次执行当前读时, 是否会多读到一条数据呢? 在 InnoDB 提交读(Read Committed)隔离级别下, 事情的确是这样的. 对事务 A 来说, 幻读发生了.

不过, 在 InnoDB 可重复读(Repeatable Read)的隔离级别下这样的问题是不会发生的. InnoDB 是怎么做到的呢?

原来, 在可重复读这个隔离级别, InnoDB除了在数据上加了锁, 还在查询的使用的索引上, 在命中的索引位置上也加上锁, 如果是非唯一索引, 还会在命中索引位置的间隙加上了一种叫做 区间锁(Gap Lock) 的锁. 两把区间锁相互之间不会冲突, 但是会和插入数据时要加在对应索引位置上的排它锁(X锁)冲突. 在可重复读隔离级别下, 加在索引数据位上的锁和加在索引间隙中的区间锁合在一起, 被称为 Next-Key Lock. 正是因为 Next-Key Lock 锁机制的存在, 使在事务 A 执行第一次当前读之后, 事务 B 无法再向满足事务 A 查询条件的结果集中插入新的数据. 这是因为 B+ 树索引中的数据是连续存放的, 新符合条件的数据只能被插入和原本符合条件的数据相邻的位置, 而现在所有相邻的位置都被 Gap Lock 锁上了!

再回到本小节一开始提出的问题, 在 InnoDB 的可重复读隔离级别中, 幻读问题被解决了吗?

这个说法是正确. 当在我们讨论是否存在幻读问题时, 一定要在意识上将快照读和当前读区分开. 如果事务 A 的两次读都是快照读, 只要事务的快照没有发生更新(例如在事务 A 中执行了 UPDATE 操作将一些新增数据更新到的了当前快照中), 在快照不变前提下, 幻读问题是不存在的. 如果前后两次都是当前读, Next-Key Lock 锁机制也能保证不会发生幻读问题. 如果将快照读和当前读得到的结果不同理解成是幻读, 就是错误的了.

Next-Key Lock 确实解决了幻读问题, 不过这并不意味着 Next-Key Lock 是完美的. 正如那句老话, 软件工程"没有银弹". Next-Key Lock 机制体现了 InnoDB 研发团队在数据库的易用和性能之间的权衡, 虽然我们可以不再担心被幻读问题困扰, 但 Next-Key Lock 却也带来了更大的性能开销. 在这设计下, 作为开发人员的我们必须更充分的理解在可重复读级别下 InnoDB 引擎对数据加锁的处理过程, 否则, 一个细节上的疏漏产生后果可能是超乎想象的.

在下一小节中, 我将以具体例子作为说明, 分析在可重复读级别下 InnoDB 的加锁过程.

各种场景下的加锁分析

关于可重复读隔离级别下的加锁过程, 我将根据查询使用的条件索引, SQL操作的类型以及是命中数据的情况三个维度组合出的各种场景, 逐一来进行分析.

现在我们有如下一张用户数据表(users), 其中 id 是表的主键(PRIMARY KEY), 在 age 字段建有一个非唯一索引 idx_age, 在 name 上则没有索引. 现在, 表里有 4 条数据. 注意, 在后面的场景分析中, 所有查找的意思都是当前读

CREATE TABLE `users` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `age` int(11) NOT NULL,
  `name` varchar(255) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  KEY `idx_age` (`age`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4;

INSERT INTO `users` (`id`, `age`, `name`)
VALUES
	(1, 17, 'Tom'),
	(2, 20, 'Jack'),
	(5, 20, 'Andy'),
	(10, 27, 'Eric');

a. 唯一索引/聚簇索引 + 查找/更新/删除 + 命中数据

SELECT * FROM users WHERE id = 2 FOR UPDATE;

当我们在事务 A 中执行上面的 SQL 时, 对于一般索引和聚簇索引的处理还有一点小小的区别. 如果 id 是非主键的唯一索引, 本次查询首先会在唯一索引上为该数据所在的节点加上 X 锁, 然后再对数据本身加上 X 锁. 不过这里的 id 是主键索引, 索引的叶子节点就是数据本身, 所以比起一般的唯一索引少了一次加锁的过程. 不过这对最终的结果影响不大, 在事务 A 提交前, id 为 2 的这条数据将一直处于被锁定的状态, 我们任然正常访问表里其他的数据或者插入新的数据.

等等, 之前说的 Gap Lock 去哪了? 这是个好问题, 实际上当我们通过唯一索引对数据进行当前读时, InnoDB 只会在命中的数据和与之对应的索引元素上加锁, 并不会将索引元素间的间隙锁上. 理由是通过唯一索引本身的唯一性我们就能保证不可能再插入新的符合条件的数据, 所以当然也就没必要再去加 Gap Lock 了. 更新或删除操作的行为与查找类似.

b. 唯一索引/聚簇索引 + 查找/更新/删除 + 未命中数据

SELECT * FROM users WHERE id = 3 FOR UPDATE;

id 为 3 的数据在 users 表中并不存, 当我们在事务 A 中执行上面的 SQL 时, 没有任何数据会被锁住, 不过在这时, 事务 A 会在 id (2, 5) 的这个区间上加上 Gap Lock. 此时我们开启事务 B, 如果我对表中的任何位置(不论位置上是否有数据)进行当前读都不会有问题, 但假如我企图往 id (2, 5) 之间的某个位置插入一个新的元素时, 就会发现事务 B 被阻塞了! 因为当事务 B 在往 id (2, 5) 之间的某个位置插入元素前, 首先会获取 id (2, 5) 之间的 Gap Lock(插入意向锁, 是 Gap 锁的一种), 并在插入元素后获取该元素上的 X 锁. 由此可见 Gap Lock 相互之间不会冲突, 但是会和 X 锁产生冲突. 更新或删除操作的行为与查找类似.

再想想, 如果将查询条件中的 id = 3 换成 id = 11 会怎么样? 答案是, InnoDB 会为 (10, +无穷) 的区间都加上 Gap Lock, 这也意味着在这种场景下, 我们将无法向表中插入任何 id 自增的新数据!

c. 唯一索引/聚簇索引 + 查找/更新/删除 + 范围查询

SELECT * FROM users WHERE id > 2 and id < 8 FOR UPDATE;

范围查询的情况可以当做是上面两种情况的结合. 当事务 A 执行这条 SQL 时, id (2, 5), (5, 10) 之间的间隙都会被加上 Gap Lock, 且 id 为 5 的记录和索引元素上还会被加上 X 锁. 所以此时事务 B 想往 id (2, 10) 之间的任何位置插入元素都将会被阻塞, 但插入 id > 10 的元素确是可行的.

d. 非唯一索引 + 查找/更新/删除 + 命中数据

UPDATE users SET name = 'XXX' WHERE age = 20;

当我们在事务 A 中执行上面的 SQL 时, InnoDB 首先会在 idx_age 索引 age (17, 20) 的区间加上 Gap Lock, 为 (2, 20, 'Jack') 和 (5, 20, 'Andy') 加上 X 锁, 为 age (20, 27) 加上 Gap Lock. 然后为 age 为 20 的两条数据(聚簇索引的叶子节点)也加上 X 锁. 在事务 A 提交前, 事务 B 将无法在 age 为 [17, 27) 的范围内插入任何数据, 需要注意的是这是一个左闭右开的区间, 说明即使是 age 为 17 的数据也不能添加, 这是因为当我们添加一条 age 为 17 的新数据时, 需要将其插到 (1, 17, 'Tom') 后面的位置, 但是这条数据后面的位置此时已经被事务 A 的 Gap Lock 给锁上了!

如果你之前对可重复读级别下 InnoDB 的锁机制并不是太熟悉, 看到这里是不是觉得有点出人意料? 仅仅是更新两条数据而已, 甚至我们还有效利地用了索引, 最终锁影响的范围仍可能远远超过我们想要操作的数据.

e. 非唯一索引 + 查找/更新/删除 + 未命中数据

DELETE FROM users WHERE age = 24;

当操作无法命中数据时, idx_age (20, 27) 之间区间都被被加上 Gap Lock, 事务 B 无法在 age 为 [20, 27) 的范围内插入任何数据.

f. 无索引 + 查找/更新/删除

UPDATE users SET age=21 WHERE name = 'Jack';

在这种场景下无论是否命中数据, 因为没有索引可以走, InnoDB 将在数据表的聚簇索引上进行全表扫描.

这种场景产生的结果是可怕的! 在事务 A 执行期间, 这张表里所有的数据(聚簇索引上的叶子节点)和数据间的间隙都将被锁住, 只要事务 A 还没有提交, 其他事务将无法修改表里任何的数据, 而且也不能往表里插入任何的数据. 除了对表执行快照读外, 整张表的业务都被事务 A 中一条简单的更新操作阻塞了!

在理解了可重复读隔离级别下的锁机制以后, 再理解其他隔离级别就容易多了. 在提交读里没有 Gap Lock, 所以锁只能加在索引元素和数据上; 在可串行化里没有快照读, 所以任何读操作都是加锁的当前读:

提交读 = 可重复读 - Gap Lock
可串行化 = 可重复读 - 快照读

死锁

在何登成的一篇博客里讲了一个死锁的例子, 具体场景如下:

在这个例子中, 虽然每个 Session 都只有一条语句, 仍旧会产生死锁. 要分析这个死锁, 首先必须用到本文前面提到的 MySQL 加锁的规则. 针对 Session 1, 从 name 索引出发, 读到的 [hdc, 1], [hdc, 6] 均满足条件,不仅会加 name 索引上的记录X锁,而且会加聚簇索引上的记录X锁,加锁顺序为先 [1,hdc,100][6,hdc,10]. 而Session 2, 从 pubtime 索引出发, [10,6], [100,1] 均满足过滤条件, 同样也会加聚簇索引上的记录X锁, 加锁顺序为 [6,hdc,10][1,hdc,100]. 发现没有, 跟 Session 1 的加锁顺序正好相反, 如果两个 Session 恰好都持有了第一把锁, 请求加第二把锁, 死锁就发生了.

这个例子也告诉我们, 在实际的工程开发中, 想完全避免问题几乎是不可能的, 充分理解现象背后的逻辑, 掌握分析问题的方法才是每个开发者应该具有的能力.

参考资料:

Show Comments