该文章是《Innodb的MVCC简介》中的细节作出解释。
在MVCC出现之前的数据库,为了实现一致性读,如sqlserver,DB2均采用锁定读技术,写操作往往会阻塞读操作,导致数据库并发性能不高。
Oracle与postgre相继推出自己的多版本并发控制技术,这一技术的核心是在发生读写冲突时候,读操作不再被写操作阻塞,而是直接读取写操作所在行的前一个版本。如何构建历史版本,Oracle与postgre分别采用了两种思路来实现。
- postgre采用的方式:更新的记录生成新记录与旧记录,均保存在数据页中,没有回滚段和undo日志记录
- oracle采用回滚段与undo日志记录,undo日志记录仅仅保存更新前的修改的必要信息,结合现有记录就能构建历史版本
postge采用的方式存在的问题是,如果更新较多,且频繁, 数据页空间膨胀得很厉害。由于不支持原位更新(update in place), 携带删除标记的记录相对也多,空间回收的进程对系统影响也很大。
Innodb采用的方式与oracle类似,采用回滚记录的方式构造历史记录。
行记录
在前面的文章讲过,聚集索引的行记录至少有两个隐藏列,DB_TRX_ID 与 DB_ROLL_PTR,其含义已经在《Innodb的MVCC简介》中讲过。
![aa46b409e5b0934a6f1fe0cd824899da.png](https://i-blog.csdnimg.cn/blog_migrate/8d58dff0e6bfd41a15da35dccd0b0af5.jpeg)
行格式
其中DB_TRX_ID用行对事务可见性判断,DB_ROLL_PTR作为指向回滚记录的指针,其结构如下
![752addbd8616dfdee3aaa77ccf254277.png](https://i-blog.csdnimg.cn/blog_migrate/9a3de11d8b4b96ac7e335ecdea6f7f7e.jpeg)
DB_ROLL_PTR
- 最高比特位是insert的标志(第55比特位)
- 随后7bit是undo的space number,与space id对应(48~54)
- 32bit是page no(第16~47比特位)
- 16bit是undo记录在undo页内的偏移(第0~15比特位)其包含的信息可使用 (insert_flag,space id,page no,offset) 四元组来表示。
Undo记录
至于undo的表空间,undo页记录是怎么存储的,这里不再赘述,讲MVCC原理只需要知道能通过rollptr找到undo的记录。
update 的undo记录的内容通常有:
- type_cmpl信息,包含undo日志类型的信息,以及其他信息
- info_bit,包含row的格式信息
- trx_id,旧值记录的trxid
- roll_ptr,旧值记录的roll_ptr
- 键值 : 主键,唯一键,rowid
- 更新列数
- 列号
- 列长
- 列值
事务的ReadView
《Innodb的MVCC简介》一文讲过,进行读操作的事务,检查行中的DB_TRX_ID来决定是否读取历史版本。
而这个检查过程,则是通过行中的DB_TRX_ID所表示的事务id,与读操作事务ReadView结构进行比较,决定当前行是否可见,如果可见,返回当前行,否则要读取undo记录,构造历史行记录。
ReadView来判断行是否可见,不是很容易理解。要理解这个过程,先要明确:
- 事务id(后面均称为trx_id)是一个永远递增的数值。可以认为是一个逻辑时钟。
- trx_id是事务的标识符,是第一次发生写操作的逻辑时钟。纯读事务没有事务id。
- 事务no(后面成trx_no)是事务提交的时候逻辑时钟。
那么可以知道,trx_id小的事务,进行写操作必定早于trx_id大的事务进行的写操作。一个事务的trx_no必定比它的trx_id大。
ReadView中有如下字段:
- m_low_limit_id:构建ReadView时候事务系统下一个trx_id (即trx_sys->max_trx_id),即还没发生的trx id, trx_id大于或者该值的行,对于该事务是不可见的。
- m_up_limit_id :当前活跃事务最小值。行记录中trx_id小于该值均可见。
- m_creator_trx_id:如果当前事务有写动作,则该值是当前事务的trx_id,凡是行中trx_id等于该值的,表示是本事务自己写的行,都是可见的,并且应当从ReadView中的活跃列表中摘除。
- m_ids : ReadView构建时当前活跃事务列表。
- m_low_limit_no:当前已经提交的事务最大的trx_no,主要用于purge
以上的文字不是很好理解,可以用如下式子表示,这样方便理解。
![ed8b68dca8f00416156aefd5a0bbf170.png](https://i-blog.csdnimg.cn/blog_migrate/18bba52035b424c00b08ed97d052f0de.jpeg)
ReadView的三个部分
这里有三部分
- 第一部分是一个开区间,表示小于uplimit均可见
- [ids] 表示活跃列表数组,属于活跃列表中的均不可见。
- 最后一部分是一个前闭后开的区间。表示大于或者等于lowlimit的均不可见。
如下示例:
![790afa2bde9141073a4aec720662d821.png](https://i-blog.csdnimg.cn/blog_migrate/cac48d2b9393bf654d40183bb06eb965.jpeg)
黄色部分表示一个只读事务,没有trx_id, 虚线部分表示读操作发生的时刻。无论隔离级是读提交还是可重复读,正在修改还没有提交的事务的数据都不允许读到(否则就是脏读隔离级了)。 那么,当时正在执行的事务有T4与T6,已经提交的事务T1,T2,T3,T5, 这些事务的数据都是可见的。Innodb构造的ReadView为
![de0dc7a51b17fe5083114e8875ec2408.png](https://i-blog.csdnimg.cn/blog_migrate/5ef90c4031804fc9d00189f2d552cc44.jpeg)
- 小于4的事务T1,T2,T3 写的行数据均可见
- 不在活跃列表[4,6]的事务T5,可见
- T7以及T7之后的事务修改的行数据均不可见。
发现行数据不可见之后,如何构造历史版本,《Innodb的MVCC简介》一文已经讲得很清楚,这里就不讲了。
可以认为,一个ReadView相当于一个快照点 ,读提交隔离级,意味着事务中多次读操作,每一次读操作,都要读一个新的快照点,也就要重新构造一个ReadView , 而可重复读,意味着多次读操作,都应该读同一个快照点,所以要重用最开始的ReadView 。
Purge
purge流程比较复杂,要讲清楚需要另开一个专题才能讲明白。这里仅仅将它的设计原则。
- purge 也有一个ReadView,是从事务系统复制过来最古老的ReadView。purge的ReadView可见的记录,均可认为undo日志可以清除,最古老的事务更改的数据,对于purge来说应该是不可见的,所以需要将m_creator_trx_id重新放入到ReadView的活跃列表中。
- 实际上purge仅仅purge 小于最古老的ReadView的m_low_limit_no的undo记录。所以要及时提交事务 ,如果事务哪怕是只读事务长期不提交,会阻止purge的清空数据页携带delete_mark的记录,以及回滚空间的回收。
- purge有两个步骤,一个步骤是清理数据页中携带delete mark的记录(purge),如果一个undo页所有的undo记录都做了purge,则会回收该undo页,如果一个文件所有页均回收完,满足一个阈值条件(innodb_max_undo_log_size与innodb_undo_log_truncate控制)就会收缩文件大小(truncate)