MVCC概述
MVCC(Multi-Version Concurrency Control)即多版本并发控制。主要是为了提高数据库的并发性能而提供的,采用了不加锁的方式处理读-写并发冲突,确保了任何时刻的读操作都是非阻塞的。只需要很小的开销,就可以实现非锁定读,从而大大提高数据库系统的并发性能。所以我们也可以说MVCC是一种用来解决读-写冲突的无锁并发控制。
引入
假设我发表了一篇博客,检查的时候发现了有几个错字,打算去修改,修改完之后要重写发布并审核。那此时对其他正在看文章的读者来说,他们是不是看不到了呢,答案是否定的,他们依然能够看到,不过看到的还是有错别字的版本,即旧版本。当审核通过之后就用新版本去覆盖了旧版#本,此时看到的文章就是已经修改过的新版本。
MVCC多版本并发控制
而对于InnoDB的MVCC机制来说,思路也大致相同。并发读写是存在线程安全问题的,有可能出现脏读、幻读、不可重复读。而MVCC的读其实是快照读,快照读读取到的数据不一定是最新的,又可能是历史的版本。也即让并发的事务的读写操作作用于不同的版本,比如读老版本,写新版本,这样无论执行写操作的事务干了啥都不会影响读的事务。
需要注意的是MVCC只在RC
和RR
两个隔离级别下工作。如果是RU
的话,允许脏读的存在,即一个事务可以读取到另一个事务未提交的数据,自然可以读到最新的版本,与MVCC冲突。如果是SERIALIZABLE
,所有的事务都是串行的,不存在并发,因此也就没有MVCC什么事了。虽然MVCC在RC
和RR
下工作,但是他们的实现方式又是不同的。
实现原理
MVCC主要是通过隐藏字段,Undo-log
日志以及ReadView
来实现的。
隐藏字段
数据库表中的每一行除了我们显示定义的几个字段之外,还构建了一些InnoDB引擎的隐藏字段,主要的有DB_ROW_ID
、DB_Deleted_Bit
、DB_TRX_ID
、DB_ROLL_PTR
。
- DB_ROW_ID:隐藏主键,6Bytes。InnoDB存储引擎是按照主键作为聚簇索引列来构建B+树存储的,如果表中没有主键,那么就选择一个唯一非空的字段。如果两种都没有就InnoDB会自动以DB_ROW_ID产生一个聚簇索引,只不过这个索引在上层无法使用,仅提供给InnoDB构建树结构存储表数据。
- DB_Deleted_Bit:删除标识,1Bytes。这里不做过多赘述。
- DB_TRX_ID:最近修改/插入事务ID,6Bytes。MySQL对于每一个创建的事务,都会为其分配一个事务ID,事务ID同样遵循顺序递增的特性,即后来的事务ID绝对会比之前的ID要大。但是对于select查询语句,其事务id=0。如果是手动开启的事务,无论是否是select,MySQL都会为其分配事务ID。而TRX_ID记录的就是最近一次改动当前这条数据的事务ID。
- DB_ROLL_PTR:回滚指针,7Bytes。指向这条记录的上一个版本(存储于 rollback segment 里)。当一个事务对一条数据做了改动后,都会将旧版本的数据放到Undo-log日志中,而DB_ROLL_PTR就是一个地址指针,指向Undo-log日志中旧版本的数据,当需要回滚事务时,就可以通过这个隐藏列,来找到改动之前的旧版本数据,而MVCC机制也利用这点,实现了行数据的多版本。
Undo-log
在InnoDB中undo日志一共有两种类型,分别是Update Undo Log
和Insert Undo Log
。
- Update Undo Log:这种类型的Undo日志主要用来存储更新数据之前的原始信息,其主要目标是满足在事务执行过程中的数据修改引发的回滚操作;并用于在读取数据时,保证数据的一致性。
- Insert Undo Log:在插入操作中产生的日志。只在事务回滚时需要,InnoDB并不需要保存完整的行数据信息,它主要用于标记这条新插入的记录在事务完成之前对其他事务是不可见的。
假设有一个person表,表中存储的数据如下:
执行这样两个事务:
start transaction;
update person set user_name = "李四" where u_id = 1;
commit;
start transaction;
update person set sex = "女" where u_id = 2;
commit;
其实就会出现这样一条版本链,旧版本的信息存储在undo日志中,新的输入的db_roll_ptr指向的只上一个版本的地址。
值得注意的是,新版本数据都会插入到链表头中,而不是追加到链表尾部。
update的执行过程:
- 对ID=1这条要修改的行数据加上
排他锁
。 - 将原本的旧数据拷贝到Undo-log的rollback Segment区域。
- 对表数据上的记录进行修改,修改完成后将隐藏字段中的trx_id改为当前事务ID。
- 将隐藏字段中的roll_ptr指向Undo-log中对应的旧数据,并在提交事务后释放锁。
Undo-log日志要设计出版本链,一方面可以实现事务回滚,另一方面则可以实现MVCC机制。
ReadView
如果t2事务要查询一条行数据,此时这条数据正在被他t1事务修写,那么这条行数据也就可能存在多个旧版本数据,t2在查询的时候,应该查询哪个旧版本的数据呢?此时就需要ReadView。具体来说,当一个事务需要读取数据时,InnoDB会创建一个ReadView实例,为该事务提供一个数据的“快照”,在这个快照中记录着当前所有活跃事务的ID(活跃事务是指还在执行的事务,即未结束(提交/回滚)的事务)。
当一个事务启动后,首次执行select操作时,MVCC就会生成一个数据库当前的ReadView,通常而言,一个事务与一个ReadView属于一对一的关系(不同隔离级别下也会存在细微差异),ReadView一般包含四个核心内容:
- creator_trx_id:当前创建这个ReadView的事务id
- trx_ids:在生成当前的ReadView之前,系统内活跃的事务id列表
- up_limit_id:活跃的当前事务列表中,最小的事务id
- low_limit_id:生成当前ReadView时,系统要给下一个任务分配的事务id。
看一个ReadView示意图:
假设当前数据库中有t1~t5这5个事务,其中活跃着的事务时t1,t2,t4,t3几经回滚,t5已经提交,此时有一条select语句执行时,就会生成一个ReadView,没有开启事务进行select,而是直接select,那么就会为其分配trx_id=0。所以产生快照的信息是:
{
"creator_trx_id" : "0",
"trx_ids" : "[1,2,4]",
"up_limit_id" : "1",
"low_limit_id" : "6"
}
MVCC机制的实现原理
当一个事务尝试修改某条数据时,会将表中的旧数据放入udno日志中;当一个事务查询某条数据的时候,MVCC会生成一个ReadView快照读。其中Undo-log主要实现数据的多版本,ReadView则主要实现多版本的并发控制。
执行过程:
- 当事务中出现select语句时,会生成一个ReadView
- 判断数据行中的隐藏列trx_id与ReadView.creator_trx_id是否相等。
- 相等:表示创建ReadView快照和修改行数据(修改行数据这个事务生成的就是trx_id)是同一个事务,那么这个事务自然可以读取到这行最新版本的数据。
- 不相等:代表目前要查询的数据,是被其他事务修改过的,继续下面的判断
- 判断数据行中的隐藏列trx_id与ReadView.up_limit_id的大小关系。
- 前者小于后者:表示修改行数据的这个事务,在创建快照前就已经完成,可以读取最新版本的数据。
- 前者大于等于后者:代表改动行数据的事务还在执行,继续进行判断
- 判断数据行中的隐藏列trx_id与ReadView.low_limit_id这个值的大小关系。
- 前者大于等于后者:那么表示这行数据是在创建ReadView(即,当前事务开始)之后被修改的,因此不能访问最新版数据。
- 前者小于后者:表示改动行数据的事务ID在up_limit_id、low_limit_id之间,需要进一步判断。
- 继续判断trx_id是否在trx_ids中。
- 在:表示改动行数据的事务目前依旧在执行,不能访问最新版数据。
- 不在:表示改动行数据的事务已经结束,可以访问最新版的数据。
这一块我的整理还是比较抽象的,建议再去看看文件末尾的参考资料,再去理解和消化。
RC、RR下的MVCC
- 在RC隔离级别下,是每个快照读都会生成并获取最新的ReadView;
- 在RR隔离级别下,则是同一个事务中的第一个快照读才会创建ReadView, 之后的快照读获取的都是同一个ReadView。
参考资料
- MySQL之MVCC机制:为什么你改了的数据我还看不见?
- MVCC详解,深入浅出简单易懂
- 【MySQL笔记】正确的理解MySQL的MVCC及实现原理