MVCC
前提条件
- MySQL version : 5.7.17
- MySQL 存储引擎:InnoDB
引入原因
- 为保持数据一致性,最简单的做法就是加锁。但加锁对性能损耗太大,尤其是对于读操作多于写操作的系统来说,给读操作也加锁,对性能来说更是无谓的浪费。但如果不加锁,就很容易产生幻读。为解决以上两种情况,就采用MVCC(多版本并发控制),简单地说就是事务读取的是数据快照
- MVCC使得数据库读不会对数据加锁,select不会加锁,提高了数据库的并发处理能力
- 借助MVCC,数据库可以实现在RC(Read Committed 提交读),RR(Repeatable Read 可重复读)事务的隔离级别,用户可以查看当前数据的前一个或者前几个历史版本。保证了ACID中的I-隔离性。(解释:事务的隔离性,一个事务的操作如果影响了另一个事务,那么另一个事务就会撤回执行;而添加读锁(读锁即共享锁,共享锁禁止排它锁获取同一行的数据的锁);MVCC避免在读操作上加锁,避免了共享锁时而排它锁无法修改数据的情况)
- InnoDB 存储引擎在事务的隔离级别为未提交读、提交读时都会产生幻读;在可重复读的隔离级别下通过MVCC解决幻读问题
示例五:事务的隔离级别为可重复时不会发生幻读
示例十:事务的隔离级别为提交读时会发生幻读
概述
- MVCC(Multi Version Concurrency Control)多版本并发控制
- Multiversion two-phrase locking protocal(多版本两阶段封锁协议)
- 两阶段锁协议,整个事务分为两个阶段,前一个阶段为加锁,后一个阶段为解锁。在加锁阶段,事务只能加锁,也可以操作数据,但不能解锁,直到事务释放第一个锁,就进入解锁阶段,此过程中事务只能解锁,也可以操作数据,不能再加锁。两阶段锁协议使得事务具有较高的并发度,因为解锁不必发生在事务结尾。它的不足是没有解决死锁的问题,因为它在加锁阶段没有顺序要求。如两个事务分别申请了A, B锁,接着又申请对方的锁,此时进入死锁状态。
- InnoDB通过MVCC机制表示数据库某一时刻的查询快照,查询可以看该时刻之前提交的事务所做的改变,但是不能看到该时刻之后或者未提交事务所做的改变。但是,查询可以看到同一事务中之前语句所做的改变
- 存储引擎 InnoDB 支持 MVCC
- 一致性非阻塞读:在事务的隔离级别为可重复读,每次读取的都是事务开始时的数据快照,即读操作不加锁,所以是非阻塞读;而事务隔离级别为提交读时,可能会发生不可重复读的问题,一致性读只是对事务内部的读操作和它自己的快照而言的
实现原理
- InnoDB有两个非常重要的模块来实现MVCC,
- 一个是undo日志,用于记录数据的变化轨迹
- 一个是Readview,用于判断该session对哪些数据可见,哪些不可见
- 事务的隔离级别为可重复读时,mvcc提供基于某个时间的快照,使得对于事务看来,总是可以提供与事务开始时刻相一致的数据,而不管这个事务执行的时间有多长,故在不同事务看来,同一时刻看到的相同的行数据可能是不一样的,即:每一行数据会有多个版本数据(副本)
- 在Mysql中MVCC是在Innodb存储引擎中得到支持的,Innodb为每行记录都实现了三个隐藏字段
- 事务ID(DB_TRX_ID ):6字节,用来标识该行所属的事务的标识,每处理一个事务,其值自动+1;删除在内部被视为一个更新,其中行中的特殊位被设置为将其标记为已删除
- 回滚指针(DB_ROLL_PTR):7字节,回溯查找数据历史版本,用于指向该行修改前的上一个历史版本;指向写到rollback segment(回滚段)的一条undo log记录(update操作的话,记录update前的ROW值)
- 隐藏的ID:DB_ROW_ID: 6字节,该值随新行插入单调增加,当由innodb自动产生聚集索引时,聚集索引包括这个DB_ROW_ID的值,不然的话聚集索引中不包括这个值. 这个用于索引当中
- 如果表中有主键或合适的唯一索引, 也就是无法生成聚簇索引的时候, InnoDB会帮我们自动生成聚集索引, 但聚簇索引会使用DB_ROW_ID的值来作为主键; 如果我们有自己的主键或者合适的唯一索引, 那么聚簇索引中也就不会包含 DB_ROW_ID 了(举例:表中用字符串做主键)
- 聚集索引。表数据按照索引的顺序来存储的,也就是说索引项的顺序与表中记录的物理顺序一致。对于聚集索引,叶子结点即存储了真实的数据行,不再有另外单独的数据页。 在一张表上最多只能创建一个聚集索引,因为真实数据的物理顺序只能有一种
// MySQL InnoDB 存储引擎 表中每行记录会添加三个隐藏字段
dict_table_add_system_columns(
/*==========================*/
dict_table_t* table, /*!< in/out: table */
mem_heap_t* heap) /*!< in: temporary heap */
{
ut_ad(table);
ut_ad(table->n_def == (table->n_cols - table->get_n_sys_cols()));
ut_ad(table->magic_n == DICT_TABLE_MAGIC_N);
ut_ad(!table->cached);
/* NOTE: the system columns MUST be added in the following order
(so that they can be indexed by the numerical value of DATA_ROW_ID,
etc.) and as the last columns of the table memory object.
The clustered index will not always physically contain all system
columns.
Intrinsic table don't need DB_ROLL_PTR as UNDO logging is turned off
for these tables. */
dict_mem_table_add_col(table, heap, "DB_ROW_ID", DATA_SYS,
DATA_ROW_ID | DATA_NOT_NULL,
DATA_ROW_ID_LEN);
#if (DATA_ITT_N_SYS_COLS != 2)
#error "DATA_ITT_N_SYS_COLS != 2"
#endif
#if DATA_ROW_ID != 0
#error "DATA_ROW_ID != 0"
#endif
dict_mem_table_add_col(table, heap, "DB_TRX_ID", DATA_SYS,
DATA_TRX_ID | DATA_NOT_NULL,
DATA_TRX_ID_LEN);
#if DATA_TRX_ID != 1
#error "DATA_TRX_ID != 1"
#endif
if (!table->is_intrinsic()) {
dict_mem_table_add_col(table, heap, "DB_ROLL_PTR", DATA_SYS,
DATA_ROLL_PTR | DATA_NOT_NULL,
DATA_ROLL_PTR_LEN);
#if DATA_ROLL_PTR != 2
#error "DATA_ROLL_PTR != 2"
#endif
/* This check reminds that if a new system column is added to
the program, it should be dealt with here */
#if DATA_N_SYS_COLS != 3
#error "DATA_N_SYS_COLS != 3"
#endif
}
}
事务隔离级别与MVCC
- read uncommitted(读没有提交的数据):无法避免脏读;总是读取最新的数据行, 而不是符合当前事务版本的数据行;由于是读到未提交的,所以不存在版本的问题,无法应用MVCC;
- read committed(只能读提交的数据):其他事务对数据库的修改,只能已提交,其修改的结果可以看见,与这2个事务开始的先后顺序无关,这个级别避免脏读,无法实现可重复读,可能会产生幻读
- repeatable read(可重复读):只能读取在它开始之前提交事务对数据库的修改,在它开始之后,所有其他事务对数据库的修改对它来说均不可见
- serializable(串行):读写都加锁,不会出现多个版本
示例:不同事务的隔离级别与MVCC
不同的事务隔离级别情况下A1 A2的查询结果是不同的
时间片 | SESSION 1 | SESSION 2 |
---|---|---|
T1 | SELECT A FROM TEST; return A = 10; | - |
T2 | BEGIN ; | - |
T3 | UPDATE TEST SET A = 20 ; | - |
T4 | - | BEGIN; |
T5 | - | SELECT A FROM TEST; return A1 = ? ; |
T6 | COMMIT; | - |
T7 | - | SELECT A FROM TEST; return A2 = ? ; |
T8 | - | COMMIT; |
查询结果如下:
事务的隔离级别 | A1 | A2 |
---|---|---|
未提交读(READ-UNCOMMITTED) | 20 | 20 |
提交读(READ-COMMITTED) | 10 | 20 |
可重复读(REPEATABLE-READ) | 10 | 10 |
串行(SERIALIZABLE) | 10 | 10 |
- MVCC只在 READ COMMITTED 和 REPEATABLE READ 两个隔离级别下工作
- 并且根据 read view 的生成原则, 导致在这两个不同隔离级别下, read committed 总是读最新一份快照数据, 而repeatable read 读事务开始时的行数据版本;
- 使得 READ COMMITED 级别能够保证, 只要是当前语句执行前已经提交的数据都是可见的
- 使得 REPEATABLE READ 级别能够保证, 只要是当前事务执行前已经提交的数据都是可见的。
Read View
- Read View是事务开启时当前所有事务的一个集合,这个类中存储了当前Read View中最大事务ID及最小事务ID
- Read View 也就是事务视图,它用于控制数据的可见性
- 在InnoDB中,只有查询才需要通过Readview来控制可见性,对于DML等数据变更操作,如果操作了不可见的数据,则直接进入锁等待
- 判断当前版本数据项是否可见
- 在innodb中, 每创建一个新事务, 存储引擎都会将当前系统中的活跃事务列表(trx_sys->trx_list)创建一个副本(read view), 副本中保存的是系统中当前不应该被本事务看到的其他事务id列表
- 当用户在事务中要读取某行记录的时候, innodb会将该行当前的版本号与该read view进行比较
- 主要组成:
- ReadView::id 创建该视图的事务ID;
- ReadView::m_ids 创建ReadView时,活跃的读写事务ID数组,有序存储;
- ReadView::m_low_limit_id 设置为当前最大事务ID;
- ReadView::m_up_limit_id m_ids集合中的最小值,如果m_ids集合为空,表示当前没有活跃读写事务,则设置为当前最大事务ID。
- READ-COMMITTED (提交读):事务内的每个查询语句都会重新创建Read View,这样就会产生不可重复读现象发生
- REPEATABLE-READ (可重复读):事务内开始时创建Read View , 在事务结束这段时间内 每一次查询都不会重新重建Read View , 从而实现了可重复读。
当前数据行记录的可见性
- 聚集索引的可见性判断和二级索引的可见性判断略有不同。因为二级索引记录并没有存储事务ID信息,相应的,只是在数据页头存储了最近更新该page的trx_id
- 如果记录(记录指的是当前的查询结果中的行,每行记录InnoDB都隐藏了DB_TRX_ID 即事务ID)的trx_id小于ReadView::m_up_limit_id,则说明该事务在创建ReadView时已经提交了,肯定可见;
- 如果记录的trx_id大于等于ReadView::m_low_limit_id,则说明该事务是创建readview之后开启的,肯定不可见;
- 当trx_id在m_up_limit_id和m_low_limit_id之间时,如果在ReadView::m_ids数组中,说明创建readview时该事务是活跃的,其做的变更对当前视图不可见,否则对该trx_id的变更可见。
- 如果当前记录不可见,则判断该记录是否有上一个版本(DB_ROLL_PTR回滚指针的指向是否为空),判断前上一个版本的记录是否可见;
MVCC SQL-DML操作
行的插入更新过程
- 1.初始化数据
F1~F6是某行列的名字,1~6是其对应的数据。后面三个隐含字段分别对应该行的事务号和回滚指针,假如这条数据是刚INSERT的,可以认为ID为1,其他两个字段为空。
- 2.事务1更改该行的各字段的值
当事务1更改该行的值时,会进行如下操作:
用排他锁锁定该行
把该行修改前的值Copy到undo log,即上图中下面的行
修改当前行的值,填写事务编号,使回滚指针指向undo log中的修改前的行
记录redo log
- 3.事务2修改该行的值
与事务1相同,此时undo log,中有有两行记录,并且通过回滚指针连在一起。
因此,如果undo log一直不删除,则会通过当前记录的回滚指针回溯到该行创建时的初始内容,所幸的时在Innodb中存在purge线程,它会查询那些比现在最老的活动事务还早的undo log,并删除它们,从而保证undo log文件不至于无限增长。
优缺点
- 优点:在读取数据时,innodb几乎不用获取任何锁,在每个查询通过版本检查,只获取需要的数据版本,提高系统并发度
- 缺点:为了实现多版本,innodb必须对每行增加相应字段来存储版本信息,同时需要维护每一行的版本信息,而且在检索行的时候,需要进行版本的比较,因而减低了查询效率;innodb还需要定期清理不再需要的行版本,及时回收空间,这也增加开销;
参考资料
- MySQL事务隔离级别以及MVCC机制
- MySQL · 引擎特性 · InnoDB 事务子系统介绍
- MySQL MVCC研究
- MySQL InnoDB MVCC原理
- 两阶段锁协议
- MySQL-InnoDB-MVCC多版本并发控制
- 数据库事务特征、数据库隔离级别,各级别数据库加锁情况(含实操)–read committed && MVCC