1. 引言

上一篇文章中,我们介绍了 mysql 的 crash safe 机制,也是 ACID 中原子性的实现 – redolog 的原理和配置方法。
mysql 异常情况下的事务安全 – 详解 mysql redolog

本文,我们来介绍 mysql 在可重复读隔离级别下事务的实现方式 – MVCC,以及他的实现原理 – undolog。

# 此处有图片 1

2. undo log

undo log 是 MVCC 实现的一个重要依赖,所以在详细介绍 MVCC 前,我们先来介绍 undo log 是什么。
undo log 与 redo log 一起构成了 MySQL 事务日志,并且我们上篇文章中提到的日志先行原则 WAL 除了包含 redo log 外,也包括 undo log,事务中的每一次修改,innodb 都会先记录对应的 undo log 记录。
那么 undo log 是什么呢?顾名思义,与 redo log 用于数据的灾后重新提交不同,undo log 主要用于数据修改的回滚。

与 redo log 记录的是物理页的修改不同,undo log 记录的是逻辑日志。
当 delete 一条记录时,undo log 中会记录一条对应的 insert 记录,反之亦然,当 update 一条记录时,它记录一条对应相反的 update 记录,如果 update 的是主键,则是对先删除后插入的两个事件的反向逻辑操作的记录。

# 此处有图片 2

这样,在事务回滚时,我们就可以从 undo log 中反向读取相应的内容,并进行回滚,同时,我们也可以根据 undo log 中记录的日志读取到一条被修改后数据的原值。
正是依赖 undo log,innodb 实现了 ACID 中的 C – Consistency 即一致性。

3. undo log 的存储与相关配置

innodb 通过段的方式来管理 undo log,每一条记录占用一个 undo log segment,每 1024 个 undo log segment 被组织为一个回滚段(rollback segment)
mysql 5.6 版本以后可以通过 innodb_undo_logs 配置项设置系统支持的最大回滚段个数,默认为 128。
通过 innodb_undo_directory 配置可以设置 undo log 存储的目录。
通过 innodb_undo_tablespaces 可以设置将 undo log 平均分配到多少个文件中,默认为 0,即全部写入同一个文件中。

这里顺便说一下,在 mysql 5.6 的早期版本及之前的版本中,并没有限制回滚段的大小,这就造成了一个非常严重的漏洞,攻击者可以通过反复更新一个字段造成 undo log 占用大量的磁盘空间,可以参看:
https://blog.jcole.us/2014/04/16/a-little-fun-with-innodb-multi-versioning/
https://bugs.mysql.com/bug.php?id=72362。

4. MVCC

此前的文章中,我们介绍了 mysql 事务隔离级别,其中非常粗略的介绍了 MVCC:
mysql 锁机制与四种隔离级别

MVCC 全称是 multiversion concurrency control,即多版本并发控制,是 innodb 实现事务并发与回滚的重要功能。
具体的实现是,在数据库的每一行中,添加额外的三个字段:

  1. DB_TRX_ID – 记录插入或更新该行的最后一个事务的事务 ID
  2. DB_ROLL_PTR – 指向改行对应的 undolog 的指针
  3. DB_ROW_ID – 单调递增的行 ID,他就是 AUTO_INCREMENT 的主键 ID

# 此处有图片 3

5. 快照读与当前读

innodb 拥有一个自增的全局事务 ID,每当一个事务开启,在事务中都会记录当前事务的唯一 id,而全局事务 ID 会随着新事务的创建而增长。
同时,新事务创建时,事务系统会将当前未提交的所有事务 ID 组成的数组传递给这个新事务,本文的下面段落我们成这个数组为 TRX_ID 集合。

5.1. 快照读

正如我们前面介绍的,每当一个事务更新一条数据时,都会在写入对应 undo log 后将这行记录的隐藏字段 DB_TRX_ID 更新为当前事务的事务 ID,用来表明最新更新该数据的事务是该事务。
当另一个事务去 select 数据时,读到该行数据的 DB_TRX_ID 不为空并且 DB_TRX_ID 与当前事务的事务 ID 是不同的,这就说明这一行数据是另一个事务修改并提交的。
那么,这行数据究竟是在当前事务开启前提交的还是在当前事务开启后提交的呢?

# 此处有图片 4

如上图所示,有了上文提到的 TRX_ID 集合,就很容易判断这个问题了,如果这一行数据的 DB_TRX_ID 在 TRX_ID 集合中或大于当前事务的事务 ID,那么就说明这行数据是在当前事务开启后提交的,否则说明这行数据是在当前事务开启前提交的。
对于当前事务开启后提交的数据,当前事务需要通过隐藏的 DB_ROLL_PTR 字段找到 undo log,然后进行逻辑上的回溯才能拿到事务开启时的原数据。
这个通过 undo log + 数据行获取到事务开启时的原始数据的过程就是“快照读”。

5.2. 当前读

很多时候,我们在读取数据库时,需要读取的是行的当前数据,而不需要通过 undo log 回溯到事务开启前的数据状态,主要包含以下操作:

  1. insert
  2. update
  3. select … lock in share mode
  4. select … for update

6. MVCC 与不可重复读、幻读的问题

6.1. 不可重复读与幻读

“不可重复读”与“幻读”是两个数据库常见的极易混淆的问题。
不可重复读指的是,在一个事务开启过程中,当前事务读取到了另一事务提交的修改。
幻读则指的是,在一个事务开启过程中,读取到另一个事务提交导致的数据条目的新增或删除。

6.2. 可重复读解决不可重复读与幻读问题的原理

那么,可重复读的隔离级别是否解决了不可重复读与幻读问题呢?
上面我们提到,对于正常的 select 查询 innodb 实际上进行的是快照读,即通过判断读取到的行的 DB_TRX_ID 与 DB_ROLL_PTR 字段指向的 undo log 回溯到事务开启前或当前事务最后一次更新的数据版本,从而在这样的场景下避免了可重复读与幻读的问题。
针对已存在的数据,insert 和 update 操作虽然是进行当前读,但 insert 与 update 操作后,该行的最新修改事务 ID 为当前事务 ID,因此读到的值仍然是当前事务所修改的数据,不会产生不可重复读的问题。
但如果当前事务更新到了其他事务新插入并提交了的数据,这就会造成该行数据的 DB_TRX_ID 被更新为当前事务 ID,此后即便进行快照读,依然会查出该行数据,产生幻读(其他事务插入或删除但未提交该行数据的情况下会锁定该行,造成当前事务对该行的更新操作被阻塞,所以这种情况不会产生幻读问题,有关事务间的锁,不在本篇文章的讨论范围内,接下来的文章我们会进一步讨论)

6.3. 实证

我们实际来看一个例子。
首先,我们创建一个表:

CREATE TABLE `test` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `value` int(10) unsigned NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

然后我们插入三条初始数据:

INSERT INTO `test` (`value`) VALUES (1), (2), (3)

接下来我们在两个窗口中分别开启一个事务并查询出现有数据:

# 此处有图片 5

我们在其中一个事务中先更新 id 为 1 的数据,再插入一条 id 为 4 的数据,再删除 id 为 2 的数据,然后,在另一个事务中查询,可以看到此时查询出来的仍然是事务开启时的初始数据,说明当前隔离级别和场景下并没有脏读的问题存在:

# 此处有图片 6

此时,我们提交所有的修改,接着在另一个事务中查询,可以看到此时查询到的结果仍然是事务开启前的原始数据,说明当前隔离级别和场景下并没有不可重复读和幻读的问题存在:

# 此处有图片 7

那么接下来,我们在未提交的这个事务中执行一条修改,可以看到,本应在事务中只影响一行的 update 操作返回了 changed: 2,接着,我们查询结果出现了 id 为 4 的行,说明了幻读问题的存在:

# 此处有图片 8

7. undo log 的清理

在回滚段中,每个 undo log 段都有一个类型字段,共有两种类型:insert undo logs 和 update undo logs。
对于执行 insert 语句插入的数据,其回滚段类型为 insert undo logs,用来在事务中回滚当前的插入操作。
对于执行 delete 语句删除和 update 语句更新的数据,其回滚段类型为 update undo logs。
如果事务 rollback,innodb 通过执行 undo log 中的所有反向操作,实现事务中所有操作的回滚,随后就会删除该事务关联的所有 undo log 段。
如果事务 commit,对于 insert undo logs,innodb 会直接清除,但对于 update undo logs,只有当前没有任何事务存在时,innodb 的 purge 线程才会清理这些 undo log 段。
这里提到了 purge 线程,他是一个周期运行的垃圾收集线程,主要用来收集 undo log 段,以及已经被废弃的索引。
在事务提交时,innodb 会将所有需要清理的任务添加到 purge 队列中,可以通过 innodb_max_purge_lag 配置项设定 purge 队列的大小。
purge 线程会在周期执行时,对 purge 队列中的任务进行清理,innodb_max_purge_lag_delay 配置项说明了 purge 线程的执行周期间隔。
所以,尽量缩短使用中每个事务的持续时间,可以让 purge 线程有更大概率回收已经没有存在必要的 undo log 段,从而尽量释放磁盘空间的占用。

8. 《高性能 MySQL》中的谬误

主页君在多年以前曾经就 MVCC 的实现阅读过相对非常权威的著作《高性能 MySQL》,其中有着下面的一段话:

# 此处有图片 9

主页君看到网上目前许许多多的博客都是按照上述文字中介绍的原理来讲述的。
但当如今主页君仔细去深究其中的原理,参阅官方文档之后,发现各版本 innodb MVCC 的原理并不是书上所描述的这样,毕竟官方文档是除源码外的第一手资料,同时,参阅一些文章贴出的源码来看,确实是按照官方文档中介绍的原理实现的,因此,本文主要参阅官方的相关源码进行详细的总结和讲述。
那么,《高性能 MySQL》中的描述是来源于哪里呢?事实上,它讲述的是 PostgreSQL 的实现方式。
与 InnoDB 类似,PostgreSQL 为每一行数据添加了 4 个额外的字段:

  1. xmin – 插入与更新数据时写入的事务 ID
  2. xmax – 删除数据时写入的事务 ID
  3. cmin – 插入与更新数据时写入的命令 ID
  4. cmax – 删除数据时写入的命令 ID

在每一个事务中,都维护了一个从 0 开始单调递增的命令 ID(COMMAND_ID),每当一个命令执行后,COMMAND_ID 都会自增。
当一个事务更新一条数据,PostgreSQL 会创建一条新的记录,并将新的记录的 xmin 更新为当前事务的事务 ID。
当一个事务删除一条数据,PostgreSQL 不会创建一条新纪录,而是将该行记录的 xmax 更新为当前事务的 ID。
因为 cmin 和 cmax 的记录,PostgreSQL 可以以此排列出同一事务中所有更新、删除操作的先后。
这样,在一个事物读取数据时,只需要读取 xmin 小于当前事务 ID 且 xmin 不在 TRX_ID 集合中的数据即可实现快照读的功能。

8.1. 优缺点

PostgreSQL 的 MVCC 实现与 innodb 的 MVCC 实现相比,最大的优点在于其查询无需解析 undo log 进行回溯。
对于数据回滚,只需要删除所有 xmin 为当前事务 ID 的记录,清除所有 xmax 为当前事务 ID 的 xmax 字段即可。
但其缺点也很明显,那就是随着更新操作,数据库中会产生大量的额外数据,这些数据同时也对数据库其他的操作例如索引的建立等都带来了额外的性能消耗。
在这里插入图片描述

9. 微信公众号

欢迎关注微信公众号,以技术为主,涉及历史、人文等多领域的学习与感悟,每周三到七篇推文,只有全部原创,只有干货没有鸡汤
在这里插入图片描述

10. 参考资料

https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_purge。
https://dev.mysql.com/doc/refman/5.6/en/replication-gtids.html。
https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html。
https://blog.jcole.us/2014/04/16/the-basics-of-the-innodb-undo-logging-and-history-system/

Logo

更多推荐