MVCC(Multi Version Concurrency Control的简称),代表多版本并发控制。与MVCC相对的,是基于锁的并发控制(Lock-Based Concurrency Control)。
MVCC主要是为了提高数据库的并发性能,读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能。
1 当前读和快照读
1. 1 当前读
当前读读指取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。比如以下这些操作都是一种当前读:
//共享锁
select lock in share mode;
select for update;
//排他锁
update;
insert;
delete;
1. 2 快照读
快照读是不加锁的非阻塞读,基于 MVCC 实现,目的是提高并发性能的。快照读读到的并不一定是数据的最新版本,而有可能是之前的历史版本。
简单说 MVCC 就是为了实现读-写冲突不加锁,而这个读指的就是快照读, 而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现。快照读的前提是隔离级别不是串行级别(串行级别下的快照读会退化成当前读)。一般的select语句都是快照读。
2 事务的隔离性
在MySQL InnoDB中,支持四种事物隔离级别
READ UNCOMMITED(未提交读)
使用查询语句不会加锁,允许脏读,事务能够看到其他事务没有提交的修改,当另一个事务又回滚了修改后的情况,被称为脏读(Dirty Read)。READ COMMITED(提交读):只能读取到已经提交的数据,只对记录加记录锁,而不会在记录之间加间隙锁,所以允许新的记录插入到被锁定记录的附近,所以再多次使用查询语句时,可能得到不同的结果,称为不可重复读(Non-Repeatable Read)。
REPEATABLE READ(可重复读):多次读取同一范围的数据会返回第一次查询的快照,会返回相同的数据行。可重复读中可能出现第二次读到而第一次没有读到的数据,也就是被其他事务插入的数据,这种情况称为幻读(Phantom Read)
SERIALIZABLE(串行读):完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻塞。
隔离级别 脏读 不可重复读 幻读 概念
READ UNCOMMITED √ √ √
READ COMMITTED × √ √
REPEATABLE READ × × √
SERIALIZABLE × × ×
大多数数据库系统的默认隔离级别都是READ COMMITTED(RC),而MySQL的默认事务隔离级别是REPEATABLE READ(RR) ,RR解决幻读是靠锁和MVCC机制。
3 undo日志
insert undo log
代表事务在 insert 新记录时产生的 undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃。
update undo log
事务在进行 update 或 delete 时产生的 undo log ; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除。
对 MVCC 有帮助的实质是 update undo log ,undo log 实际上就是存在 rollback segment 中的旧记录链。
4 MVCC原理
4.1隐藏字段
MySQL中,在每一行记录中除了数据字段,还有一些隐藏字段:
- DB_ROW_ID:单调递增的行 ID,没定义主键时,InnoDB会以row_id为主键生成一个聚集索引。
- DB_TRX_ID:事务ID:记录了新增/最近修改这条记录的事务id,事务id是自增的。
- DB_ROLL_PTR:回滚指针:指向当前记录的上一个版本(在 undo log 中)。
4.2 版本链
在修改数据的时候,会向 undo log 记录数据原来的快照,用于回滚事务。
多个事务操作同一行数据时,不同事务对该行数据的修改会产生多个版本,然后通过回滚指DB_ROLL_PTR连成一个链表,这个链表就称为版本链。
4.2 ReadView
ReadView就是事务执行SQL语句时,产生的读视图。
注意,ReadView是与SQL绑定的,而并不是事务,所以即使在同一个事务中,每次SQL启动时构造的ReadView(up_trx_id和low_trx_id)也都是不一样的
核心的数据结构
trx_id_t: 每个读写事务都会通过全局id产生器产生一个id,只读事务的事务id为0,只有当其切换为读写事务时候再分配事务id。
trx_sys_t: 这个结构体用来维护系统的事务信息,全局只有一个,在数据库启动的时候初始化。比较重要的字段有:
- max_trx_id:这个字段表示系统当前还未分配的最小事务id,如果有一个新的事务,直接把这个值作为新事务的id,然后这个字段递增即可。
- descriptors:这个是一个数组,里面存放着当前所有活跃的读写事务id,当需要开启一个readview的时候,就从这个字段里面拷贝一份,用来判断记录的对事务的可见性。
read_view_t:
InnDB为了判断某条记录是否对当前事务可见,需要对此记录进行可见性判断,这个结构体就是用来辅助判断的。
当需要一个一致性读的时候(即创建新的readview时),会把全局读写事务id拷贝一份到readview本地(read_view_t->descriptors),当做当前事务的快照。read_view_t中包含了3个重要的属性:
- up_limit_id:read_view_t->descriptors中最小的值,所有小于此值的记录都应该被此readview看到,可以理解为low water mark。
- low_limit_id:创建read_view_t时的max_trx_id,其一定大于read_view_t->descriptors中的最大值,所有大于等于此值的记录都不应该被此readview看到,可以理解为high water mark。
- trx_ids:read_view_t->descriptors的事务id列表,即Read View初始化时当前未提交的事务列表。
可见性判断逻辑
当查询出一条记录后(记录上有一个trx_id,表示这条记录最后被修改时的事务id),可见性判断的逻辑如下:
如果记录上的trx_id小于read_view_t->up_limit_id,则说明这条记录的最后修改在readview创建之前,因此这条记录可以被看见。
如果记录上的trx_id大于等于read_view_t->low_limit_id,则说明这条记录的最后修改在readview创建之后,因此这条记录不可以被看见。
如果记录上的trx_id在read_view_t->up_limit_id和read_view_t->low_limit_id之间,分两种情况:
- trx_id在read_view_t->descriptors,则表示这条记录的最后修改是在readview创建之时,被另外一个活跃事务所修改,所以这条记录也不可以被看见。
- trx_id不在read_view_t->descriptors之中,则表示这条记录的最后修改在readview创建之前,所以可以看到。
当进行RR读的时候,除了事务自己做的变更外,trx_ids中的事务对于本事务是不可见的。
可见性算法
Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的 DB_TRX_ID(即当前事务 ID )取出来,如果 DB_TRX_ID 跟 Read View 的属性做了某些比较,不符合可见性,那就通过 DB_ROLL_PTR 回滚指针去取出 Undo Log 中的 DB_TRX_ID 再比较,即遍历链表的 DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的 DB_TRX_ID , 那么这个 DB_TRX_ID 所在的旧记录就是当前事务能看见的最新老版本。
5 RC , RR的不同
5.1 ReadView生成
RC , RR 隔离级别的一个非常大的区别就是它们生成ReadView的时机不同。
RR下的ReadView
- 第一次创建readview后,这个readview就会一直持续到事务结束,也就是说在事务执行过程中,数据的可见性不会变,所以在事务内部不会出现不一致的情况
总结:当前事务过程中,区间[up_trx_id, low_trx_id]中的事务如果已经提交了,对当前事务是不可见的。
RC下的ReadView生成
- 事务中的每个查询语句都单独构建一个readview,所以如果两个查询之间有事务提交了,两个查询读出来的结果就不一样
总结:当前事务过程中,区间[up_trx_id, low_trx_id]中的事务如果已经提交了,对当前事务可见。
5.2 RC和RR隔离级别下的快照读和当前读:
- RC隔离级别下,快照读和当前读结果一样,都是读取已提交的最新;
- RR隔离级别下,当前读结果是其他事务已经提交的最新结果,快照读是读当前事务之前读到的结果。RR下创建快照读的时机决定了读到的版本。
5.3 解决幻读问题
对于快照读:通过MVCC来进行控制的,不用加锁。按照MVCC中规定的“语法”进行增删改查等操作,以避免幻读。
对于当前读:通过next-key锁(行锁+gap锁)来解决幻读问题。