10分钟理解MVCC并发版本控制原理+实践检验

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 logRead View。我们依次拆解这三个概念

隐式字段

mysql中,每条表记录除了用户自定义的字段外,还有数据库隐式增加的DB_TRX_ID(6字节),DB_ROLL_PTR(7字节),DB_ROW_ID(6字节)三个字段。虽然网上都是这么说,那到底是真的有吗?抱着探究的目的,我们看下mysql官方手册中的描述
mysql官方手册-8.0

顺带我们看看源码中的定义(详见: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在写写加锁的场景,是乐观锁还是悲观锁?如何确定的

六、参考资料

1. MVCC多版本并发控制

2. MySQL 8.0 Reference Manual

3. mysql-server changes_visible部分源码

4. MVCC原理探究及MySQL源码实现分析

5. 高性能MySQL(第三版.Baron.Scbwartz)

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 精致技术 设计师:CSDN官方博客 返回首页