1.MVCC是什么?
MVCC全称Multi-Version Concurrency Control,即多版本并发控制。它通过维护数据的多个版本来实现高效的并发控制,用于在多个并发事务同时读写数据库时保持数据的一致性和隔离性。
在搞清楚MVCC的实现原理之前,还需要了解快照读和当前读的概念。
一致性非锁定读(快照读)
简单的select语句(不加锁)就是快照读,读取的是记录数据的可见版本,不加锁,是非阻塞读。
select ...
一致性锁定读(当前读)
读取的是记录的最新版本,读取时需要保证其他并发事务不能修改当前记录,会对读取的记录加锁。如果执行的是下列语句,就是锁定读。
select ... lock in share mode
select ... for update
insert
、update
、delete
操作
2.MVCC实现原理
MVCC
的实现依赖于:隐藏字段、Read View、undo log。
隐藏字段
在内部,InnoDB
存储引擎为每行数据添加了三个隐藏字段
隐藏字段 | 含义 |
---|---|
DB_ROW_ID(6字节) | 隐藏主键,如果当前表不存在主键,则将该隐藏字段作为主键 |
DB_TRX_ID(6字节) | 最近修改事务ID,记录插入这条数据或最后一次修改该记录的事务ID |
DB_ROLL_PTR(7字节) | 回滚指针,指向这条记录的上一个版本,用于配合undo log |
假设有一个学生表,该表没有指定主键。
那么该表实际上的字段如下,如果存在主键则不存在DB_ROW_ID
字段。
undo log
undo log
分为两种类型:insert undo log
和 update undo log
。
Insert undo log
是在事务进行插入操作时生成的日志。其主要作用是用于事务回滚时撤销插入操作。该日志只在回滚时需要,在事务提交后,可被立即删除。Update undo log
是在事务进行更新或删除操作时生成的日志。其主要作用是用于事务回滚时撤销更新和删除操作。该日志不仅在回滚时需要,在快照读时也需要,不会被立即删除。
一条记录的每一次更新操作产生的 undo log 格式都有一个一个 DB_TRX_ID事务id 和 DB_ROLL_PTR 指针:
- 通过 DB_TRX_ID 可以知道该记录是被哪个事务修改的;
- 通过 DB_ROLL_PTR 指针可以将这些 undo log 串成一个链表,这个链表就被称为版本链;
举例说明:
事务1已经提前执行了INSERT INTO user (id, age, name) VALUES (10, 10, 'Jack');
语句插入了一条记录。则DB_TRX_ID
(插入这条数据或最后一次修改该记录的事务ID)为1,由于insert undo log
在事务提交后自动删除,所以不存在undo log日志,DB_ROLL_PTR
为null。
该示例中有四个并发事务,其他事务在不同的时刻将执行update语句修改记录。
所有事务执行完毕后,当前记录的DB_TRX_ID为4,且形成了一条Update Undo Log版本链,后续MVCC可以利用这条版本链获取旧数据。
Read View
ReadView(读视图)是快照读执行时MVCC获取数据的依据,记录并维护系统尚未提交的事务(也称为活跃事务)id。
ReadView有以下四个重要字段:
字段 | 含义 |
---|---|
m_ids | 当前活跃事务的ID集合 |
min_trx_id | 最小活跃事务ID |
max_trx_id | 预分配事务ID,当前最大事务ID+1(事务ID自增) |
creator_trx_id | ReadView创建者的事务ID |
Tips:
m_ids
的长度可不是max_trx_id - min_trx_id
,因为m_ids是当前活跃事务的ID集合,在min_trx_id
到max_trx_id
即可能有活跃事务,也可能有非活跃事务。
当一个事务需要读取一条记录时,需要遵循以下四条规则进行读取(非常重要):
-
DB_TRX_ID == creator_trx_id
时,说明该数据就是当前事务更改的,可以访问该版本。 -
DB_TRX_ID < min_trx_id
时,比最小活跃事务ID小,说明当前事务已经提交了,可以访问该版本。 -
DB_TRX_ID > max_trx_id
时,比预分配事务ID大,说明当前事务在ReadView生成后才开始,还没有提交不能访问该版本。 -
min_trx_id <= DB_TRX_ID <= max_trx_id
时,如果DB_TRX_ID不在m_ids中,即当前事务已经提交了,可以访问该版本。
看完这些规则我们可以总结以下规律:
- 当前事务可以读取自己更改的记录,对应第一条规则
- 只有一个事务提交了,才能去读取该事务ID下的版本记录(保证事务的隔离性,防止脏读),对应第二、三、四条规则
3.MVCC的执行流程
这里承接第二部分举过的案例,来具体分析事务5在不同隔离级别两次查询id为10的记录时,分别会读取哪个版本的数据。学会这个案例之后,就能理解MVCC如何解决不可重复读和幻读的问题。
RC隔离级别
在RC隔离级别下,事务每一次执行快照读时都会生成一次ReadView。
在第一次查询时,还未提交的事务有3、4、5,那么m_ids(活跃事务ID集合)为{3,4,5},min_trx_id(最小活跃事务ID)为3,max_trx_id(预提交事务ID)为5+1=6,creator_trx_id(事务创建者ID)为5。
在第二次查询时,还未提交的事务有4、5,那么m_ids(活跃事务ID集合)为{4,5},min_trx_id(最小活跃事务ID)为4,max_trx_id(预提交事务ID)为5+1=6,creator_trx_id(事务创建者ID)为5。
RR隔离级别
在RR隔离级别下,仅在事务中第一次执行快照读时生成ReadView,后续复用该ReadView。
在第一次查询时,还未提交的事务有3、4、5,那么m_ids(活跃事务ID集合)为{3,4,5},min_trx_id(最小活跃事务ID)为3,max_trx_id(预提交事务ID)为5+1=6,creator_trx_id(事务创建者ID)为5。
在第二次查询时,直接复用ReadView。
读取版本记录
在读取版本记录时,需要根据DB_TRX_ID匹配ReadView的读取规则,判断当前记录对DB_TRX_ID对应的事务是否可见,如果可见,直接读取当前版本,如果不可见,则读取前一个undo log记录继续进行匹配。
我们以第一个ReadView举例,当前undo log版本链和读视图如下:
当DB_TRX_ID为4,存在于活跃事务列表中,因此不可以读取该行数据,需要向前找DB_TRX_ID为3的记录。
当DB_TRX_ID为3时,同样存在于活跃事务列表,因此不可以读取该行数据,需要向前找DB_TRX_ID为2的记录。
当DB_TRX_ID为2时,发现DB_TRX_ID<min_trx_id,符合规则,因此可以读取该行记录。
最后的读取结果为:
10 | 20 | Jack | 2 | 0x00001 |
---|
4.MVCC小结
MVCC解决不可重复读
RC隔离级别
在RC读取已提交下,事务每一次执行快照读时都会生成一次ReadView,这也就造成了每次读取就有不同 ReadView,那么就会读到已提交的事务修改的内容,不能解决不可重复读的问题。
RR隔离级别
解决 RR 不可重复读主要靠 Readview,在隔离级别为可重复读时,仅在事务中第一次执行快照读时生成ReadView,后续复用该ReadView。由于后续复用了 ReadView,所以数据对当前事务的可见性和第一次是一样的,所以从 undo log 中读到的数据快照和第一次是一样的,即便过程中有其他事务修改也读不到。因此解决了不可重复读的问题。
MVCC解决幻读
InnoDB
存储引擎在 RR 级别下通过 MVCC
和 Next-key Lock
(临键锁) 来解决幻读问题:
1、执行快照读
在快照读的情况下,RR 隔离级别使用MVCC,只会在事务开启后的第一次查询生成 Read View
,并使用至事务提交。所以在生成 Read View
之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的 “幻读”。
2、执行当前读
在当前读的情况下,读取的都是最新的数据,如果其它事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读。InnoDB
使用Next-key Lock
来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。