引言
此篇文章会逐步剖析ReadView,深入探究当前读与一致性读的区别。我们将借助查询语句和更新语句来逐步剖析MySQL开展事务多版本并发控制的方式。我们从一个问题开启讨论:begin/start transaction
命令是否就意味着事务开启呢?换句话说,执行该命令时会生成ReadView吗?
正文
首先得说明的是,begin/start transaction
命令并非事务的起始点,只有在执行完它们之后的第一个针对InnoDB表的语句时,事务才真正启动。若要立刻启动一个事务,需使用start transaction with consistent snapshot
命令。这里所说的视图均为一致性视图:consistent read view
,并非View视图虚拟表。
begin/start transaction
对应的一致性视图是在执行第一个快照读语句时创建的。start transaction with consistent 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_pointer
字段。后面虚线连接的三个表格是undo log中的内容。此外,V3、V2、V1版本在数据库中并非物理真实存在,而是每次需要该版本数据时,依据roll_pointer
指针计算得出。例如,V3版本的数据是通过V4依次执行U3和U2命令计算得到的。
所以,事务启动时,只需知晓当前事务ID就能明确哪些数据可读取。以当前事务启动时间为准,若一个数据版本在当前事务之前生成,则认为可读;若在当前事务之后生成,则不能读该版本,需沿roll_pointer
指针找到之前的版本。
接着理解一致性视图ReadView。每次生成ReadView时,都会记录下图中的四个值。其中活跃的事务ID列表指已启动但未提交的事务:

数据版本的可见性规则基于trx_id
和ReadView的对比结果完成,具体流程如下:
1. 若trx_id
是当前事务,则可访问。
2. 若事务ID是活跃ID之前已提交的事务,则可访问。
3. 若事务ID是当前事务之后才开启的事务,则不可访问。
4. 若事务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中的内容进行判断(前文讲过的逻辑):
1. 102是否为create_trx_id
?不是,继续判断。
2. 102是否小于101?不是,继续判断。
3. 102是否大于104?不是,继续判断。
4. 102是否在m_ids中?不在,继续判断,只剩最后一种可能。
5. 102不在m_ids中且事务已提交==>可访问。
当事务104执行第二个select name from person where id = 1;
时,根据不同隔离级别有不同处理方式:
1. RC级别,此时会生成一个新的ReadView。那么此次生成的ReadView与第一次不同(比如这次生成的ReadView中,m_ids只有101),整个查询结果可能不同,此处不再演示。
2. 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锁,排他锁):
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);事务更新数据时,只能用当前读。若当前记录的行锁被其他事务占用,则需进入锁等待。
那为何表结构不支持“可重复读”呢?因为表结构无对应行数据,也无row trx_id
,所以只能遵循当前读逻辑。不过,MySQL 8.0已能将表结构置于InnoDB字典中,未来或许会支持表结构的可重复读。