为什么 InnoDB 引擎可以解决幻读问题

一、行锁到底锁的是什么

行锁是最重要的锁之一, MySQL 中 InnoDB 引擎就是通过行锁来解决的幻读问题。

为了验证行锁到底锁住了什么,我们通过几个例子来验证一下。

创建两张表并插入一些测试数据(注意表cc1 有 2 个索引而表 cc2 没有索引):

CREATE TABLE `cc1` (
  `id` int(11) NOT NULL,
  `name` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `NAME_INDEX` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO cc1 VALUE(1,'张1');
INSERT INTO cc1 VALUE(5,'张5');
INSERT INTO cc1 VALUE(8,'张8');
INSERT INTO cc1 VALUE(10,'张10');
INSERT INTO cc1 VALUE(20,'张20');

CREATE TABLE `cc2` (
  `id` varchar(32) NOT NULL,
  `name` varchar(32) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO cc2 VALUE(1,'张1');
INSERT INTO cc2 VALUE(5,'张5');
INSERT INTO cc2 VALUE(8,'张8');
INSERT INTO cc2 VALUE(10,'张10');
INSERT INTO cc2 VALUE(20,'张20');

插入测试数据后,我们来看一个例子(操作表 cc1),首先还是打开两个客户端。

客户端一执行以下语句:

BEGIN;
SELECT * FROM cc1 WHERE id=1 FOR UPDATE;

客户端二分别执行以下两条语句:

SELECT * FROM cc1 WHERE id=1 FOR UPDATE;-- 语句1
SELECT * FROM cc1 WHERE id=5 FOR UPDATE;-- 语句2

大家在执行前可以猜测一下,这两条语句是否可以加锁成功?

结果是语句 1 会阻塞,也就是加锁不成功,语句 2 会加锁成功。当客户端一执行 commit 之后,客户端二再执行语句 1 就加锁成功了(演示结束后大家记得要把事务 commit,防止数据一直被锁)。

看这个例子貌似锁是把 id=1 的这条数据给锁住了?接下来我们再看一个例子(操作表 cc2):

客户端一执行以下语句:

BEGIN;
SELECT * FROM cc2 WHERE id=1 FOR UPDATE;

然后客户端二分别执行以下两条语句:

SELECT * FROM cc2 WHERE id=1 FOR UPDATE;
SELECT * FROM cc2 WHERE id=5 FOR UPDATE;

大家再思考下这次的两条语句哪条会阻塞呢?

结果是两条语句都被阻塞了,通过这个例子好像和前面的结论冲突了,这次客户端一的加锁语句貌似并不仅仅锁住了一条数据,事实上经过测试,这次是整张表都被锁住了(演示结束后大家记得要把事务 commit,防止数据一直被锁)。

大家一起思考下这两个例子为什么会有这个差异,回到前面建表语句,我们发现这两张表的唯一区别就是表 cc1 有索引,而表 cc2 没有索引。

看到这个区别,结果似乎明朗了,锁锁住的是索引,一旦表没有索引,则会进行锁表

对于 InnoDB 而言,索引分为了聚集索引和非聚集索引,那么既然是锁住了索引,那假如锁住的是二级索引,是不是我们可以操作索引字段之外的其他字段呢?

说一千次道一万次,不如动手实践一次,我们还是看一个例子。

客户端一执行以下语句:

BEGIN;
SELECT * FROM cc1 WHERE NAME='张1' FOR UPDATE;

然后客户端二分别执行以下两条语句:

SELECT * FROM cc1 WHERE name='张1' FOR UPDATE;
SELECT * FROM cc1 WHERE id=1 FOR UPDATE;

经过前面的分析,我们知道客户端二的第一条语句肯定是会被阻塞,那么第二条语句呢?实际上第二条语句依然会被阻塞(演示结束后大家记得要把事务 commit,防止数据一直被锁)。

所以这里又可以得出一个结论:当辅助索引被锁住后,其对应的主键索引也会被锁住,而在 InnoDB 中,索引即数据,所以主键索引被锁,就相当于整条数据被锁住。

二、行锁的算法

MySQL 通过加锁来防止了幻读,但是如果行锁只是锁住一行记录,好像并不能防止幻读,所以行锁似乎并没有那么简单。

实际上行锁有三种算法:记录锁(Record Lock),间隙锁(Gap Lock)和临键锁(Next-Key Lock),而之所以能做到防止幻读,正是临键锁起的作用

记录锁(Record Lock)

记录锁比较容易理解,就像前面的例子中当我们的查询语句能命中一条记录的时候,InnoDB 就会使用记录锁,锁住所命中的这一行记录。

间隙锁(Gap Lock)

现在有一个情况就是,我们的查询语句并不总是能查询出数据,所以当我们的查询没有命中记录的时候,InnoDB 就会使用间隙锁。

客户端一执行以下语句:

BEGIN;
SELECT * FROM cc1 WHERE id=4 FOR UPDATE;-- 注意,表cc1中数据id只有1,5,8,10,20

客户端二分别执行以下语句,并关注是否会阻塞:

INSERT INTO cc1 VALUE (2,'张2'); -- 阻塞
INSERT INTO cc1 VALUE (3,'张3'); -- 阻塞
SELECT * FROM cc1 WHERE id=2 FOR UPDATE; -- 成功

这时候客户端一的查询语句没有符合条件的结果,所以无法使用记录锁,这时候就会使用间隙锁,而在 MySQL 中,间隙是根据索引值来划分的,因为表 cc1 中的主键值分别为:1,5,8,10,20。那么就会有如下六个间隙锁的区间: (-∞,1),(1,5),(5,8),(8,10),(10,20),(20,+∞)。

而 4 正好落在了间隙(1,5),所以 MySQL 会把这个间隙加一个间隙锁,防止数据插入,这样就防止了幻读,而客户端二最后一条语句能加锁成功,说明间隙锁之间并不冲突。

通过上面的例子,我们可以得出以下结论:

  • 间隙锁与间隙锁之间不冲突,也就是事务 A 加了间隙锁,事务 B 可以在同一个间隙中加间隙锁。

  • 间隙锁主要是会阻塞插入操作。

临键锁(Next-Key Lock)

临键锁就是记录锁和间隙锁的结合,采用的区间是左开右闭。当我们进行一个范围查询,不但命中了一条或者多条记录,且同时包括了间隙,这时候就会使用临键锁,临键锁是 InnoDB 中行锁的默认算法。

在表 cc1 中,会产生以下六个临键锁的区间:(-∞,1],(1,5],(5,10],(10,15],(15,20],(20,+∞)。

注意了:这里仅针对 RR 隔离级别,对于 RC 隔离级除了外键约束和唯一性约束会加间隙锁,其他情况并没有间隙锁,自然也就没有了临键锁,所以 RC 级别下加的行锁可以认为都是记录锁,没有命中记录则不加锁,也就是 RC 的隔离级别是没有解决幻读问题的。

行锁的加锁规则

行锁的加锁规则如下:

  1. MySQL 中默认使用 RR 隔离级别,而 RR 隔离级别下默认使用临键锁,也就是默认遵循左开右闭区间范围加锁。

  2. 当使用主键或者唯一索引命中记录时,临键锁会退化为记录锁(如文中最开始例子)。

  3. 当查询语句未命中任何记录时候,临键锁会退化为间隙锁(如间隙锁例子)。

  4. 查找过程中访问到的对象才会加锁。

  5. 索引上进行等值查询时,在向右遍历时发现最后一个值不满足等值条件的时候,临键锁会退化为间隙锁,即一般会锁住最后一个命中的 索引 value和其下一个左开右闭区间。

上面这些规则,前面 3 条相对比较容易理解,最后两条可能会有点不好理解,我们还是通过两个例子来验证,相信通过这两个例子大家就会理解到加锁规则了。

客户端一执行语句:

BEGIN;
SELECT * FROM `cc1` WHERE id>=10 AND id<11 FOR UPDATE;

客户端二执行语句:

UPDATE `cc1` SET `name`='15号-1' WHERE id=15;-- 5.7版本阻塞,8.0.21版本不阻塞

首先我们分析一下客户端一语句:

  • 条件 id>=10:根据加锁规则 5,当 MySQL 找到 id=10 的数据之后,会继续向 B+树的叶子节点继续往后遍历,直到下一个不等于 10 的值出现,然后将其锁住,所以加锁范围是 [10,15];

  • 条件 id<11:先找到 11,找不到 11 会继续向右边查找到下一个值,找到的也是 15,而因为条件是 11,所以小于 11的数据也会全部被扫描到,加锁范围是 (-∞,15]。

综合两个条件,加锁范围是 [10,15],然后又因为 id 是主键索引,所以根本就不需要锁住 15,因为在 [10,15) 这个区间不可能会再出现一条 id=15 的数据,所以其实 MySQL 根本就不需要再往后面去扫描数据了,结合规则 4,因为主键索引不会继续扫描 id=15 的数据,所以加锁范围最终会退化为 [10,15)(5.7 版本的 MySQL 有 BUG,15 也会被锁住,如果自己有 5.7 版本的 MySQL 的话,大家可以尝试验证一下)。

如果说把条件换成普通索引,那么这里就会锁住 15,因为MySQL 只有扫描到下一个值,发现他和当前条件不匹配才会结束扫描,根据规则 4,扫描到的数据就加上,所以会一直锁到 15 这个位置。

你可能感兴趣的:(MySQL,数据库,java,mysql)