MySQL 数据库之 InnoDB 事务隔离级别和锁的关系

摘要:在 sql 标准中定义了 4 种隔离级别,每一种级别都规定了一个事务中所做的修改,那些在事务内和事务间是可见的,那些是不可见的。较低级别的隔离性通常可以执行更高的并发,系统的开销也更低。mysql 事务隔离级别是可重复读,是依靠 mvcc + next-key lock 实现的。

MySQL 的锁、索引、事务、隔离级别系列文档

MySQL 索引的分类和各种索引的简单理解

MySQL 锁的分类和死锁的解决方案

MySQL 事务的概念和事务 ACID 基本实现原理

MySQL 数据库之 InnoDB 事务隔离级别和锁的关系(本文)


SQL 的隔离级别

数据库是存放数据的地方,大部分业务场景无非就是业务程序操作数据库,数据库中有事务的机制,用来保证操作的原子性、一致性、持久性。每个客户端都可以开启一个事务去执行操作,都可以提交和回滚。每个客户端开启事务后,对数据库的操作也是并发执行的。有可能好几个客户端都在操作同一行数据,对这一行数据的更新也不一样,那么此时当前事务修改的未提交数据和已提交数据是否要对其他事务可见?这些都需要隔离性来控制。

在《高性能 MySQL》书中对事务描述有这样一段话:“在 sql 标准中定义了 4 种隔离级别,每一种级别都规定了一个事务中所做的修改,那些在事务内和事务间是可见的,那些是不可见的。较低级别的隔离性通常可以执行更高的并发,系统的开销也更低"。隔离性在事务中非常重要,可以保证事务的并发,使各事务操作之间不受影响。


四个隔离级别概念

read uncommitted:读未提交,当前事务会读取到其他事务未提交的数据,这种数据称为 “脏读”。

read committed:读已提交,当前事务会读取到其他事务已经 commit 的数据,可以解决“脏读”,但是会出现“幻读”和“不可重复读”。

repeatable read:可重复读,mysql 默认隔离级别,有效的解决“脏读”、“幻读”,实现“可重复读”,当前事务读取不会受其他事务 commit 或 rollback 影响。

serializable:串行化,最高的隔离级别,不会出现“脏读”、“幻读”,实现了“可重复读”,优点实现简单,缺点并发比较低。

注意:这四种隔离级别都是 sql 的标准定义,不同的数据库会有不同的实现,特别注意的是“mysql 在可重复读隔离级别下,是可以禁止幻读问题的发生”。四种隔离级别演示过程:MySQL 事务的四个隔离级别演示


四种隔离级别的实现

read uncommitted 隔离级别相当于没有隔离级别,总是读取最新的数据,在实际业务中会造成很多问题,此处就不表了。

为什么隔离级别可以提高并发能力?假如没有隔离级别的概念,在事务中要避免脏读、幻读和实现可重复读,应该怎么实现呢?其实 serializable 隔离级别就是一个最好的例子,他会对读加共享锁,写加排他锁,读写互斥,这样就很好的避免脏读、幻读和实现可重复读。但是这种方式并发能力太差,由于每次都会加锁,会导致大量的锁超时和锁争用的问题。

我们可以换着理解下,写操作加锁是正常的,当前事务操作的数据是不允许其他事务操作的。但是读操作其实没有必要加锁,我们可以利用某些机制和手段,让 innodb 存储引擎避免脏读、幻读和实现可重复读取。说到这里,不妨把这种不需要加锁的读取称为“快照读”,也就是读取的历史数据。而对于更新操作,需要加锁的操作称为“当前读”,事务操作的是数据库中的最新数据,而不是历史数据,所以要等待其他事务操作完成 commit 后,当前事务才可以继续操作。

而 read committed 和 repeatable read 隔离界别就实现了刚才的描述。对于避免脏读,实现可重复读,是依靠 mvcc 实现,对于避免幻读是依靠 next-key lock 实现。mvcc 是基于 undo log 实现,并且在 mvcc 中有版本链和 read view 两个概念。而 next-key lock 是行锁的一种加锁算法,从而可以有效的避免幻读。

总结来说,存在即合理,每个隔离级别都有自己的使用场景,要根据实际的业务情况来定。接下来,着重记录下 mvcc 的简单实现,幻读是如何产生,幻读又是如何解决的。还有什么是“快照读”和“当前读”。


MVCC 多版本并发控制

mvcc 是 multiversion concurrency control 的简称,也就是多版本并发控制。我们可以将 mvcc 看成对行锁的一种妥协,它在许多情况下避免了使用锁,同事可以提供更小的锁开销。根据实现的不同,他可以允许非阻塞式读,在写操作时只锁定必要的记录。

mvcc 的实现,是通过保存数据在某个时间点的快照实现的。也就是说,不管事务执行多长时间,每个事务看到的数据都是一致的。根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能不一样。还有就是 mvcc 只在 read committed 和 repeatable read 两个隔离级别下工作,其他两个隔离级别都和 mvcc 不兼容。


版本链

要理解 mvcc 概念,这里有两个重要的点就是版本链和 read view。正常来讲,新建一个表会有如下三个字段:

——————————————————————
| 主键 | 姓名 | 年龄 |
——————————————————————
| id   | name | age  | 
——————————————————————
| 1    | 张三 | 30   |
——————————————————————

实际上对于 innodb 存储引擎来说,他的聚簇索引记录中都包含两个必要的隐藏列 trx_id 和 roll_pointer,那么实际结构如下:

——————————————————————————————————————————————
| 主键 | 姓名 | 年龄 | 事务id | 回滚指针     |
——————————————————————————————————————————————
| id   | name | age  | trx_id | roll_pointer |
——————————————————————————————————————————————
| 1    | 张三 | 30   | 100    |              |
——————————————————————————————————————————————

● trx_id:每次对某条记录进行更新或者插入时,都会生成一个新的数据版本,并且把事务 id 赋值给 trx_id。

● roll_pointer:每次对某条记录进行更新或者插入时,这个隐藏列会存一个指针,可以通过这个指针找到该记录修改前的数据版本,也可以称为回滚指针。这个指针会把所有版本连接起来,形成一个版本链。

通过 roll_pointer 回滚指针,所有被修改过的记录就串联在一起,形成一个单链表,链表头就是当前最新的数据记录,即最后一次实际 commit 对应的记录,链表尾则是很久之前修改的记录,同时每个链表元素都有一个 trx_id 记录着该记录对应的修改事务的事务编号,每个链表元素可以认为是对应事务生成的快照。

				——	——————————————————————————————————————————————	 ——
				|	| 主键 | 姓名 | 年龄 | 事务id | 回滚指针     |	  |
				|	——————————————————————————————————————————————	  |
				|	| id   | name | age  | trx_id | roll_pointer |	  |
数据库最新数据	|	——————————————————————————————————————————————	  |
				|	| 1  | 张三   | 18   | 100    |              |	  |
				——	——————————————————————————————————————————————	 ——
										↓
				——	——————————————————————————————————————————————	 ——
				|  	| 1  | 李四   | 19   | 40     |              |	  |
				|	——————————————————————————————————————————————	  |
				|						↓						 	  |
				|	——————————————————————————————————————————————	  |
undo log 日志   |	| 1  | 王五   | 20   | 40     |	 		     | 	  | 修改历史版本
				|	——————————————————————————————————————————————	  |
				|						↓						 	  |
				|	——————————————————————————————————————————————	  | 
				|	| 1  | 马六   | 21   | 10     |              |	  |
				——	——————————————————————————————————————————————	 ——

上面这个就是一个版本链,通过这个版本链可以将事务回滚到任意一个版本上,也可以读取任意一个版本的数据,实现读已提交和可重复读。我们知道在可重复读隔离级别下,事务启动的时候,只能看见所有已提交的事务,其他未提交的事务是看不到的。

因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。

为了解决读取那个版本的问题,提出了 read view 的对比方案。那么实际放到 innodb 中是怎么实现的呢?接下来就要引入 read view 的概念了。


Read View

在实现上, innodb 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 id。“活跃”指的就是,启动了但还没提交。数组里面事务 id 的最小值记为低水位,当前系统里面已经创建过的事务 id 的最大值加 1 记为高水位。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。

read view 中主要包含 4 个比较重要的内容:

m_ids:表示在生成 read view 时,当前系统中活跃的读写事务的事务 id 列表。
min_trx_id:表示在生成 read view 时,当前系统中活跃的读写事务中最小的事务 id,也就是 m_ids 中最小的值。
max_trx_id:表示生成 read view 时,系统中应该分配给下一个事务的 id 值。
creator_trx_id:表示生成该 read view 的事务的事务 id。

> 注意 max_trx_id 并不是 m_ids 中的最大值,事务 id 是递增分配的。比方说现有 id 为 1,2,3 这三个事务,之后 id 为 3 的事务提交了。那么一个新的读事务在生成 read view 时,m_ids 就包括 1 和 2,min_trx_id 的值就是 1,max_trx_id 的值就是 4。

在一个事务启动的时候,生成的事务 id 有以下几种可能:

1、如果被访问版本的 trx_id 属性值小于 m_ids 列表中最小的事务 id,表明生成该版本的事务在生成 read view 前已经提交,所以该版本可以被当前事务访问。

2、如果被访问版本的 trx_id 属性值大于 m_ids 列表中最大的事务 id,表明生成该版本的事务在生成 read view 后才生成,所以该版本不可以被当前事务访问。

3、如果被访问版本的 trx_id 属性值在 m_ids 列表中最大的事务id和最小事务 id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 read view 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 read view 时生成该版本的事务已经被提交,该版本可以被访问。


何时创建 Read View

我们知道 mvcc 只在read committed 和 repeatable read 两个隔离级别下工作,两个隔离级别创建视图的时机也不同:

● read committed 在每一次进行普通 select 操作前都会生成一个 read view。

● repeatable read 只在第一次进行普通 select 操作前生成一个 read view,之后的查询操作都重复使用这个 read view 就可以了。

正常来讲视图是在事务开启时候,第一个 select 才会创建一个 read view,但是我们使用“start transaction with consistent snapshot; ”的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的 start transaction。


快照读和当前读

事务的隔离级别其实主要是针对事务读的操作,比如某些当前事务的数据是否对其他事务可见或不可见。比如 read committed 读已提交和 repeatable read 可重复读,都是读取的历史数据,是不及时的数据,而不是当前数据库的数据。这在一些对于数据的时效特别敏感的业务中,很有可能出现问题。

对于这种读取历史数据的方式,我们称为“快照读(snapshot read)”,而读取数据库当前版本数据的方式,我们称为“当前读(current read)”。很显然,在 mvcc 中:

快照读:就是事务中普通的 select

select * from ....;

当前读:特殊的操作,插入/更新/删除操作,属于当前读(需要注意的是 select 加锁也是当前读),处理的都是当前数据库最新版本的数据,需要加锁。

select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update ;
delete;

我们可以想象下,快照读就是普通的 select 访问版本链的过程,访问的历史数据。但是当前读是更新操作,是需要加锁的,因为一行数据只能被一个事务修改,无法被其他事务同时修改。并且其他事务再操作数据的时候也是操作的数据库中的最新数据,而不是历史数据。

我们可以用一个例子来解释下快照读和当前读,开启两个 mysql 客户端会话,假设当前隔离级别是 repeatable read:

# 准备工作
mysql> CREATE TABLE `t` (
    ->   `id` int(11) NOT NULL,
    ->   `k` int(11) DEFAULT NULL,
    ->   PRIMARY KEY (`id`)
    -> ) ENGINE=InnoDB;
Query OK, 0 rows affected (0.03 sec)

mysql> insert into t(id, k) values(1,1),(2,2);
Query OK, 2 rows affected (0.01 sec)
Records: 2  Duplicates: 0  Warnings: 0
# 事务A
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t;
+----+------+
| id | k    |
+----+------+
|  1 |    1 |
|  2 |    2 |
+----+------+
2 rows in set (0.00 sec)
# 事务B
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t set k=k+1 where id = 1;
Query OK, 1 row affected (0.04 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> commit;
Query OK, 0 rows affected (0.00 sec)
# 事务 A
mysql> select * from t;
+----+------+
| id | k    |
+----+------+
|  1 |    3 |
|  2 |    2 |
+----+------+
2 rows in set (0.00 sec)


mysql> commit;
Query OK, 0 rows affected (0.01 sec)

此时我们发现事务 A 更新前和更新后,数据发生了变化,并没有实现可重复读取。此时的事务 B 不应该读取到 (1, 2),(2, 2)吗?为什么读取的却是(1, 3),(2, 2)这是为什么呢?那么事务到底是隔离还是不隔离的呢?这是因为事务 A 在更新前,被事务 B 更新了数据,这是因为事务 A 的更新操作是当前读,需要获取数据库的最新数据,而不是快照中的历史数据,否则事务 B 的更新就丢了。因此,事务 B 此时的 set k=k+1 是在(1, 2)的基础上进行的操作。所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

如果此时事务 B 更新的是 id = 2 的数据,那么事务 A 的读取结果肯定是(1, 2),(2, 2),实现了可重复读取,因为此时事务 A 读取的 id = 2 的行数据还是快照中的数据。但是如果此时事务 B 更新完成后没有马上提交,那么此时的事务 A 会进入等待状态,因为更新的数据只能被一个事务操作,其他事务都需要进入等待状态,等其他事务更新完成后,当前事务才可以继续操作。

当时当前读中还有一点需要注意就是“幻读”,幻读是依靠 next-key 锁解决的。


幻读是什么?怎么解决的?

在 MySQL 锁的分类和死锁的解决方案 这篇文章的【行锁的三种算法】中简单说了下幻读。所谓幻读“指的是当某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围的记录时,会产生幻行(phantom row)”。mysql 是利用 next-key 来避免幻读的。

结束语:感谢您对本网站文章的浏览,欢迎您的分享和转载,但转载请说明文章出处。
Top