InnoDB MVCC解析

innodb的多版本控制主要是依靠readview+undo log实现的,其中readview为事务当前系统的可见性视图,即当前时刻的事务系统trx_sys快照,通过readview来判断记录对当前事务的可见性;undo log以链表的形式按新旧顺序存储一行记录的历史数据。

下面分别解析readview和undo log原理。

Readview

innodb中的插入、修改或者删除操作总是直接操作真实数据,即数据库所有事务共享的数据,并不存在每个事务有其私有的数据副本,因而每个事务读取当前行记录时都需要判断别的事务操作该行后本事务对记录的可见性,可见性的判断通过每个事务的readview来判断。

在5.6版本及之前时,readview的生成通过扫描trx_sys中的读写事务链表,来获取当前活跃读写事务id并全部放入readview中,在5.7则是维护了一个新的活跃事务id链表,每个新开启的事务都先设置为只读read-only事务,当有读写操作时转换为读写事务,分配回滚段并分配事务id并加入活跃事务id链表,因而每次新事务生成readview时,只需要memcpy一下该链表就行,而不需要锁住trx_sys而浪费大量时间扫描链表。

readview的数据结构read_view_t保存的数据主要有:

  • rw_trx_ids,读写事务id数组。

  • low_limit_id,读写事务id中最大的事务id,大于该事务id的记录对于当前事务一定是不可见的。

  • up_limit_id,最小的事务id,小于该id的记录对于当前事务一定是可见的。

  • low_limit_no,trx_no小于low_limit_no的undo log对于readview是可以purge的,主要是purge thread回收undo log扫描活跃事务链表以确定可以purge的undo log记录。

我们知道每条行记录都有两个隐藏列,一个是最新修改该记录的事务id DB_TRX_ID,一个是指向存储之前旧记录undo log的指针DB_ROLL_PTR,由于innodb的修改操作都是对数据库真实唯一的数据进行操作,因而当前行记录可能被其他事务B修改,而该修改对当前读取记录的事务A可能并不可见(比如事务A先于事务B执行,并且两个事务都没有提交,那么事务B对行记录的修改操作理论应该对事务A不可见),所以当前事务的读取记录操作都需要用其readview与记录的DB_TRX_ID比较,来判断当前事务与最近操作记录的事务的先后顺序,从而得知记录的可见性,具体判断规则如下:

  • 如果DB_TRX_ID小于up_limit_id,那么记录对于当前事务是可见的。

  • 如果DB_TRX_ID大于low_limit_id,那么记录对于当前事务是不可见的。

  • 如果DB_TRX_ID处于up_limit_id和low_limit_id之间,那么需要从rw_trx_ids数组找其是否存在于活跃读写事务中,如果存在,那么该记录对于事务时不可见的,如果不存在,那么是可见的。

  • 对于可见的结果,那么直接读取该记录并返回,如果不可见,则根据DB_ROLL_PTR指针去undo log里依据链表依次构建旧记录,并根据旧记录里的DB_TRX_ID来判断是否可见,直到找到可见记录或者链表扫描到终点(一般是插入操作的后一个操作,插入操作产生的undo log在事务结束后就会被purge,因为插入中的数据仅对当前事务具有可见性)。

  • 对于次级索引的查询,不存储聚集索引有的DB_TRX_ID和DB_ROLL_PTR两列,而是每个page都有一个max_trx_id来标识最近修改该页的最大事务id,因而每次查询二级索引,如果max_trx_id小于up_limit_id那么该page所有记录对于当前事务均可见,否则需要返回聚集索引查询记录可见性。

对于不同的隔离级别,其实现的不同之处在于readview和锁的应用:

  • 对于Read uncommitted,其每次读取记录都不会用readview来进行判断可见性,而是直接读取记录,因而如果有其他事务在该事务进行时修改了该记录,那么此次读取就是dirty read。

  • 对于Read committed,其会在每个语句执行前都会生成一次新的readview,有人可能会觉得这比RR级别仅生成一次readview要耗时很多,岂不是要比RR反而效率低很多,其实一个是readview生成的代价在5.7改进后小了很多,仅需要直接memcpy活跃事务数组即可(需要加trx_sys->mutex全局锁),并且还维护了一个readview cache list,对于只读事务,并且执行时读写事务并未改变,那么其结束后可以放进cache list以供其他事务重用,而从cache list取readview甚至不用对trx_sys加锁,所以开销比较小;另一个是总是维持较新的readview,减小了很多重构undo log记录的开销(这个开销是非常大的),较老的readview往往对读取记录都需要遍历较长的undo log链表,花费大量时间。

  • 对Repeatable read,会在事务开始后第一个执行的select前生成readview,并且一直不会改变,或者如果事务以START TRANSACTION WITH SNAPSHOT,那么也会在开始时直接生成一个readview,有种给当前数据库打快照的感觉。

  • 对于Serializable,则是锁的应用不同,加的不是简单的记录锁,而是间隙锁和next-key锁,即事务A读取了两个记录,那么这个记录两边的和中间的记录会加锁(首先对表级别加LOCK_IS锁,然后对查询的记录家LOCK_S共享锁),事务B可以正常读取这些记录,但是不能进行修改或者插入。

Undo log

undo log也是支持innodb mvcc的重要组成部分,用来存储数据的多版本记录,不过从宏观上来看都是串行的版本记录,并没有真的实现多版本副本同时共存。

读操作不会产生undo log,只有数据的修改操作,insert/delete/update回产生undo log,其分为两类,一类是insert,会在undo segment中分配一个insert slot给他,一类是update和delete,会分配一个update slot给他,即一个事务一般会分配两个slot。

InnoDB MVCC解析_第1张图片

[1]

undo log由多个回滚段组成,其中resg0预留给系统表空间ibdata,resg1~resg32这32个回滚段存放在临时表系统表空间,resg33~resg128(也可以更多,默认是96个)则存放在独立undo表空间,如果不支持或者没打开独立undo表空间选项,则依然存储在共享ibdata中。

undo log同样根据space id和page no定位,即其组织方式和普通的索引和数据页是相同的,独立表空间的space id是固定的,从1开始,即只能在install阶段启用独立undo表空间。

每个segment则又由1024个slot组成,每个slot里存储该事务insert或者update的修改操作链表,以时间顺序排列。因而理论上innodb最多可以同时支持1024×96个事务同时存在。

InnoDB MVCC解析_第2张图片

[1]

回滚段的分配方式具体如下:

  • 用round-robin轮询算法来依次分配回滚段给事务,即从33依次遍历到128再返回重新遍历,如果回滚段被标记为skip_allocator(purge线程需要缩减该undo表空间大小,进行truncate),那么则跳过到下一个回滚段。

  • 分配回滚段后,其trx_ref_count计数会增加,以表明该回滚段有事务正在使用,不可被purge线程truncate。

事务分配slot后,每当事务有修改记录的操作,就会根据修改类型分别向insert slot和update slot写入undo 记录了。对于不同的操作具体记录的内容也不同:

  • INSERT,undo log中主要记录插入记录的唯一主键,从而可以在回滚时唯一确定记录地址,insert的undo log在事务commit后就会被释放。

  • Delete,innodb并不会直接删除数据,而是给该行标记位delete_bit置为1,在undo log中记录行记录的唯一主键以及原记录的DB_TRX_ID和DB_ROLL_PTR,这么做主要是为了让其他需要查看该行历史记录的事务仍可以通过该行rollback指针查找,如果删除了就无处回溯了。

  • Update,一共有四种update类型,一种是update主键索引,这种会让记录在聚集索引中位置改变,而innodb是不会直接删除行记录的,因而会先给原记录delete_bit标记为1,然后在insert一条新主键记录,这两个操作都会记录undo log;一种是更新普通列,并且列长度不超过原来长度,则进行in-place update,即直接在原地修改列值,并在undo log里记录唯一主键,原列值,以及两个特殊列;一种是更新普通列,但是列长度超过原长度,则根据长度来决定是换一个位置用delete+insert方式更新,还是需要分配一个新页来放下该新列值。

    最后对于二级索引的更新都不会产生undo log。

下面通过一些实验的验证innodb MVCC的特性:

首先创建一个测试表create table test (id int primary key, comment char(50)) engine=InnoDB;

然后在隔离级别RR下进行实验:

  • 首先在session A开启事务START TRANSACTION;然后插入一条记录insert into test values(1,'test1');,并且不提交事务(auto commit设为0)。

    然后在session B开启一个事务START TRANSACTION;并对id=1的记录进行查询select * from test where id=1;,结果返回的是empty set,这是因为session B的事务开启时session A的事务正在运行未提交,因而会加入B事务的readview中,从而对B不可见。

    随后我们在session A中继续提交事务COMMIT;然后再在session B查询id=1的记录,发现仍然返回empty set,这是因为B的事务隔离级别为RR,其readview仅在第一个select时生成一次就不再改变,因而事务A的事务id仍然存在于readview中。

  • 继续在上面实验结果下接着实验,在session A开启事务,不作任何操作。

    然后在session B开启一个事务,删除id=1的记录并提交delete from test where id=1;

    随后A事务立即查询id=1的记录,select * from test where id=1;结果仍然可以返回1 test1两条记录,这是因为innodb并不会立即删除记录,而是标记为delete mark,当所有活跃事务的readview都对该记录的DB_TRX_ID不可见时,purge线程才会对该记录进行清理。而B事务开启在A事务之后,因而其事务id大于A事务readview的low_limit_id,故其操作对A事务都不可见,因而A事务仍然可以看到id=1的记录。

随后在隔离级别RC下进行实验:

  • 操作流程相同于RR级别的第一个实验,其结果却是B事务的第一个select返回empty set,但第二个select返回 1 test1的记录,这是因为RC隔离级别下事务的每个语句都会生成一个新的readview,因而第二个select 生成的readview里A事务已经提交,因而不存在于其中,故其修改是可见的。

Innodb MVCC实现存在的问题

  • 一个最典型的就是undo log无限增长的问题,考虑一个持续时间极长的事务,并伴随其他短事务频繁的update、insert操作,那么此时因为前一个长时间事务古董级的readview存在,其他事务产生的undo log不能进行purge操作,越来越多,导致磁盘空间大量被占用。[2]

  • 基于上一个问题,如果长时间事务需要读取的数据跟其他事务频繁更新的数据是同一份热点数据,那么基本上长时间事务会因此永远不会完成,而是陷于不断重构undo log的循环中。

  • 同样是长事务的问题--备份问题,构建老版本记录是需要持有该页的page latch,开始备份时会生成一个一直存在的readview,如果此时系统某一个表读写事务并发很高,那么做的修改都会一直记录在undo log,并且无法purge,当备份事务进行到备份该表时会需要花费很长时间遍历undo log构建老版本,因而page latch也会持续很长,如果此时仍有其他事务更新该表,也需要持有page latch,那么将一直阻塞直至等待超时,实例自杀。

[1] http://mysql.taobao.org/monthly/2015/04/01/

[2] https://www.percona.com/blog/2014/12/17/innodbs-multi-versioning-handling-can-be-achilles-heel/

你可能感兴趣的:(mysql)