MVCC的实现原理
- 隐式字段
- undo日志
- Read View(读视图)
- 整体流程
- 例子
MVCC的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 3个隐式字段,undo日志 ,Read View 来实现的。所以我们先来看看这个三个point的概念
隐式字段
-
MySQL
中会为每一行记录生成隐藏列,接下来就让我们了解一下这几个隐藏列吧。(1)DB_TRX_ID:事务ID,是根据事务产生时间顺序自动递增的,是独一无二的。如果某个事务执行过程中对该记录执行了增、删、改操作,那么
InnoDB
存储引擎就会记录下该条事务的id。(2)DB_ROLL_PTR:回滚指针,本质上就是一个指向记录对应的
undo log
的一个指针,大小为 7 个字节,InnoDB
便是通过这个指针找到之前版本的数据。该行记录上所有旧版本,在undo log
中都通过链表的形式组织。(3)DB_ROW_ID:行标识(隐藏单调自增
ID
),如果表没有主键,InnoDB 会自动生成一个隐藏主键,大小为 6 字节。如果数据表没有设置主键,会以它产生聚簇索引。(4)实际还有一个删除flag隐藏字段,既记录被更新或删除并不代表真的删除,而是删除flag变了。
如上图,DB_ROW_ID是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID是当前操作该记录的事务ID,而DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本
undo日志
-
每当我们要对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),都需要把回滚时所需的东西记录下来, 比如:
- Insert undo log :插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。
- Delete undo log:删除一条记录时,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。
- Update undo log:修改一条记录时,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
InnoDB
把这些为了回滚而记录的这些东西称之为undo log
。这里需要注意的一点是,由于查询操作(SELECT
)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo log
。
每次对记录进行改动都会记录一条undo日志,每条undo日志也都有一个
DB_ROLL_PTR
属性,可以将这些undo日志都连起来,串成一个链表,形成版本链。版本链的头节点就是当前记录最新的值。不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除
purge
- 从前面的分析可以看出,为了实现InnoDB的MVCC机制,更新或者删除操作都只是设置一下老记录的deleted_bit,并不真正将过时的记录删除。
- 为了节省磁盘空间,InnoDB有专门的purge线程来清理deleted_bit为true的记录。为了不影响MVCC的正常工作,purge线程自己也维护了一个read view(这个read view相当于系统中最老活跃事务的read view);如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。
对MVCC有帮助的实质是update undo log ,undo log实际上就是存在rollback segment中旧记录链,它的执行流程如下:
一、 比如一个有个事务插入persion表插入了一条新记录,记录如下,name为Jerry, age为24岁,隐式主键是1,事务ID和回滚指针,我们假设为NULL
二、 现在来了一个事务1对该记录的name做出了修改,改为Tom
-
在事务1修改该行(记录)数据时,数据库会先对该行加排他锁
-
然后把该行数据拷贝到undo log中,作为旧记录,既在undo log中有当前行的拷贝副本
-
拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务ID为当前事务1的ID, 我们默认从1开始,之后递增,回滚指针指向拷贝到undo log的副本记录,既表示我的上一个版本就是它
-
事务提交后,释放锁
三、 又来了个事务2修改person表的同一个记录,将age修改为30岁
-
在事务2修改该行数据时,数据库也先为该行加锁
-
然后把该行数据拷贝到undo log中,作为旧记录,发现该行记录已经有undo log了,那么最新的旧数据作为链表的表头,插在该行记录的undo log最前面
-
修改该行age为30岁,并且修改隐藏字段的事务ID为当前事务2的ID, 那就是2,回滚指针指向刚刚拷贝到undo log的副本记录
-
事务提交,释放锁
从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,既链表,undo log的链首就是最新的旧记录,链尾就是最早的旧记录(当然就像之前说的该undo log的节点可能是会purge线程清除掉,向图中的第一条insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)
Read View(读视图)
在可重复读隔离级别下,我们可以把每一次普通的select
查询(不加for update
语句)当作一次快照读,而快照便是进行select
的那一刻,生成的当前数据库系统中所有未提交的事务id数组(数组里最小的id
为min_id
)和已经创建的最大事务id
(max_id
)的集合,即我们所说的一致性视图readview
。在进行快照读的过程中要根据一定的规则将版本链中每个版本的事务id
与readview
进行匹配查询我们需要的结果。
快照读是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。快照读的实现是基于多版本并发控制,即MVCC
,可以认为MVCC
是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。MVCC
只在 READ COMMITTED
和 REPEATABLE READ
两个隔离级别下工作,其他两个隔离级别不和MVCC
不兼容。因为READ UNCOMMITTED
总是读取最新的数据行,而不是符合当前事务版本的数据行,而SERIALIZABLE
则会对所有读取的行都加锁。事务的快照时间点(即下文中说到的Read View
的生成时间)是以第一个select
来确认的。所以即便事务先开始,但是select
在后面的事务的update
之类的语句后进行,那么它是可以获取前面的事务的对应的数据。
RC和RR隔离级别下的快照读和当前读:RC隔离级别下,快照读和当前读结果一样,都是读取已提交的最新;RR隔离级别下,当前读结果是其他事务已经提交的最新结果,快照读是读当前事务之前读到的结果。RR下创建快照读的时机决定了读到的版本。
对于使用RC和RR隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的。核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。为此,InnoDB
提出了一个Read View
的概念。
Read View
就是事务进行快照读(普通select
查询)操作的时候生产的一致性读视图,在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,它由执行查询时所有未提交的事务id数组(数组里最小的id为min_id
)和已经创建的最大事务id(max_id
)组成,查询的数据结果需要跟read view
做对比从而得到快照结果。
版本链比对规则:
- 如果落在绿色部分(trx_id<min_id),表示这个版本是已经提交的事务生成的,这个数据是可见的;
- 如果落在红色部分(trx_id>max_id),表示这个版本是由将来启动的事务生成的,是肯定不可见的;
- 如果落在黄色部分(min_id<=trx_id<=max_id),那就包含两种情况:
a.若row的trx_id在数组中,表示这个版本是由还没提交的事务生成的,不可见;如果是自己的事务,则是可见的;
b.若row的trx_id不在数组中,表示这个版本是已经提交了的事务生成的,可见。
那么这个判断条件是什么呢?
/** Check whether the changes by id are visible.
@param[in] id transaction id to check against the view
@param[in] name table name
@return whether the view sees the modifications of id. */
bool changes_visible(
trx_id_t id, //当前事务id
const table_name_t& name) const
MY_ATTRIBUTE((warn_unused_result))
{
ut_ad(id > 0);
if (id < m_up_limit_id || id == m_creator_trx_id) {
return(true);
}
check_trx_id_sanity(id, name);
if (id >= m_low_limit_id) {
return(false);
} else if (m_ids.empty()) {
return(true);
}
const ids_t::value_type* p = m_ids.data();
return(!std::binary_search(p, p + m_ids.size(), id));
}
如上,它是一段MySQL判断可见性的一段源码,即changes_visible方法,该方法展示了我们拿DB_TRX_ID去跟Read View某些属性进行怎么样的比较
在展示之前,我先简化一下Read View,我们可以把Read View简单的理解成有三个全局属性
m_ids(trx_list)
一个数值列表,用来维护Read View生成时刻系统正活跃的事务ID
up_limit_id
记录trx_list列表中事务ID最小的ID
low_limit_id
ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
- 首先比较DB_TRX_ID < up_limit_id, 如果小于,则当前事务能看到DB_TRX_ID 所在的记录,如果大于等于进入下一个判断
- 接下来判断 DB_TRX_ID 大于等于 low_limit_id , 如果大于等于则代表DB_TRX_ID 所在的记录在Read View生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断
- 判断m_ids.empty()如果空则说明,你这个事务在Read View生成之前就已经Commit了,你修改的结果,我当前事务是能看见的
整体流程
我们在了解了隐式字段,undo log, 以及Read View的概念之后,就可以来看看MVCC实现的整体流程是怎么样了
整体的流程是怎么样的呢?我们可以模拟一下
-
当事务2对某行数据执行了快照读,数据库为该行数据生成一个Read View读视图,假设当前事务ID为2,此时还有事务1和事务3在活跃中,事务4在事务2快照读前一刻提交更新了,所以Read View记录了系统当前活跃事务1,3的ID,维护在一个列表上,假设我们称为trx_list
-
Read View不仅仅会通过一个列表trx_list来维护事务2执行快照读那刻系统正活跃的事务ID,还会有两个属性up_limit_id(记录trx_list列表中事务ID最小的ID),low_limit_id(记录trx_list列表中事务ID最大的ID,也有人说快照读那刻系统尚未分配的下一个事务ID也就是目前已出现过的事务ID的最大值+1,我更倾向于后者;所以在这里例子中up_limit_id就是1,low_limit_id就是4 + 1 = 5,trx_list集合的值是1,3,Read View如下图
-
我们的例子中,只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务,所以当前该行当前数据的undo log如下图所示;我们的事务2在快照读该行记录的时候,就会拿该行记录的DB_TRX_ID去跟up_limit_id,low_limit_id和活跃事务ID列表(trx_list)进行比较,判断当前事务2能看到该记录的版本是哪个。
-
所以先拿该记录DB_TRX_ID字段记录的事务ID 4去跟Read View的的up_limit_id比较,看4是否小于up_limit_id(1),所以不符合条件,继续判断 4 是否大于等于 low_limit_id(5),也不符合条件,最后判断4是否处于trx_list中的活跃事务, 最后发现事务ID为4的事务不在当前活跃事务列表中, 符合可见性条件,所以事务4修改后提交的最新结果对事务2快照读时是可见的,所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本
-
也正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同
例子
首先我们要准备两张表,一张test
和一张account
表,然后我们以account
的undo log
来画版本链,准备数据和原始记录图如下
//test表中数据
id=1,c1='11';
id=5,c1='22';
//account表数据
id=1,name=‘lilei’;
如下图,我们将按照里面的顺序执行sql
当我们执行到第7行的select
的语句时,会生成readview[100,200],300
,版本链如图所示:
此时我们查询到的数据为lilei300
。我们首先要拿最新版本的数据trx_id=300
来readview
中匹配,落在黄色区间内,一看该数据已经提交了,所以是可见的。继续往下执行,当执行到第10行的select
语句时,因为trx_id=100
并未提交,所以版本链依然为readview[100,200],300
,版本链如图所示:
此时我们查询到的数据为lilei300
。我们按上边操作,从最新版本依次往下匹配,我们首先要拿最新版本的数据trx_id=100
来readview
中匹配,落在黄色区间内,一看该数据在未提交的数组中,且不是自己的事务,所以是不可见的;然后我们选择前一个版本的数据,结果同上;继续向上找,当找到trx_id=300
的数据时,会落在黄色区间,且是提交的,所以数据可见。继续往下执行,当执行到第13行的select
语句时,此时尽管trx_id=100
已经提交了,因为是InnoDB
的RR模式,所以readview
不会更改,仍为readview[100,200],300
,版本链如图所示:
此时我们查询到的数据为lilei300
。原因同上边的步骤,不再赘述。
当执行
update
语句时,都是先读后写的,而这个读,是当前读,只能读当前的值,跟readview
查找时的快照读区分开。
刚才演示的是InnoDB
下的RR模式,接下来我们简单说一下RC模式,上文中提到的RC模式的数据读都是读最新的即当前读,所以readview是实时生成的,执行语句如图所示:
当我们执行到第13行的select
的语句时,会生成readview[200],300
,版本链还和之前一样,此时我们查询到的数据为lilei2
。原因和上边讲的RR模式下的比对规则相同。
此处我们演示的是update
的情况,对于删除的情况可以认为是update
的特殊情况,会将版本链上最新的数据复制一份,然后将trx_id
改成删除操作的trx_id
,同时在该条记录的头信息(record header
)里的(deleted_flag
)标记位上写上true
,来表示当前记录已经被删除,在查询时按照上边的规则查到对应的记录,如果delete_flag
标记位为true
,意味着记录已被删除,则不返回数据。
大家应该还关心一个问题,即undo log
什么时候删除呢?系统会判断,没有比这个undo log
更早的read view
的时候,undo log
会被删除。所以这里也就是为什么我们建议你尽量不要使用长事务的原因。长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。