MVCC(multiversion concurrency control)多版本并发控制的意思。InnoDB是一个多版本存储引擎。会保留多个行记录修改的历史版本来支持事务的并发特性和回滚。MVCC是一种提高事务并发的一种技术,事务的一致性非锁定读(Consistent Nonlocking Reads)就是通过MVCC来实现。一致性非锁定读意思InnoDB使用多版本控制向查询提供数据库在某个时间点的快照数据。查询查看在该时间点之前提交的事务所做的更改。整个过程是无锁的。数据库多个事务并发存在读读、读写和写写操作。其中读读可以不需要控制,因为不会修改数据库数据。写写需要借助锁来阻塞执行。读写这里就可以使用多版本控制来实现并发不需要借助锁,提高数据库并发。
一致性读不会在所访问的表上加任何锁,不会影响其它事务对表进行读取和修改。
undo log
事务过程中修改会保留数据库多个版本记录,这些不同的行数据版本存储在重做日志(undo log)中。重做日志存储在undo tablespace中。mysql8在数据库初始化时undo tablespace 会默认创建两个,在数据目录下innodb_undo_001和innodb_undo_002两个文件,可以通过innodb_undo_directory设置存放位置,默认是在数据目录下。每个undo tablespace根据数据页(innodb_page_size)大小不同,数据页大小默认16kb情况下,8.0.23前默认10MB,往后版本是16MB.
每个undo tablespace又被划分成128个回滚段( rollback segments),每个回滚段由多个undo slot组成,也称作undo log segment。undo slot的数量和InnoDB数据页的大小有关系。undo slot数量=innodb_page_size/16。数据页默认大小16kb情况下,每个回滚片段上undo slot数量就是1024个。这样一个undo slot的大小= undo tablespace大小/(128*1024)
列一下上面几个值的大小:
一个undo tablespace大小:16MB
一个undo tablespace分成128个回滚段(rollback segment),回滚段的大小为128KB
一个回滚段有1024个slot(undo log segment),一个slot的大小为128bytes。
一般情况下一个undo log segment只存储一个事务执行过程中产生的一组undo日志。当然如果事务提交可能undo log segment会被其它事务复用的情况。如果一个事务的操作较多,一个undo log segment可能存储不下就会使用多个undo log segment。可以用上面几个参数来初步衡量数据库支持读写事务并发的能力。
如果每一个并发事务只有一种写操作,则数据库并发能力:
undo slot数量(innodb_page_size / 16)* 回滚段数量 * undo空间数量
当然一个事务可能存在多个操作,如下面要说的日志类型,insert和更新(update、delete)会存放在不同的undo log segment上,这会导致该值变小。这也只是一个理论评估值。
undo log日志类型
在介绍undo log记录格式前先回忆下行数据记录前面说的几个隐藏列:
1、6字节的事务ID(DB_TRX_ID),事务ID是数据库全局自增的。
2、7字节的回滚指针(DB_ROLL_PTR),回滚指针指向回滚片段中一个undo log记录,如果一行被修改,会记录一条包含修改之前的内容的undo log记录,用来回滚时使用。对于新插入行不存在上个版本,roll_pointer是空。
3、6字节的行记录ID (DB_ROW_ID)
每一个undo log记录都会有一个undo_no。每个事务中都是从0开始递增,事务没有提交,每生成一个undo log记录,该日志对应undo_no就+1。这样当事务回滚时候就可以按照undo_no顺序逆序进行回滚。
undo log分两种插入和更新undo log。更新包括修改和删除。之所以插入类型单独一种,是因为插入只有当前事务可见,在事务提交或回滚后该日志可以立即清理回收。更新类undo日志也用于一致性读操作,当生成该更新undo log记录的事务提交后,当前数据版本可能还会被其它并发事务使用,因此不能立即回收清理,只有当InnoDB中所有事务快照对当前日志都没有引用时,这些日志才会被丢弃。使用异步purge线程来清理回收更新undo log空间。
因此建议及时进行事务提交,否则回滚片段会变的特别长,影响数据库并发能力。
insert undo log:
事务在insert操作时产生的undo log。这种操作undo log只要记录行数据对应的表ID和主键就可以了,实际行数据还是存储在表空间里。因为insert操作的记录,只对当前事务本身可见,不存在版本链中,这种undo log可以在事务提交后直接删除。
delete undo log
事务在delete操作时,delete操作首先会修改行记录的删除标识为1,行数据并未真正删除。然后记录undo log。delete 类型undo log除了记录对应表ID和被删除数据主键信息外,还会记录原记录对应旧的事务ID(trx_id)和回滚指针(roll_pointer)。
update undo log
事务在更新操作时,会记录一条更新undo log。里面有表ID,主键ID,旧记录事务ID和旧记录回滚指针。另外还会记录被更新列数,和被更新各列的原值,如果事务回滚就可以根据该条undo log记录恢复数据至本次update前。多个事务对同一行记录进行修改,在多次更新后,roll_pointer就形成了一个版本链(多版本)。每个事务能看到的数据记录值要依赖后面要说的readview来确定。
readview
readview是当前事务对数据可见性的一个视图规则,其中涉及到以下几个参数:
- creator_trx_id 创建当前事务的事务ID
- rw_trx_ids 当前读视图快照生成时所有的读写事务ID列表(执行中还未提交)
- min_trx_id 活跃读写事务列表(rw_trx_ids )最小值。被称作低水位(low water mark)。
- max_trx_id 当前数据库中事务最大ID+1,也就是此刻还未开始的事务,称作高水位(high water mark)。
当前事务读快照生成可读性遵循以下规则:
1、如果数据对应trx_id<min_trx_id 表示这个版本是已提交的事务这个数据是可见的。
2、如果数据对应trx_id>=max_trx_id 表示数据版本是在当前事务创建后生成的,不可见。
3、如果数据对应trx_id在min_trx_id 和max_trx_id 之间,要分两种情况进行判断。如果trx_id在活动事务列表rw_trx_ids 中,则当前事务不可见;反之可见。
这里第一种情况很好理解,因为是另一个活跃事务修改记录未提交,当前事务不可见。而不在rw_trx_ids中是什么情况呢?这种就是比min_trx_id 开启的晚但是比min_trx_id 提交的早。举个例子来理解下:
假设同时5个事务并发,id分别是[100,101,102,103,104,105]。执行过程中100,102,103相继提交。运行中事务列表变长[101,104,105]。这个时候新开事务106。分析下上面几个值
{
creator_trx_id :106,
rw_trx_ids :[101,104,105],
min_trx_id :101,
max_trx_id :107
}
根据规则当前事务106是能看到102和103数据的。这就是第二种情况的意思。
4、当前事务可以看到自己修改的数据版本。即数据版本trx_id=creator_trx_id 。
可重复读和读已提交两个隔离级别创建readview的时机机制有一些差别。 可重复读是在事务开始时刻,确切地说是第一个读操作创建readview的,往后整个事务的一致性读数据都来自这个readview;读已提交是在每次读操作都创建一个对应的readview,多次select会根据此刻数据库事务创建不同版本的快照。
快照读和当前读
一个读写事务中即包含数据读和数据写两部分。 上面说的readview版本可见性是适用于读数据,在事务中也称为快照读。
但是对于一个事务的写操作,写操作中如果涉及数据读取,如update test set balance=banace+1 where id=1;会先读取balance的值,这个时候读取就不会使用快照读。写操作通常会有锁的存在,会读取当前最新版本数据也称作当前读。这里当前读是更新语句此刻已提交的最新值,会和本事务第一个readview读生成的快照读有一定的时间差。如果同时多个事务进行写操作,会有锁等待。
实例测试
上面说了那么多理论,使用实际例子来验证测试下,便于理解。还是使用上一篇介绍事务隔离级别的表。
CREATE TABLE account (
id int(11) NOT NULL AUTO_INCREMENT,
balance int(11) DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB;
insert into account(balance) values(0),(0),(0);
mysql> select * from account;
+----+---------+
| id | balance |
+----+---------+
| 1 | 0 |
| 2 | 0 |
| 3 | 0 |
+----+---------+
3 rows in set (0.00 sec)
默认隔离级别可重复读下,默认自动提交,开启三个事务
session1 | session2 | session3 | |
---|---|---|---|
T1 | start transaction; select balance from account where id=1; | ||
T2 | 开启事务更新数据 start transaction; update account set balance=1 where id=1; | ||
T3 | select balance from account where id=1; | ||
T4 | commit; | ||
T5 | select balance from account where id=1; | ||
T6 | update account set balance=balance+1 where id=1; | ||
T7 | update account set balance=balance+1 where id=1; | ||
T8 | select balance from account where id=1; | ||
T9 | commit | ||
T10 | select * from account where id=1; |
T1时刻,session1读取快照版本 balance=0
T2时刻,session2更新为1不提交
T3时刻,session1读取balance仍然为T1时刻快照balance=0
T4时刻,session2提交更新
T5时刻,session1读取balance仍然为T1时刻快照balance=0
T6时刻,sessioin1更新,会尝试对id=1记录加锁,并且使用当前读读取balance最新值+1,这时候可以读取到T4时刻session2提交的记录balance=1,最后执行更新balance=balance+1=2。未提交
T7时刻,session3直接更新数据,默认事务自动提交。尝试对id=1记录加锁。由于T6更新未提交,这里T7会进入阻塞等待
T8时刻,查询balance结果为2,可以看到自己T6时刻修改的版本数据
T9时刻,session1提交释放锁,session3的锁等待结束,执行更新自动提交。
T10时刻,session3查看结果balance=3,在T9 session1提交版本基础上修改。