想要详细了解事务的隔离级别及其底层实现,绕不开多版本并发控制MVCC(Multi-Version Concurrency Control),本文以InnoDB为例,剖析了MVCC在更新-读取数据过程中,存储引擎底层的实现过程及数据结构变化。
本文主要为笔者自己的学习总结和理解,希望能帮助大家降低该部分知识的学习难度,让更多同学了解MVCC的底层实现机制,如有偏误,还请指正,笔者感激不尽。本文主要参考InnoDB MVCC detailed,极客时间:MySQL实战45讲 两篇文章,如果想深入理解这部分内容,强烈推荐大家读下原文,有时间也可以读下InnoDB源码。
可重复读是什么
在可重复读隔离级别下,一个事务启动后,在不同时刻读取到的数据总是一致,即便有其他事务在该事务启动后,对数据进行了修改,对该事务也完全无感知。不过除两种情况。
-
如果事务要更新某一数据,需要获取该数据的最新值,此时隔离级别会临时降为读提交,即其他事务已经更新并提交的内容,对当前事务可见。如:
select * from t where k = 1 for update -
在事务执行过程中,自己修改的数据,再次读取为最新的数据 举个栗子🌰
CREATE TABLE `tmp`(
`id` int(11) NOT NULL,
`k1` int(11) DEFAULT NULL,
`v1` int(11) DEFAULT NULL,
PRIMARY KEY(`id`),
KEY `idx_k1` (`k1`)
)ENGINE = InnoDB;
INSERT INTO tmp(id,k1,v1) VALUES(1,1,1),(2,2,2),(3,3,3);
实例测试
| 事务一(事务ID=100) | 事务二 (事务ID=200) |
|---|---|
start transaction with consistent snapshot; | |
start transaction with consistent snapshot; | |
select * from tmp where id = 1; | |
update tmp set v1 = 11 where id = 1;commit; | |
select * from tmp where id = 1; | select * from tmp where id = 1; |
select * from tmp where id = 1 for update; |
可以看到事务一首先启动事务,之后无论事务二如何修改数据行(id=1),对于事务一均不可见。但是当事务一要更新该行数据前,执行select * from tmp where id = 1 for update;,此时事务隔离级别会临时降级为读提交,可以看到改行数据最新修改的数据内容
可重复读如何实现
在MySQL中,可重复读依赖于InnoDB底层实现的MVCC。当事务启动瞬间,InnDB底层会创建一个该事务的可见性视图,可以理解为对当前该时刻的数据库快照,之后该事务的所有读操作都是基于这个瞬间的快照做查询
MVCC的核心是在修改数据时,除修改数据以外,还会记录下历史变更记录(redolog),且redolog中每条记录都有一个版本号,即写入事务的id。依据redolog可以回溯找到历史版本数据。
当查询数据时,存储引擎根据数据的版本号和查询事务的可见性视图版本对比。如果数据在事务启动后写入提交,则对当前事务不可见,需要回溯到可见性视图指定版本
MVCC机制详细剖析-聚集索引
InnoDB底层索引包含两种类型,聚集索引和二级索引。聚集索引中包含完整的行数据,二级索引上仅包含索引字段。在进行检索时,如果InnoDB选择使用二级索引,则可能需要回表到聚集索引上取完整数据。关于索引选择机制,相关的文章有很多,不在此赘述。
下面我们首先剖析下为了实现MVCC机制,InnoDB在聚集索引上做了什么。包括在写入和更新事务时,底层数据结构如何变更;可见性视图是什么,如何保存数据快照,在读取时,数据的可见性如何判断。
图 1 InnoDB聚集索引结构
首先,我们看下InnoDB聚集索引的底层结构。InnoDB 每一行的完整数据存在于聚集索引(基于主键ID构造的B+ Tree),其叶子节点是完整的行数据。不过,如图1所示,每一行除了存储必要的数据外,还有额外的三个字段:
- DB_TRX_ID:最后一次更新数据行的事务ID
- DB_ROLL_PTR: 一个指向UNDO_LOG的指针,UNDO_LOG中存储了该行数据的变更记录,可依据UNDO_LOG追溯到历史版本
- DB_ROW_ID: 隐藏行ID,如果未指定索引主键,会使用该字段作为隐藏主键(与MVCC机制关系不大,可先忽略) 更新数据时发生了什么
-- (TRX_ID = 200)
update tmp set v1 = 11 where id = 1;
假设事务二的事务ID=200,在事务一后启动,当事务二执行上述语句时,发生了什么?
- 根据主键ID,按聚集索引找到id = 1 的叶子节点
- 将v1字段修改为11,并将DB_TRX_ID更新为 200
- 依据DB_ROLL_PTR找到UNDO_LOG, 在UNDO_LOG 中记录上一个版本的数据及版本号
可见性视图是什么,如何判断一个数据版本的可见性
每个事务启动瞬间,会向系统申请一个事务ID,这个事务ID按照申请先后顺序是递增的。
数据的版本号即为更新该数据的事务ID
当一个事务启动瞬间,系统会保存下该事务启动瞬间系统所有的活跃事务ID(启动但未提交),称为视图数组。基于该视图数组与当前事务ID可以构建当前事务的可见性视图。
读取数据时,我们通过判断当前事务ID(read_trx_id),当前事务视图数组V,数据版本号ID(data_trx_id)三者关系,判断该数据是否在事务启动前已提交。其中视图数组中事务ID的最大值,我们记录为m_up_limit_id,同理,最小值记录为m_low_limit_id,如果判定为不可见,则需要回滚至上一版本。
评判准则如下:
-
如果data_trx_id = read_trx_id,说明数据是当前读事务更新的,可见
-
如果data_trx_id < m_low_limit_id,说明数据是在事务启动前提交,可见
-
如果 m_low_limit_id <= data_trx_id <= m_up_limit_id, 此时,需要看data_trx_id是否存在于视图数组V中。如果V中包含该data_trx_id,说明事务启动瞬间,更新该数据的事务仍然活跃(未提交),当前数据版本是在事务启动后提交的,不可见。如果V中不包含data_trx_id,说明事务启动前该事务已提交,数据可见。
-
如果data_trx_id > m_up_limit_id, 说明更新该数据的事务是在当前读事务后启动,数据不可见。
读取数据时发生了什么
-- (TRX_ID = 100)
select * from tmp where id = 1;
假设事务一的事务ID=100,当事务一执行如下语句时,发生了什么?
- 根据主键ID,按聚集索引找到id = 1 的叶子节点
- 检查发现DB_TRX_ID=200,当前事务ID=100,视图数组为[100], 发现为data_trx_id > m_up_limit_id,数据不可见
- 遍历UNDO_LOG 找到在事务一启动前写入的数据版本,为可见数据版本,作为查询结果返回
隔离级别什么时候降级为读提交
在执行更新读时,事务必须拿到当前行的最新数据,此时隔离级别会临时降为读提交。 比如当事务一执行如下语句时,所查到的结果为事务二更新后的数据。
--(TRX_ID = id1)
select * from tmp where id = 1 for update;
此时查询过程是什么样的?
- 根据主键ID,按聚集索引找到其叶子节点
- 此时事务需要更新数据,加锁后直接返回最新的数据版本
MVCC机制详细剖析-二级索引
刚刚讨论了为保证一致性读,聚集索引底层的实现机制。
我们知道在查询时如果优化器选择的是二级索引,此时可能的查询过程是先到二级索引上执行查询,找到符合条件的主键ID后,再回表到主键索引上拿到所有的数据。
这样有两个疑问:
- 二级索引上并不像聚集索引那样会记录数据的版本号以及指向回滚日志的指针,在读取数据时如何判断其该版本数据是否可见?
- 如果搜索键值发生变化,原来的搜索键是直接删除了吗?如果想读取老版本的数据,如何保证老版本的搜索键有效呢?
为解决上述疑问,我们先看一个测试实例,然后逐步剖析二级索引上数据结构变化
实例测试
首先还是通过两个事务来测试下,这里我们更新 k1值,然后按照老版本k1值进行检索
| 事务一(TRX_ID = 100) | 事务二(TRX_ID = 101) |
|---|---|
start transaction with consistent snapshot; | |
start transaction with consistent snapshot; | |
select * from tmp where k1 = 1; | |
update tmp set k1 = 11 where k1 = 1; | |
select * from tmp where k1 = 1;select * from tmp where k1 = 1 for update; | select * from tmp; |
可以看到在事务一执行查询时 select * from tmp where k1 =1 时,尽管事务二已经将k1值更新为11,但是此时仍然可以查到结果。而使用写-读查询查到的结果则为空。
那么二级索引在k1 的值更新为11时,如何保证索引上仍然可以检索到k1=1呢?其实二级索引在更新k1=11时,只是插入了一条数据,原k1=1 仍然存在,在物理结构上没有真正删除。
二级索引结构
首先,我们分析下二级索引的数据结构。
图 2 InnoDB 二级索引结构
可以看到,在二级索引中包含索引键外,还包含如下信息:
-
主键ID:回表到聚集索引时使用,找到关联行的其他数据
-
删除位标识:软删除标识,仅占1bit。当更新键值时,仅仅将老键标识为删除,然后插入一个新键,后续会有专门的purge进程清理
此外在叶子节点所在的数据页,还会记录一个页面最大更新事务ID(page_update_max_trx_id)。当某事物更新或插入该数据页的数据时,会将该字段设置为该事务的ID
详细剖析
首先我们插入一行新数据,插入数据后二级索引结构如图3所示,此时k1=10,跟id=10相关的二级索引键只有一行,页面最大更新事务ID(page_update_max_trx_id)= 100
执行语句
事务一(trx_id =100)
视图数组:[100]
m_up_limit_id:100
m_low_limit_id:100
图 3 二级索引结构-插入新数据
更新数据
假设事务二(trx_id =101)执行如下语句,将k1重新设置为100。 如图4所示,此时只是将k1=10的索引键标识为删除,再插入一行新的k1=100的索引键,此时跟id=10相关的二级索引键有两行,页面最大更新事务ID(page_update_max_trx_id)= 101
事务二(启动前,事务一已提交,trx_id =101)
视图数组:[101]
m_up_limit_id:101
m_low_limit_id:101
图 4 二级索引结构-更新数据
查询数据(情况一)
(trx_id =102)
视图数组:[101,102]
m_up_limit_id:102
m_low_limit_id:101
假设事务三(trx_id =102)启动时,事务一已提交,事务二未提交,事务三在执行上述查询时,具体查询流程如下:
- 根据查询条件,选择使用二级索引,检索找到二级索引上叶子k1=10的叶子节点;
- 检查该页面的page_max_trx_id=101,发现page_max_trx_id>m_low_limit_id 并且 page_max_trx_id 在 视图数组中,说明该页面数据在事务启动后有过修改,无法确定其可见性,需要回表。
- 在主键索引中做可见性检查,返回(10,10,10)发现与k1值与二次索引一致,证明二次索引数据有效,作为结果返回
事务三在执行k1=100查询时,查询流程如下
查找流程:
- 根据查询条件,检索B+数,找到叶子k1=100的叶子节点;
- 检查该页面的page_max_trx_id=101,发现page_max_trx_id>m_low_limit_id 并且 page_max_trx_id 在 视图数组中,说明该页面数据在事务启动后有过修改,无法确定其可见性,需要回表。
- 在主键索引中做可见性检查,返回(10,10,10)发现与k1=100不一致,判断结果无效,跳过该索引行
查询数据(情况二)
(trx_id =200)
视图数组:[200]
m_up_limit_id:200
m_low_limit_id:200
假设事务四(trx_id =200)启动时,事务一、事务二均已提交,事务四在执行上述查询时,具体查询流程如下:
- 根据查询条件,检索B+数,找到叶子k1=10的叶子节点;
- 检查该页面的page_max_trx_id=101,发现page_max_trx_id<m_low_limit_id 说明该页面全部数据对该事务均可见。
- 找到k1=10的记录,但是其delete=1,表明数据已删除,忽略该结果
查找流程:
- 根据查询条件,检索B+数,找到叶子k1=100的叶子节点;
- 检查该页面的page_max_trx_id=101,发现page_max_trx_id<m_low_limit_id 说明该页面数据在事务启动后没有修改,该页面全部数据对该事务均可见。
- 找到k1=100的记录,且其delete=0,表明数据有效,返回该结果 标记为删除的行怎么处理
在二级索引中,被标识为删除的行,后续会有专门的清理进程负责回收,回收时会判断是否还有未提交的事务需要使用该删除行,判定无用后则删除,与redo log回收机制类似。