MVCC

217 阅读8分钟

MVCC (一致性读)

通过Undo的版本链来控制并发事务访问相同记录时的行为,这种机制被我们称为多版本并发控制MVCC(Multi-Version Concurrency Control)。

MVCC只有在READ COMMITTED、REPEATABLE READ这两种事务隔离级别下才有效,用来处理多个事务读写同一条数据时数据一致性,解决读写冲突的手段(不用加锁),提高访问性能。(InnoDB引擎层次实现的)

Uodo Log的版本链

在我们的聚簇索引中,我们每条用户数据都拥有两个隐藏列TRX_ID、ROLL_POINTER(如果表中没有可以承担主键责任的列,则会分配一个ROW_ID),当我们对这条数据进行修改时,我们在修改前会对该数据做一个Undo Log ,该Undo Log大概包括主键列值、被修改列值、TRX_ID、ROLL_POINTER等。

通过对一条记录进行多次修改,生成多个Undo Log时,这多个Undo Log 会通过ROLL_POINTER 组成一个链表,每个链表的节点也可以称为一条记录的历史版本,这个链表的名字叫做Undo 的版本链。

小贴士:

TRX_ID:事务ID,当我们对该数据进行修改时,都会修改该数据对应事务的事务ID写进这里面。

ROLL_POINTER:指向该记录上一次修改时的Undo Log日志。(只有该数据被修改过才会有这个值)

通过一个例子来深入了解一下我们的版本链,表名为demo。

idnameageTRX_ID(隐藏列,插入该记录的事务ID)
1张三1599
2李四1899

现在有两个事务A(TRX_ID = 100) ,事务B(TRX_ID = 101) 先后对id为1的数据进行修改。

事务A : update demo set name = '王二' where id = 1;

事务B : update demo set name = '大郎' where id = 1;

他们所生成的版本链如下图:注意,并不是指向id为1的位置,只是为了方便,实际指向该记录在Undo Log 文件里的字节偏移量(就是从文件开始读多少多少个字节是这条历史记录)。

ReadView

ReadView简介

ReadView就是事务在进行快照读(select)时所生成的ReadView(一致性视图)。

ReadView主要有四个内容:

  • M_IDS:在生成ReadView时,当前数据库系统中活跃的读写事务的事务ID列表。
  • MIN_TRX_ID:在生成ReadView时,当前数据库系统中活跃的读写事务的最小事务ID,也就是M_IDS中最小的值。
  • MAX_TRX_ID:在生成ReadView时,当前数据库系统中应该分配给下一个事务的事务ID值。(注意,是下一个事务的值,会+1的)
  • CREATOR_TRX_ID:生成ReadView时的事务ID。

生成ReadView的时机

  • READ COMMITTED(RC)隔离级别时:在每一次select读取数据时生成ReadView。
  • REPEATABLE READ (RR)隔离级别时:只有在第一次select读取数据时生成ReadView。

ReadView可见性

  1. 如果当前访问记录TRX_ID值和CREATOR_TRX_ID值相等,意味着是当前事务在访问他自己修改的记录,所以当前最新版本是能够访问到的。
  2. 如果当前访问记录的TRX_ID值小于ReadView中的MIN_TRX_ID值,意味着当前访问记录的最新版本在ReadView生成前已经提交了,所以是能访问之前的数据的。
  3. 如果当前访问记录的TRX_ID值大于等于ReadView中的MAX_TRX_ID值,意味着当前访问记录的最新版本在ReadView生成后才提交(修改当前访问记录的事务在生成ReadView的事务之后才修改该记录),所以这条数据在是不能被访问的。
  4. 如果当前访问记录的最新版本TRX_ID在MIN_TRX_ID和MAX_TRX_ID中之间,那么需要判断TRX_ID 是否在M_IDS中,如果在,说明在ReadView生成后这个事务还是活跃的(没提交,没回滚),那么这条记录不能被访问,如果不在,说明修改这条记录的事务在生成ReadView时事务被提交了(不活跃、并且修改成功,所以只能是提交状态),说明这条数据还是能被访问的

小小小小贴士:如果记录的当前版本对于当前事务来说不能被访问,那么需要根据ROLL_POINTER找到下一条Undo Log,在根据这规则判断是否能够访问,依次类推,如果记录的最后一个版本也不可见,那么查询结果就不包含这条记录。

文字总是枯燥无味的,通过图来深入了解这个规则吧,表名demo。

eg1:访问记录的TRX_ID 等于 CREATOR_TRX_ID 值(RC) ;

idnameTRX_ID(隐藏列,插入该记录的事务ID)
1王二80
2张三84
顺序事务A事务B
begin;begin;
update其他表分配一下事务ID :TRX_ID:85
update demo set name = '李四' where id = 1;分配事务ID :TRX_ID : 86
update demo set name = '王五' where id = 1;
select name from demo where id = 1;
commit;
commit;

该记录完整版的Undo Log 版本链为:

步骤解读(只解读生成ReadView的步骤):

事务B第⑤步根据id = 1 查询,此时生成ReadView视图,M_IDS 为[85,86] , MIN_TRX_ID 为85(当前并没有活跃的读写事务),MAX_TRX_ID 为87(当前最大活跃事务B的86 +1 ),CREATOR_TRX_ID 为86

根据ReadView可见性分析,将 TRX_ID 与 CREATOR_TRX_ID 做对比,发现最新记录(版本链③)的TRX_ID = CREATOR_TRX_ID (86 == 86) ,代表当前最新版本是在我们当前事务中做的修改,所以是能够访问到的,将修改的 最新值name为‘王五’返回客户端。

eg2:访问记录的TRX_ID 小于 MIN_TRX_ID(RC) ;

idnameTRX_ID(隐藏列,插入该记录的事务ID)
1王二80
2张三84
顺序事务A事务B
begin;begin;
update demo set name = '李四' where id = 1;分配事务ID :TRX_ID : 86
update demo set name = '王五' where id = 1;
commit;update其他表分配一下事务ID :TRX_ID:87
select name from demo where id = 1;
commit;

该记录完全版Undo Log 版本链为:

步骤解读(只解读生成ReadView的步骤):

事务A第⑤步根据id = 1 查询,此时生成ReadView视图,M_IDS 为[87] , MIN_TRX_ID 为87(当前并没有活跃的读写事务),MAX_TRX_ID 为88 (当前最大活跃事务B的87 +1 ),CREATOR_TRX_ID 为87

根据ReadView可见性分析,将 TRX_ID 与 CREATOR_TRX_ID 做对比,发现最新记录(版本链③)的TRX_ID < CREATOR_TRX_ID (86 < 87) ,代表是我们生成的这个ReadView之前提交的,能够使用这条记录,此时将name‘王二’返回至客户端。

eg3:访问记录的TRX_ID值大于等于的MAX_TRX_ID(这个根据RR级别会好理解一点);

idnameTRX_ID(隐藏列,插入该记录的事务ID)
1王二80
2张三84
顺序事务A事务B AND 事务C
begin;隐式提交事务.....
update其他的表分配一下事务ID TRX_ID:85
select name from demo where id = 1;
update demo set name = '李四' where id = 1;分配事务ID :TRX_ID : 86
update demo set name = '王五' where id = 1 ;分配事务ID:TRX_ID : 87(事务C)
select name from demo where id = 1;
commit;

该记录完全版Undo Log 版本链为:

步骤解读(只解读生成ReadView的步骤):

  1. 事务A在第③步根据Id = 1 查询,此时生成ReadView视图,M_IDS为[85],MIN_TRX_ID为85,MAX_TRX_ID 为86(85 + 1 ), CREATOR_TRX_ID为 85
  2. 在第④、⑤步时,事务B And 事务C 先后修改了这条记录,此时记录的完整版Undo Log为上图。
  3. 在第⑥步时,由于当前的隔离级别是RR ,所以并不会重新生成ReadView(RC情况下每次生成造成了重复读的问题),所以我们根据记录(版本链③)的TRX_ID来判断,TRX_ID >= CREATOR_TRX_ID (87 >= 85),所以版本链③的那条记录对于我们来说是不可见的,根据这条记录的ROLL_POINTER 来找到版本链②。
  4. 我们拿到版本链②的TRX_ID来判断,发现TRX_ID 还是 >= CREATOR_TRX_ID (86 >= 85), 所以再次往下找,找到版本①的那条记录。
  5. 发现版本一的记录符合TRX_ID 小于 MIN_TRX_ID(80 < 85),所以我们直接返回该数据的name值‘王二’。

eg4:访问记录的TRX_ID在MIN_TRX_ID和MAX_TRX_ID中之间,判断TRX_ID 是否在M_IDS中,在里面这条记录是不能被访问,如果不在里面是能被访问的。

idnameTRX_ID(隐藏列,插入该记录的事务ID)
1王二80
2张三84
顺序事务A事务B事务C
begin;begin;begin;
update其他的表分配事务ID:TRX_ID:85
update demo set name = '李四' where id = 1;分配事务ID :TRX_ID : 86
update demo set name = '王五' where id = 1 ;分配事务ID:TRX_ID : 87(事务C)
select name from demo where id = 1;
commit;
select name from demo where id = 1;
commit;commit;

该记录完全版Undo Log 版本链为:

步骤解读(只解读生成ReadView的步骤):

  1. 事务A在第⑤步根据Id = 1 查询,此时生成ReadView视图,M_IDS为[85,86,87],MIN_TRX_ID为85,MAX_TRX_ID 为88(87 + 1 ), CREATOR_TRX_ID为 85
  2. 在第③、④步时,事务B And 事务C 先后修改了这条记录,此时记录的完整版Undo Log为上图。
  3. 在第⑤步时,,所以我们根据记录(版本链③)的TRX_ID来判断,此时TRX_ID在MIN_TRX_ID和MAX_TRX_ID中间,所以我们需要拿TRX_ID去检测是不是在M_IDS中,发现也在,这条记录是在当前十五不可见的,所以就继续往下找,找到TRX_ID 为86的(版本链②),发现也不行,最后找到TRX_ID为80的(版本链①),返回name为‘王二’的记录。
  4. 然后在第⑦步,重新根据id=1查询,此时重新生成ReadView视图,M_IDS为[85,87](86的事务B已经commit了,不活跃了),MIN_TRX_ID为85,MAX_TRX_ID 为88(87 + 1 ), CREATOR_TRX_ID为 85
  5. 然后根据第3点...找到TRX_ID 为86的,发现不在M_IDS中了,说明在这个过程中,事务已经被提交了,这条记录在当前事务是可见的,所以我们可以直接返回这条name为‘李四’的记录。(RC 级别的不可重复读问题)

二级索引时的ReadView

只有聚簇索引才有TRX_ID,ROLL_POINTER这两列。但是如果我们的查询条件是使用二级索引、联合索引等来查的呢?

二级索引的Page Header部分有一个PAGE_MAX_TRX_ID 的值,记录着修改这个页的最大事务,当我们通过使用二级索引来查询的时候,会用ReadView的MIN_TRD_ID去跟Page Header的PAGE_MAX_TRX_ID做对比。

如果我们MIN_TRD_ID > PAGE_MAX_TRX_ID,那说明该页面的所有内容都可见,如果小于或等于的话,根据这个二级索引内的主键ID的去做回表操作拿到聚簇索引中对应的那一列,根据那一列的值与二级索引的值做一个对比,比如,二级索引的name = ‘张三’,聚簇索引的name = ‘张三’,如果相同,就将这条记录返回,如果不相等,根据ReadView的规则继续往下找。

Delete的数据在RR级别下是如何可见的呢

还会被使用的delete_flag记录 (就是Delete的记录,会打一个delete_flag标识继续为MVCC服务,不会真正的物理删除)以及Undo Log (Delete记录的Undo Log )都不会在purge过程时清除

ReadView解决的问题以及未完全解决的问题

Read Committed 依靠ReadView,事务每次select读取时,都会产生一个新的快照,避免脏读的问题(因为未提交事务是活跃的,所以其他事务未提交的修改是不可见的),同时也因为每次读取都生成一个ReadView,会有不可重复读的问题(我在同一个事务中读到的记录都他妈不一样?黑人问号???)。

Repeatable Read(RR) 同样也是依靠ReadView来解决脏读、不可重复读的问题,大部分情况解决幻读问题。和Read Committed不同的是,RR隔离级别的情况下,事务只有在第一次select 的情况下,才会产生这个ReadView,之后的每一次select都不会产生ReadView,之后重复使用第一次生成的ReadView。(因为只依赖了第一次select的ReadView,第一次读到啥样后面就啥样。)

在RR级别下面,是会有可能产生幻读的,比如事务A (TRX_ID为100)开启一个事务,在第一次select时将ReadView生成,事务B(隐式提交,TRX_ID为101)在事务A select生成ReadView之后插入一条数据,此时那条数据的TRX_ID为101,但是在事务A里面,我们通过Update 语句修改了刚刚事务B插入的一条数据,由于被事务A修改了,此时这条数据的TRX_ID为100,对于第一次select生成的ReadView也是可见的,所以在这种情况也会产生幻读的情况。(同样也有可能产生的问题)

顺序事务A事务B
begin;隐式提交事务....
select name from demo where id <= 3;
insert into demo values(2,'王二');
update demo test name = '麻子' where id = 2;
select name from demo where id <= 3;如果是MVCC情况下,这里还是能查到新插入id = 2 的记录。(能产生幻读)
commit;

上面能够读到的情况属于update破坏快照读了,由快照读变成当前读。

幻读我他妈直接加锁。