什么是MVCC
全称Multi-Version Concurrency Control,即多版本并发控制,主要是为了提高数据库的并发性能。以下文章都是围绕InnoDB引擎来讲,因为myIsam不支持事务。
同一行数据平时发生读写请求时,会上锁阻塞住。但mvcc用更好的方式去处理读—写请求,做到在发生读—写请求冲突时不用加锁。
这个读是指的快照读,而不是当前读,当前读是一种加锁操作,是悲观锁。
那它到底是怎么做到读—写不用加锁的,快照读和当前读又是什么鬼,跟着你们的贴心老哥,继续往下看。
那你可能问为什么要拍个“快照”,也就是MVCC机制?
还记得事务的一大特性就是隔离性,一共有4个隔离级别,读未提交,读已提交,可重复读,串行化。
以MySQL InnoDB 引擎的默认隔离级别可重复读为例,可重复读指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的。
关于事务的基本特性请移步一文带你理解MySQL事务核心知识点
为了保证事务启动到结束整个生命周期看到的数据是一致的, 一般有两种方案:
- MySQL对数据“读-写”的时候,加锁,其他事务写这条数据时加上锁,其他事务读取的时候阻塞。
- MySQL可以对事务启动的时候,对数据库拍个“快照”,那么事务运行过程中读取都从这个快照读取,不也是保证数据一致么。
第一种方案存在明显的问题,加锁会引发阻塞,从而降低数据库性能。而MySQL设计者们采用第二种,也就是大名鼎鼎的MVCC,它不仅能够解决不可重复读,还一定程度解决幻读的问题,因为你整个数据库快照都有了,你就知道那个时刻的数据了。
虽然说SQL标准定义中可重复读隔离级别下会存在幻读的现象,但是不同的数据库厂商可以基于SQL标准下有不同的实现,那么不同隔离级别下发生的现象也会有出入,就拿MySQL的可重复读隔离级别就可以一定程度保证幻读。
小结一下:
MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突 ,做到即使有读写冲突时,也能做到不加锁 , 非阻塞并发读,而这个读指的就是快照读 , 而非当前读。
什么是快照读和当前读?
前面提到了快照读和当前读,这又有什么不一样呢,什么样的sql语句算是快照读,什么样的又算是当前读呢?
快照读
快照读又叫普通读,也就是利用MVCC机制读取快照中的数据。不加锁的简单的SELECT 都属于快照读,比如这样:
SELECT * FROM user WHERE ...
- 快照读是基于MVCC实现的,提高了并发的性能,降低开销
- 大部分业务代码中的读取都属于快照读
当前读
当前读读取的是记录的最新版本,读取时会对读取的记录进行加锁, 其他事务就有可能阻塞。加锁的 SELECT,或者对数据进行增删改都会进行当前读。比如:
SELECT * FROM user LOCK IN SHARE MODE; # 共享锁
SELECT * FROM user FOR UPDATE; # 排他锁
INSERT INTO user values ... # 排他锁
DELETE FROM user WHERE ... # 排他锁
UPDATE user SET ... # 排他锁
- update、delete、insert语句虽然没有select, 但是它们也会先进行读取,而且只能读取最新版本。
它读取的数据库记录,都是当前最新的版本,会对当前读取的数据进行加锁,防止其他事务修改数据。是悲观锁的一种操作。
如下操作都是当前读:
- select lock in share mode (共享锁)
- select for update (排他锁)
- update (排他锁)
- insert (排他锁)
- delete (排他锁)
- 串行化事务隔离级别
快照读的实现是基于多版本并发控制,即MVCC,既然是多版本,那么快照读读到的数据不一定是当前最新的数据,有可能是之前历史版本的数据。
如下操作是快照读:
- 不加锁的select操作(注:事务级别不是串行化)
快照读与mvcc的关系
MVCCC是“维持一个数据的多个版本,使读写操作没有冲突”的一个抽象概念。
这个概念需要具体功能去实现,这个具体实现就是快照读。(具体实现下面讲)
听完贴心老哥的讲解,是不是瞬间茅塞顿开。
数据库并发场景
- 读-读:不存在任何问题,也不需要并发控制
- 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
- 写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失
MVCC机制是咋工作的呢?
前面打个比方说MVCC机制相当于是基于整个数据库“拍了个快照”,这时,你会说这看上去不太现实啊。如果一个库有 100G,那么我启动一个事务,MySQL 就要保存 100G 的数据出来,这个过程得多慢啊,而且也很占用空间啊,根本就不能支持几个事务啊。别急,我们现在来讲解下MVCC机制是如何工作的。
数据的多个版本
首先MySQL innoDB存储引擎需要支持一条数据可以保留多个历史版本。怎么保留呢?还记得事务日志undo log吗?
undo log保存了数据的各个历史版本,用于数据的回滚,保证事务的一致性。详情查看详解MySQL事务日志——undo log
对于使用 InnoDB 存储引擎的数据库表,它的聚簇索引记录中都包含下面两个隐藏列:
- trx_id,当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里;
- roll_pointer,每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版本记录,于是就可以通过它找到修改前的记录。
InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。
如上图所示,针对id=1的这条数据,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称之为版本链,根据版本链就可以找到这条数据历史的版本。
一致性视图ReadView
利用undo log日志我们已经保留下了数据的各个版本,那么现在关键的问题是要读取哪个版本的数据呢?
这时就需要用到ReadView了,ReadView就是事务在使用MVCC机制进行快照读操作时产生的一致性视图, 比如针对可重复读隔离级别,是在事务启动的时候,创建一个ReadView, 那ReadView种都有哪些关键信息呢?
- trx_ids: 指的是在创建 ReadView 时,当前数据库中「活跃事务」的事务 id 列表,注意是一个列表, “活跃事务”指的就是,启动了但还没提交的事务。
- min_trx_id: 指的是在创建 ReadView 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。
- max_trx_id:这个并不是 m_ids 的最大值,而是创建 ReadView 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1;
- creator_trx_id :指的是创建该 ReadView 的事务的事务 id, 只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为 事务分配事务id,否则在一个只读事务中的事务id值都默认为0。
对于当前事务的启动瞬间来说,读取的一个数据版本的trx_id,有以下几种可能:
- 如果被访问版本的trx_id属性值与ReadView中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
- 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
- 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
- 如果落在黄色部分,那就包括两种情况
- 若 数据的trx_id在trx_ids数组中,表示这个版本是由还没提交的事务生成的,不可见, 去读取这条数据的历史版本,这条数据的历史版本中都包含了事务id信息,去查找第一个不在活跃事务数组的版本记录。 若 数据的trx_id不在trx_ids数组中,表示这个版本是已经提交了的事务生成的,可见。
这种通过版本链 + 一致性视图 来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制),现在你明白MySQL如何实现了“秒级创建快照”的能力了吧。
还是不懂?举例说明
如果你对MVCC机制的整个流程还是比较模糊,我们现在举例来说明下。
比如student表中有一个事务id为8的插入记录:
insert into student(id, name, class) values(1, '张三', '一班')
我们现在在MySQL的读已提交和可重复读隔离级别下,MVCC机制的整个工作流程。
MySQL中的读未提交和序列化并不需要MVCC机制,读未提交,直接读取别人未提交的数据,而序列化全程用加锁的方式,也用不上MVCC, 大家体会下。
可重复读隔离级别下
可重复读REPEATABLE READ 隔离级别的事务来说,只会在第一次执行查询语句时生成一个 ReadView ,之后的查询就不会重复生成了。
begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。
事务10 | 事务20 | 事务30 |
beginUPDATE student SET name="李四" WHERE id=1;UPDATE student SET name="王五" WHERE id=1; | ||
begin更新了一些其他表的数据 | ||
beginSELECT * FROM student WHERE id = 1; |
事务10和20均未提交,现在事务30执行select, 那么得到的结果是什么呢?
- 在执行select语句时会先生成一个ReadView,ReadView的trx_ids列表的内容就是[10, 20],min_trx_id为10,max_trx_id为21,creator_trx_id为0。
- 然后从版本链中挑选可见的记录,从图中看出,最新版本的列name的内容是'王五',该版本的trx_id值为10,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
- 下一个版本的列name的内容是'李四',该版本的trx_id值也为10,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本。
- 下一个版本的列name的内容是'张三',该版本的trx_id值为8,小于ReadView中的min_trx_id值10,说明已经提交了,那么最终返回'张三'。
读已提交隔离级别下
读已提交READ COMMITTED是每次读取数据前都生成一个ReadView。基本的规则和流程与可重复读隔离级别一致,这里不做重复赘叙。
undo日志
Undo log 主要用于记录数据被修改之前的日志,在表信息修改之前先会把数据拷贝到undo log里。
当事务进行回滚时可以通过undo log 里的日志进行数据还原。
Undo log 的用途
- 保证事务进行rollback时的原子性和一致性,当事务进行回滚的时候可以用undo log的数据进行恢复。
- 用于MVCC快照读的数据,在MVCC多版本控制中,通过读取undo log的历史版本数据可以实现不同事务版本号都拥有自己独立的快照数据版本。
undo log主要分为两种:
- insert undo log
- 代表事务在insert新记录时产生的undo log , 只在事务回滚时需要,并且在事务提交后可以被立即丢弃
- update undo log(主要)
- 事务在进行update或delete时产生的undo log ; 不仅在事务回滚时需要,在快照读时也需要;
- 所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除
Read View(读视图)
事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照。
记录并维护系统当前活跃事务的ID(没有commit,当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以越新的事务,ID值越大),是系统中当前不应该被本事务看到的其他事务id列表。
Read View主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。
Read View几个属性
- trx_ids: 当前系统活跃(未提交)事务版本号集合。
- low_limit_id: 创建当前read view 时“当前系统最大事务版本号+1”。
- up_limit_id: 创建当前read view 时“系统正处于活跃事务最小版本号”
- creator_trx_id: 创建当前read view的事务版本号;
Read View可见性判断条件
- db_trx_id < up_limit_id || db_trx_id == creator_trx_id(显示)
- 如果数据事务ID小于read view中的最小活跃事务ID,则可以肯定该数据是在当前事务启之前就已经存在了的,所以可以显示。
- 或者数据的事务ID等于creator_trx_id ,那么说明这个数据就是当前事务自己生成的,自己生成的数据自己当然能看见,所以这种情况下此数据也是可以显示的。
- db_trx_id >= low_limit_id(不显示)
- 如果数据事务ID大于read view 中的当前系统的最大事务ID,则说明该数据是在当前read view 创建之后才产生的,所以数据不显示。如果小于则进入下一个判断
- db_trx_id是否在活跃事务(trx_ids)中
- 不存在:则说明read view产生的时候事务已经commit了,这种情况数据则可以显示。
- 已存在:则代表我Read View生成时刻,你这个事务还在活跃,还没有Commit,你修改的数据,我当前事务也是看不见的。
MVCC和事务隔离级别
上面所讲的Read View用于支持RC(Read Committed,读提交)和RR(Repeatable Read,可重复读)隔离级别的实现。
RR、RC生成时机
- RC隔离级别下,是每个快照读都会生成并获取最新的Read View;
- 而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View,之后的查询就不会重复生成了,所以一个事务的查询结果每次都是一样的。
解决幻读问题
- 快照读:通过MVCC来进行控制的,不用加锁。按照MVCC中规定的“语法”进行增删改查等操作,以避免幻读。
- 当前读:通过next-key锁(行锁+gap锁)来解决问题的。
RC、RR级别下的InnoDB快照读区别
- 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来,此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;
- 即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见
- 而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因
总结
MVCC(Multi-Version Concurrency Control)是一种数据库并发控制机制,用于处理数据库系统中多个事务同时对同一数据进行读写操作时的并发冲突问题。
在传统的并发控制机制中,如锁机制,当一个事务对某个数据对象进行修改时,会对该数据对象加锁,其他事务在访问该数据对象时需要等待锁释放。这种机制在高并发环境下容易导致性能瓶颈,因为所有事务必须依次等待锁的释放才能进行下一步操作。
MVCC机制通过为每个事务提供一个独立的数据版本来解决这个问题。每个数据对象都可以存在多个版本,每个版本都有一个时间戳,用于标识该版本的创建时间。当一个事务开始时,它会获得一个时间戳,该时间戳会用于决定它所能看到的数据版本。
在MVCC机制下,读操作不会阻塞写操作,也不会阻塞其他事务的读操作。读操作只会读取事务开始之前已经存在的数据版本,而写操作则会创建新的数据版本,并将新版本的时间戳设置为事务的时间戳。这样,读操作可以同时进行,因为它们不会看到正在进行的写操作的结果。
当一个事务提交时,它会检查它所使用的数据版本是否仍然有效(即没有被其他事务修改)。如果数据版本有效,则事务的修改生效;如果数据版本无效,则事务被回滚,并重新执行。
MVCC机制的优点是允许高度并发的读操作,提高了数据库系统的并发性能。它还提供了更好的隔离级别,允许事务并发执行而不会产生不一致的结果。然而,MVCC机制也增加了数据库系统的存储开销,因为需要维护多个数据版本。因此,在设计数据库系统时需要权衡存储开销和并发性能之间的关系。
本问重点介绍了MVCC机制,以及 MVCC 在 READ COMMITTD、 REPEATABLE READ 这两种隔离级别的事务在执行快照读操作时访问记录的版本链的过程。这样使不同事务的 读-写 、 写-读 操作并发执行,从而提升系统性能。
- READ COMMITTD 在每一次进行普通SELECT操作前都会生成一个ReadView
- REPEATABLE READ 只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。