MYSQL面试八股文-InnoDB的MVCC实现机制

背景

什么是MVCC?

MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。

特别要注意MVCC只在 读已提交(RC) 和 可重复度(RR) 这两种事务隔离级别下才有效。

是数据库引擎(InnoDB)层面实现的,用来处理读写冲突的手段(不用加锁),提高访问性能。

有锁了为什么需要MVCC?

MySQL中同时使用锁和MVCC(多版本并发控制)是为了实现更高效的并发访问和事务隔离性。

锁是一种常见的并发控制机制,它可以确保事务之间的数据访问互斥,避免数据的读写冲突。当一个事务对数据进行修改时,会获取相应的锁,其他事务在这个锁未释放之前无法访问或修改该数据。

然而,使用锁机制存在一些限制和性能问题。锁会导致并发度下降,因为多个事务在并发执行时可能需要等待锁的释放。此外,锁的粒度较大时,可能会导致锁冲突的频率增加,从而影响系统的性能。

为了解决锁带来的性能问题,MySQL引入了MVCC。MVCC基于数据版本控制,通过保存不同版本的数据来实现事务隔离和并发访问。

当事务需要读取数据时,MVCC允许它读取到自己启动之前的数据版本,而不会受到其他事务正在修改的影响。这避免了锁的使用,提高了并发性能和系统吞吐量。

同时,MVCC还可以提供更高的事务隔离级别,如可重复读。在MVCC中,读取操作不会被阻塞,也不会阻塞其他事务的写入操作,因为每个事务都可以读取到自己的数据版本。

因此,MySQL中同时使用锁和MVCC的目的是在保证并发性和事务隔离性的同时,提高系统的性能和并发处理能力。锁用于一些特定的操作,而MVCC适用于大部分的读操作,二者结合使用可以达到更好的效果。

什么场景和条件下使用MVCC?

MVCC只在 读已提交(RC) 和 可重复度(RR) 这两种事务隔离级别下才有效。

高并发读取:当系统需要支持大量并发读取操作时,MVCC可以提供更好的性能。由于每个事务可以读取自己的数据版本,读取操作不会相互阻塞,从而提高了并发性能。

长事务支持:如果系统中存在需要长时间运行的事务,使用MVCC可以避免锁的使用,减少对其他事务的阻塞。长事务在MVCC下仅需保持对自己的数据版本的访问,而不会影响其他事务的读写操作。

乐观并发控制:MVCC属于一种乐观并发控制方式,它假设事务之间的冲突较少发生。当冲突发生时,事务会检测到冲突并进行回滚,从而保证数据的一致性。如果系统中的并发冲突相对较少,MVCC可以有效减少锁冲突和阻塞的情况,提高系统性能。

高事务隔离级别要求:MVCC可以提供更高的事务隔离级别,如可重复读。在这种隔离级别下,每个事务都可以读取到自己启动之前的一致数据视图,避免了脏读、不可重复读和幻读等问题。

使用MVCC能带来什么好处?

多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。

所以MVCC可以为数据库解决以下问题在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失

什么是当前读和快照读?

当前读

像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读。

为什么叫当前读?

就是因为它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

快照读

不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。

简单来说MVCC就是为了实现读-写冲突不加锁,而这个读指的就是快照读, 而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现

当前读,快照读和MVCC的关系

准确的说,MVCC多版本并发控制指的是 “维持一个数据的多个版本,使得读写操作没有冲突” 这么一个概念。
仅仅是一个理想概念而在MySQL中,实现这么一个MVCC理想概念,我们就需要MySQL提供具体的功能去实现它,而快照读就是MySQL为我们实现MVCC理想模型的其中一个具体非阻塞读功能。
而相对而言,当前读就是悲观锁的具体功能实现要说的再细致一些,快照读本身也是一个抽象概念,再深入研究。
MVCC模型在MySQL中的具体实现则是由 4个隐式字段,版本链undo日志 ,读视图Read View 等去完成的,具体可以看下面的MVCC实现原理。

小小结

MVCC的出发点是不局限于只让数据库采用悲观锁这样性能不佳的形式去解决读-写冲突问题,而提出的解决方案,所以在数据库中,因为有了MVCC,所以我们可以形成两个组合:
MVCC + 悲观锁 MVCC解决读写冲突,悲观锁解决写写冲突
MVCC + 乐观锁 MVCC解决读写冲突,乐观锁解决写写冲突
这种组合的方式就可以最大程度的提高数据库并发性能,并解决读写冲突,和写写冲突导致的问题。

MVCC实现原理

MVCC的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 4个隐式字段,版本链 undo日志,读视图Read View 来实现的。

隐式字段

每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段。

DB_ROW_ID 6byte, 隐含的自增ID(隐藏主键):如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引; 当有主键或者有不允许为null的unique键时,不包含此字段;
DB_TRX_ID 6byte, 最近修改(修改/插入)事务ID:存储修改此数据的事务id,只有这个事务操作了某些表的数据后当更改操作发生的时候(update,delete,insert),才会分配唯一的事务id,并且此事务id是递增的;
IDDB_ROLL_PTR 7byte, 回滚指针:指向这条记录的上一个版本(存储于rollback segment里);
DELETED_BIT 1byte, 删除标记位:记录被更新或删除并不代表真的删除,而是删除flag变了;

隐式字段初始图

隐式字段初始图

如上图,DB_ROW_ID是数据库默认为该行记录生成的唯一隐式主键(可能会没有);DB_TRX_ID是当前操作该记录的事务ID; 而DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本;delete flag没有展示出来。

undo log(版本链)

版本链是一条链表,链接的是每条数据曾经(从新到旧)的修改日志。

这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo log。

undo log主要分为3种:

Insert undo log :插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。
Update undo log:修改一条记录时,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
Delete undo log:删除一条记录时,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。
删除操作都只是设置一下老记录的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中旧记录链,它的执行流程如下:
1. 例如预先在user_info表插入了一条新记录,记录如下,name为“张三”, gender为“1”,主键是“1”,事务ID和回滚指针都是为NULL。
2. 现在来了一个事务1对该记录的name做出了修改,改为“张麻子”。

  • 在事务“1”修改该行(记录)数据时,数据库会先对该行加排他锁
  • 然后把该行数据拷贝到undo log中,作为旧记录,即在undo log中有当前行的拷贝副本
  • 拷贝完毕后,修改该行name为“张麻子”,并且修改隐藏字段的事务ID为当前事务1的ID, 我们默认从1开始,之后递增,回滚指针指向拷贝到undo log的副本记录,即表示我的上一个版本就是它
  • 事务提交后,释放锁

3. 然后又来了个事务2修改user_info表的同一个记录,将gender修改为“0”

  • 在事务2修改该行数据时,数据库也先为该行加锁
  • 然后把该行数据拷贝到undo log中,作为旧记录,发现该行记录已经有undo log了,那么最新的旧数据作为链表的表头,插在该行记录的undo log最前面
  • 修改该行gender为“0”,并且修改隐藏字段的事务ID为当前事务2的ID, 那就是2,回滚指针指向刚刚拷贝到undo log的副本记录
  • 事务提交,释放锁

从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,即链表,undo log的链首就是最新的旧记录,链尾就是最早的旧记录(当然就像之前说的该undo log的节点可能是会purge线程清除掉,向图中的第一条insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里

版本链示意图

版本链示意图

Read View(读视图)

Read View就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)

所以我们知道 Read View主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,即可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。

Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出Undo Log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本。

相关源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/** 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. */

[[nodiscard]] bool changes_visible(trx_id_t id,
const table_name_t &name) const {
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));
}

/** Check whether transaction id is valid.
@param[in] id transaction id to check
@param[in] name table name */

void ReadView::check_trx_id_sanity(trx_id_t id, const table_name_t &name) {
if (&name == &dict_sys->dynamic_metadata->name) {
/* The table mysql.innodb_dynamic_metadata uses a
constant DB_TRX_ID=~0. */

ut_ad(id == (1ULL << 48) - 1); return; } if (id >= trx_sys_get_next_trx_id_or_no()) {
ib::warn(ER_IB_MSG_1196)
<< "A transaction id"
<< " in a record of table " << name << " is newer than the"
<< " system-wide maximum.";
ut_d(ut_error);
#ifndef UNIV_DEBUG
THD *thd = current_thd;
if (thd != nullptr) {
char table_name[MAX_FULL_NAME_LEN + 1];

innobase_format_name(table_name, sizeof(table_name), name.m_name);

push_warning_printf(thd, Sql_condition::SL_WARNING, ER_SIGNAL_WARN,
"InnoDB: Transaction id"
" in a record of table"
" %s is newer than system-wide"
" maximum.",
table_name);
}
#endif
}
}

需要判断版本链中的哪个版本是是当前事务可见的,因此有了一致性视图的概念。其中有四个属性比较重要:

  • m_ids: 在生成ReadView时,当前活跃的读写事务的事务id列表
  • m_up_limit_id: m_ids的最小值
  • m_low_limit_id: m_ids的最大值+1
  • m_creator_trx_id: 生成该事务的事务id,单纯开启事务是没有事务id的,默认为0,creator_trx_id是0

版本链中的当前版本是否可以被当前事务可见的要根据这四个属性按照以下几种情况来判断

  • 当 id = m_creator_trx_id 时:当前事务可以看见自己所修改的数据,可见
  • 当 id < m_up_limit_id 时 : 生成此数据的事务已经在生成readView前提交了, 可见
  • 当 id >= m_low_limit_id 时 :表明生成该数据的事务是在生成ReadView后才开启的, 不可见
  • 当 m_up_limit_id <= id < m_low_limit_id 时
    • id 在 m_ids 列表里面 :生成ReadView时,活跃事务还未提交,不可见
    • id 不在 m_ids 列表里面 :事务在生成readView前已经提交了,可见

如果某个版本数据对当前事务不可见,那么则要顺着版本链继续向前寻找下个版本,继续这样判断,以此类推。

注:RR和RC生成一致性视图的时机不一样 (这也是两种隔离级别实现的主要区别)

  • 读提交(read committed RC) 是在每一次select的时候生成ReadView的
  • 可重复读(repeatable read RR)是在第一次select的时候生成ReadView的

整体流程

我们在了解了隐式字段,undo log, 以及Read View的概念之后,就可以来看看MVCC实现的整体流程是怎么样了

整体的流程是怎么样的呢?我们可以模拟一下

当事务2对某行数据执行了快照读,数据库为该行数据生成一个Read View读视图,假设当前事务ID为2,此时还有事务1和事务3在活跃中,事务4在事务2快照读前一刻提交更新了,所以Read View记录了系统当前活跃事务1,3的ID,维护在一个列表上,假设我们称为m_ids

事务1 事务2 事务3 事务4
事务开始 事务开始 事务开始 事务开始
修改且已提交
进行中 快照读 进行中

Read View不仅仅会通过一个列表m_ids来维护事务2执行快照读那刻系统正活跃的事务ID,还会有两个属性m_up_limit_id(记录m_ids列表中事务ID最小的ID),m_low_limit_id(记录m_ids列表中下一个事务ID,也就是目前已出现过的事务ID的最大值+1);所以在这里例子中m_up_limit_id就是1,m_low_limit_id就是4 + 1 = 5,m_ids集合的值是1,3,Read View如下图

我们的例子中,只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务,所以当前该行当前数据的undo log如下图所示;
我们的事务2在快照读该行记录的时候,就会拿该行记录的DB_TRX_ID去跟m_up_limit_id,m_low_limit_id和活跃事务ID列表(m_ids)进行比较,判断当前事务2能看到该记录的版本是哪个。

所以先拿该记录DB_TRX_ID字段记录的事务ID 4去跟Read View的的m_up_limit_id比较,看4是否小于m_up_limit_id(1),所以不符合条件;
继续判断 4 是否大于等于 m_low_limit_id(5),也不符合条件;
最后判断4是否处于m_ids中的活跃事务, 最后发现事务ID为4的事务不在当前活跃事务列表中, 符合可见性条件;
所以事务4修改后提交的最新结果对事务2快照读时是可见的,所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本。

也正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同

MVCC相关问题

可重复读(RR)是如何在读已提交(RC)级的基础上解决不可重复读的?

说人话:可重复读的快照是在第一次快照读的时候生成,整个事务周期内都是读的这个快照不会变,也就解决了数据变化问题(不可重复读的问题)。

可重复读(RR),读已提交(RC)级别下的InnoDB快照读有什么不同?

在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View并记录下来,此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。

而早于Read View创建的事务所做的修改均是可见而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。

总结

1. MVCC是为了在高并发下尽量减少锁开销的一个多版本并发控制机制,仅在可重复读(RR),读已提交(RC)级别下有效。
2. 所有在过程中涉及加锁的读取都是当前读,不加锁的select读取到的内容为快照读。
3. MVCC通过四个隐式字段(DB_ROW_ID:隐藏的自增ID,有主键或唯一索引的时候没有这个隐式字段;DB_TRX_ID:最后执行的事务的ID,自增;IDDB_ROLL_PTR:上次修改的事务记录指针,通过这个指针形成修改回退聊;DELETED_BIT:标记删除而不是真的删除)和unlog(IDDB_ROLL_PTR形成的链)以及ReadView(快照视图)实现。
4. 可重复读(RR)下读视图是在第一次读的时候就生成并且贯穿整个事务;读已提交(RC)下读视图通过ID的大小和范围判断读取undo log中的符合条件的数据来进行读取,可以做到其他事务已经提交的内容能被当前事务读取到。

程序猿老龚(龚杰洪)原创,版权所有,转载请注明出处.

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注