跳至主要內容

隔离级别的实现原理

AruNi_Lu数据库MySQL约 2529 字大约 8 分钟

本文内容

1. 四种隔离级别是如何实现的?

在四种隔离级别中,读未提交和串行化的实现是最简单的,基本不需要做过多的事情:

  • 读未提交:每次读取数据时,不做任何措施,直接读取最新数据 就好了,所以可以读到未提交的数据;
  • 串行化:通过加 读写锁 来避免并发访问数据,读加读锁、写加写锁,读写锁互斥,所以一个事务在读的时候,另一个事务不可进行写操作,反之亦然。

而对于读提交和可重复读的实现机制,就要涉及到额外的知识了。其实跟一个视图有关系,对于这两个隔离级别,数据库中会创建一个 视图,事务内访问的时候 以视图的逻辑结果为准,唯一的区别是视图的创建时机不同:

  • 读提交:视图是在 每条 SQL 语句开始执行时 创建的,所以只要在执行这条 SQL 前,别的事务做的修改已经提交了,那么创建的视图就包含最新的记录;
  • 可重复读:视图是在 事务启动时 创建的,整个事务期间都使用这个视图,所以不会受到其他事务的影响。

由于每个不同时间开启的事务,它们创建的视图是不同的,那视图所读取到的数据也是不同的,那这些数据是怎么组织/保存的呢?这就涉及到 MVCC 了。

2. MVCC 如何工作?

MVCC 全称是 Multi-Version Concurrency Control,多版本并发控制,指的是在使用 读提交可重复读 这两种隔离级别的事务下,执行普通 SELECT 操作时访问记录的版本链的过程

通过这个版本链,既可以 保障并发事务的隔离性、又可以 提高数据库的性能(可以使得 读-写、写-读操作并发执行)。

这个版本链是由什么构成的呢?与前面讲的视图又有什么关系?

其实,版本链是由最新记录和多个 undo log 构成,每一个 undo log 都是一个视图,当事务中需要访问某个记录时,沿着 undo log 链(版本链)往下找,找到当前事务能看见的视图即可。

不了解 undo log 的可以先看 undo log:世上真有后悔药open in new window

那么又如何判断当前事务能看见的视图是哪个呢?这又要讲讲 Read View 了,它在 MVCC 中起到了至关重要的作用。不过再此之前,我们得先知道版本链长什么样。

2.1 版本链长什么样?

首先得知道,在 InnoDB 存储引擎中,聚簇索引的记录中都包含着两个 隐藏字段,分别是 trx_id 和 roll_pointer:

  • trx_id操作该条记录的事务 id,当某个事务对聚簇索引的记录做了修改操作(CUD 都算)时,就会把该事务的事务 id 值赋给该记录的 trx_id 字段;
  • roll_pointer:当某个事务对聚簇索引的记录做了修改操作(CUD 都算)时,都会把原记录写到 undo log 中,这个 roll_pointer 就相当于一个指针,指向这个 undo log,即通过新记录中的 roll_pointer 就可以找到旧纪录。

注意:row_id 不是必要的隐藏字段,当表中没有主键或非空的唯一键时才会包含 row_id。

下面用一个 hero 表做示例,来看看版本链是怎么连接而成的。现在 hero 中只有一条记录:

numbernamecountry
1刘备

假设在插入上面这条记录时,其事务 id 为 80,那么该记录的示意图如下:

image-20231207181958726

注意:insert undo 只在事务回滚时起作用,当事务提交后就没用了,提交后该类型的 undo log 会被清理,但 roll_pointer 不会被清理,还是占用 7 个字节,其值表示的信息有 undo log 的类型等。

现在我们启动两个事务对上面这条记录进行修改操作:

image-20231207183631231

每次对记录进行改动都会生成一条 undo log,而每条 undo log 也会有一个 roll_pointer 属性(除了 insert undo,其没有更早的记录了),通过最新记录、undo log、roll_pointer 就可以将这些串联起来,形成一个版本链了,如下图所示:

image-20231207183949750

在版本链中,表头就是当前记录的最新值,其他都是旧记录,被 roll_pointer 串联起来。其中的 trx_id 很重要,在下面的 Read View 中就会使用到。

需要注意的是,这些 undo log 并不是一直保存着的,否则多么消耗空间。当没有事务需要用到这些 undo log 时,即系统中没有比该 undo log 更早的 Read View 时(通过 trx_id 判断),就会被删除。

因此,尽量 不要使用长事务,因为 长事务会导致系统中存在很老的视图,所以在其提交之前,这些 老的 undo log 都必须保留着,这就会导致 占用大量的存储空间。而且 长事务还可能占用更久的锁资源,因为锁是需要的时候加上,但要事务结束才释放。

而且在 MySQL 5.5 之前undo log 和数据字典是存放在一起的,都在 .ibd 文件中,所以就算最终长事务提交了,undo log 被清理,但文件也不会缩小。因为不能将直接 ibd 文件删除,只是之前 undo log 的位置可以被重用(覆盖)了,即逻辑上的空闲

不过从 MySQL 5.6、5.7 开始,undo log 和数据字典就是分开文件存储的了,undo log 存储在与事务日志相关的文件中(系统表空间的单独部分),数据字典存储在 ibdata 文件中。

2.2 Read View

我们前面说到,判断当前事务能看见的视图是哪个需要使用到 Read View,在 Read View 中,有四个非常重要的字段:

  • m_ids:在生成 Read View 时,当前系统中 活跃的事务 id 列表,活跃事务即开启但还没提交的事务;
  • min_trx_id:在生成 Read View 时,活跃事务 id 列表中最小的事务 id,即 m_ids 的最小值;
  • max_trx_id:生成 Read View 时,系统应该 分配给下个事务的事务 id,注意并不是 m_ids 的最大值,而是下一个事务的 id(事务 id 是递增分配的);
  • creator_trx_id:生成该 Read View 事务的事务 id

通过 Read View 可以将记录中的 trx_id 划分为下面这三种情况:

image-20231207221934054

这样就可以根据 Read View 和前面的版本链,判断记录的某个版本是否可见了,具体规则如下:

  • 被访问版本的 trx_id 等于 Read View 中的 creator_trx_id,则该版本对当前事务 可见,因为这是当前事务 在访问它自己修改过的记录
  • 被访问版本的 trx_id 小于 Read View 中的 min_trx_id,表明该版本的事务 在生成该 Read View 之前就已经提交, 所以该版本对当前事务 可见
  • 被访问版本的 trx_id 大于等于 Read View 中的 max_trx_id,表明该版本的事务 在当前事务生成 Read View 后才开启,所以该版本对当前事务 不可见
  • 被访问版本的 trx_id 在 Read View 的 min_trx_id 和 max_trx_id 之间,则需要判断 trx_id 是否在 m_ids 列表中
    • ,表明创建 Read View 时,该版本的事务还是 活跃着(还没提交),所以该版本对当前事务 不可见
    • 不在,表明创建 Read View 时,该版本的事务 已经被提交,所以该版本对当前事务 可见

如果某个版本的记录对当前事务不可见,就 顺着版本链依次往下找 即可,若最后一个版本也不可见,则表明该记录对事务完全不可见,查询结果就不会包含此纪录。

3. 总结

知道了版本链和 Read View,再来回顾下 MVCC 的概念就好理解多了,即执行普通 SELECT 操作时 访问记录的版本链的过程,MVCC 只有在读提交和可重复读的隔离级别中才会使用到。它们最大的不同就是 生成 Read View 的时机不同

  • 读提交在每次查询前都会生成一个 Read View;
  • 而可重复读只有在第一次查询前会生成一个 Read View,之后的查询都复用这个 Read View。所以才能保证整个事务内读取多次某条记录,每次读取到的都是相同的。

而读未提交和串行化则不需要额外的控制了,分别靠每次查询直接读取最新记录和加读写锁保证。

4. 参考文章

上次编辑于: