引言
本文章将逐步拆分讲解 ReadView,深入理解当前读和一致性读的区别。使用查询语句和更新语句逐步分析 MySQL 是如何进行事务的多版本并发控制的。我们以一个问题开始我们的讨论吧
begin/start transaction 命令代表了事务的开启吗?换句话说指向该命令的时候,会生成一个 ReadView 吗?
正文
首先需要解释的就是 begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作的 InnoDB 表的语句,事务才真正启动。如果想要马上启动一个事务,需要使用 start transaction with consitent snapshot 命令。这里将的视图都是一致性视图:consistent read view,而不是 View 视图虚拟表
- begin/start transaction一致性视图是在第执行第一个快照读语句时创建的
- start transaction with consitent snapshot一致性视图是在执行该命令时创建的
接下来我们将 read view 拆开来看,进一步理解 MVCC,也就是"快照"在 MVCC 里是怎么工作的?
在可重复读隔离级别下,事务在启动的时候就"拍了个快照"。注意,这个快照是基于整库的。InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的 而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id 。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id,下图就是 undo log 引用链

其实在 InnoDB 的存储中,每一个行数据就是上图 V4 表格中的内容,包含了 trx_id 和 roll_pointeri 字段。而后面虚线连接的三个表格其实就是 undo log 中的内容。此外 V3,V2,V1 版本在数据库中并不是物理真是存在的,而是每次需要这个版本的数据时,去根据 roll_pointer 指针计算。例如 V3 版本的数据就是通过 V4 依次执行 U3 和 U2 命令计算而来的
所以,一个事务启动的时候,只需要声明当前事务 ID 就能够知道哪些数据是否可以读取。以当前事务启动为准,如果一个数据版本是在当前事务之前生成的,那么就认为可读。如果是当前事务之后才生成的,就说明不能够读这个版本,需要沿着 roll_pointer 指针找到之前的版本
我们继续理解一致性视图 ReadView。每一次生成 ReadView 时,都会记录下图中的四个值。其中活跃的事务 ID 列表就是指已经启动的但还未提交的事务

数据版本的可见性规则就是基于 trx_id 和 ReadView 的对比结果完成的,具体的流程如下
- 如果 trx_id 是当前的事务,那么就可以访问
- 如果事务 id 是活跃 id 之前就已经提交的事务,那么就可以访问
- 如果事务 id 是当前事务之后才开启的事务,那么就不可以访问
- 如果事务 id 处于活跃 id 集合中,有两种情况
- 4.1 活跃 id 已经提交,可以访问
- 4.2 活跃 id 未提交,不可以访问
通过时间节点(trx_id 的序号)来看就是下图的效果
读到这里,你就明白了"所有数据都有多个版本"的这个特性,以及为什么 MySQL 怎么实现"秒级创建快照"的能力
下面我们进一步用示例理解上面的流程,并进一步理解 Repeatable Read 和 Read Commit
当事务 104 执行第一个 select name from person where id = 1;,会生成一个 ReadView,如下
此时 undo log 的版本链如下
当查询的时候,会拿着 V2 版本的 trx_id,也就是 102 去和 ReadView 中的内容进行判断(逻辑上面讲过)
- 102 是否是create_trx_id?不是,继续判断
- 102 是否小于 101?不是,继续判断
- 102 是否大于 104?不是,继续判断
- 102 是否在m_ids中?不在,继续判断,只剩下最后一种可能
- 102 不在m_ids中,并且事务员已经提交 ==> 可以访问
当事务 104 执行第二个 select name from person where id = 1;,根据不同的隔离级别有不同的处理方式
- RC 级别,此时会在生成一个新的 ReadView。那么此时的 ReadView 是不是就和第一次不一样了(比如这一次生成的 ReadView 中,m_ids只有 101 了)?那整个查询的结果是不是可能就不一样了,这里就不再继续演示
- RR 级别,会继续沿用第一次生成的 ReadView,所以判断的结果是一样的!
说完了读,我们继续讲更新数据的原则,在下面的例子中(事务 C 采用的是自动提交模式),最后事务 A 查询的结果是 3,那么根据一致性读,结果似乎不正确?
在事务 B 的 update 语句中,事务 B 的活动是先生成的,之后事务 C 才提交,不是应该看不见 (1,2) 吗,怎么能算出 (1,3) 来?

其实如果事务 B 在更新之前查询一次数据,这个查询返回的 k 的值确实是 1。
但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务 C 的更新就丢失了。因此,事务 B 此时的 set k=k+1 是在(1,2)的基础上进行的操作
所以,这里就用到了这样一条规则:
更新数据都是先读后写的,而这个读,只能读当前的值,称为"当前读"(current read)
因此,在更新的时候,当前读拿到的数据是 (1,2),更新后生成了新版本的数据 (1,3),这个新版本的 trx_id 是 101。所以,在执行事务 B 查询语句的时候,一看自己的版本号是 101,最新数据的版本号也是 101,是自己的更新,可以直接使用,所以查询得到的 k 的值是 3
当前读(Current Read),除了 update 语句外,select 语句如果加锁,也是当前读
所以,如果把事务 A 的查询语句 select * from t where id=1 修改一下,加上 lock in share mode 或 for update,也都可以读到版本号是 101 的数据,返回的 k 的值是 3。下面这两个 select 语句,就是分别加了读锁(S 锁,共享锁)和写锁(X 锁,排他锁)
sql
select k from t where id=1 lock in share mode;
select k from t where id=1 for update;
如果将事务 C 修改为下面的情况,事务 C 不是立马提交的,那么事务 B 又是如何处理 update 的?
这就要进一步提到"两阶段锁"。事务 C 没提交,也就是说 (1,2) 这个版本上的写锁还没释放。而事务 B 是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务 C 释放这个锁,才能继续它的当前读

截止到现在,我们已经把一致性读(Consistent Read)和当前读(Current Read)以及行锁串起来了
总结来说就是:
可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。
本篇文章参考了《极客时间 MySQL45讲》和其他资料进行的笔记归纳总结,希望能帮助你更好的理解