10分钟理解MVCC并发版本控制原理+实践检验
文章目录
一、准备工作
1.1 先进入某个database(没有则创建),创建2张表,分别插入一条记录
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(200) NOT NULL DEFAULT '' COMMENT '用户名',
`ctime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`mtime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COMMENT='user表';
CREATE TABLE `demo` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(200) NOT NULL DEFAULT '' COMMENT '登录用户名',
`ctime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`mtime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COMMENT='demo表';
INSERT INTO `user` (`id`, `name`) VALUES ('1', 'wuhhh');
INSERT INTO `demo` (`id`, `name`) VALUES ('1', 'wuhhh');
1.2 顺序打开3个terminal,并登陆上mysql,进入到刚才的database
1.3 查看当前隔离级别
本机为8.x版本
//对应8.x版本MySQL
SELECT @@global.transaction_isolation;
//对应8.x以下版本MySQL(未经验证)
SELECT @@global.tx_isolation;
很显然,默认为REPEATABLE-READ级别
二、问题引入
按如下图所示的方式分别开启事务,注意时间序列
begin;
update user set name = 'tx-1' where id=1;
update demo set name = 'tx-3' where id=1;
select * from user where id = 1;
update user set name = 'tx-2' where id=1;
select * from demo where id = 1;
(注:为方便文章阅读者在自己本机上验证copy sql,以上为图片中所涉及到的sql)
说明:事务开始时间:事务1<事务2<事务3
思考1:请问在执行到第9条sql时,我们得到的记录是怎样的呢?拿到的id为1的那条记录,name值是否为tx-1呢?
…
可以观察到,在事务二中,不论是执行第6步还是第9步,我们看到的id为1的那条记录,均是刚开始初始化进去的。对于第6步,我们看到id=1的那条记录,name='wuahhh’在某种程度上可以理解,毕竟事务1里对该条记录的修改尚未提交,但在事务1提交后,在事务2里检索,依然看不到被事务1更新的值,这是为什么呢?
思考2:假如我们移除事务2中的步骤6,也就是在事务1提交之前不执行select,那么结果会不会一样呢?如图(读者可自行检验)
…
看到如此的结果是否开始迷惘了呢?
明明是RR隔离级别,且属于不同的事务,仅改变了select的执行顺序,为什么一个能看到事务1更新的值,一个又看不到呢?
三、深入分析
3.1 什么是mvcc
MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存
MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读
3.2 mvcc解决了什么问题
数据库并发场景有三种,分别为:
- 读-读:不存在任何问题,也不需要并发控制
- 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
- 写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失
多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。 所以MVCC可以为数据库解决以下问题
- 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
- 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题,也就是说mvcc机制,在针对同一条记录的写写场景时,依然是阻塞的
3.2 mvcc实现原理
在高性能mysql一书中,简单阐述了innodb引擎中mvcc的实现,但不够详细(详见:[高性能MySQL第一章13页])
而经多方查找资料可知,mvcc在mysql innodb引擎中的实现,实际主要依赖三大核心,隐式字段,undo log,Read View。我们依次拆解这三个概念
隐式字段
mysql中,每条表记录除了用户自定义的字段外,还有数据库隐式增加的DB_TRX_ID(6字节),DB_ROLL_PTR(7字节),DB_ROW_ID(6字节)三个字段。虽然网上都是这么说,那到底是真的有吗?抱着探究的目的,我们看下mysql官方手册中的描述
顺带我们看看源码中的定义(详见:https://github.com/mysql/mysql-server/blob/7ed30a748964c009d4909cb8b4b22036ebdef239/storage/innobase/dict/dict0dict.cc)
/* 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, false);
dict_mem_table_add_col(table, heap, "DB_TRX_ID", DATA_SYS,
DATA_TRX_ID | DATA_NOT_NULL, DATA_TRX_ID_LEN, false);
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,
false);
/* This check reminds that if a new system column is added to
the program, it should be dealt with here */
}
}
隐式字段的作用
-
DB_TRX_ID
6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID
-
DB_ROLL_PTR
7byte,回滚指针,指向这条记录的上一个版本(存储于rollback segment里)
-
DB_ROW_ID
6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引
如上图,DB_ROW_ID是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID是当前操作该记录的事务ID,而DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本
undo log
undo log主要分为两种:
- insert undo log
代表事务在insert新记录时产生的undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃 - update undo log
事务在进行update或delete时产生的undo log; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除
拓展:
为了节省磁盘空间,InnoDB有专门的purge线程来清理deleted_bit为true的记录。为了不影响MVCC的正常工作,purge线程自己也维护了一个read view(这个read view相当于系统中最老活跃事务的read view);如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的
对MVCC有帮助的实质是update undo log ,undo log实际上就是存在rollback segment中旧记录链,用图来演示如下:
从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本的链表,undo log的链首就是最新的记录,链尾就是最早的记录
Read View
什么是Read View,说白了Read View就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)
所以我们知道 Read View主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。
Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出undo log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本
那么这个判断条件是什么呢?
我们看下源码(详见:https://github.com/mysql/mysql-server/blob/ee4455a33b10f1b1886044322e4893f587b319ed/storage/innobase/include/read0types.h):
/** Check whether the changes by id are visible.
@param[in] id transaction id to check against the view
@param[in] name table name
@return whether the view sees the modifications of id. */
bool changes_visible(trx_id_t id, const table_name_t &name) const
MY_ATTRIBUTE((warn_unused_result)) {
ut_ad(id > 0);
if (id < m_up_limit_id || id == m_creator_trx_id) {
return (true);
}
check_trx_id_sanity(id, name);
if (id >= m_low_limit_id) {
return (false);
} else if (m_ids.empty()) {
return (true);
}
const ids_t::value_type *p = m_ids.data();
//如果搜索到,即在当前活跃事务,则不可见,如果不在,则可见
return (!std::binary_search(p, p + m_ids.size(), id));
}
up_limit_id:记录trx_list列表中事务ID最小的ID
low_limit_id:ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
很显然,比较的逻辑是:
-
首先比较DB_TRX_ID < up_limit_id (或里面判断的是否为当前事务id,比较容易理解), 如果小于,则说明在当前事务开始之前就已提交的,因此当前事务能看到DB_TRX_ID 所在的记录,如果大于等于进入下一个判断
-
接下来判断 DB_TRX_ID 大于等于 low_limit_id , 如果大于等于则代表DB_TRX_ID 所在的记录在Read View生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断
-
判断DB_TRX_ID是否在活跃事务之中,m_ids.contains(DB_TRX_ID),如果在,则代表我Read View生成时刻,你这个事务还在活跃,还没有Commit,你修改的数据,我当前事务也是看不见的;如果不在,则说明,你这个事务在Read View生成之前就已经Commit了,你修改的结果,我当前事务是能看见的
3.3 完整流程
四、总结
回到当初我们引入的问题,事务1提交后,事务2执行第一次查询能获取到值,之所以有这样的现象,是因为在RR级别下,某个事务的对某条记录的第一次快照读时会创建一个快照读和Read View,此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见。而在其他事务提交更新之后第一次使用快照读,那么就等同于在Read View创建的之前的事务,因此所做的修改是可见的
综合来说:是Read View生成时机的不同,从而造成RR级别下快照读的结果的不同。当然RC级别下同理
结论:在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。
五、不足待继续深入的地方
1、在什么地方能确认在RR级别下的某个事务的对某条记录的第一次快照读,与后来的快照读使用的是同一个Read View?须待熟悉的大佬研读源码解答
2、如何确定在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View?
3、mvcc在写写加锁的场景,是乐观锁还是悲观锁?如何确定的