1. 概述
本文只是复习数据库理论知识,对于MySQL的具体语法不做复习,其实语法没什么可复习的,增删改查,注意连接查询。
数据库可以看成是数据的集合,而数据库管理系统则是为了方便高效地对外提供服务,比如高效地增删改查。
增删改查的高效涉及到多方面,比如逻辑上数据库的设计范式、字段以及约束的设置;比如底层索引、数据结构以及存储引擎的设置。另外,数据库是对外提供服务的,不可避免地涉及到多线程,这时候就需要事务和锁机制。
2. 字段约束
数据表中有很多字段,为了更好地规范统一数据以及方便标识数据,可对字段添加约束。简单地说,约束就相当于限制条件。限制条件很容易理解,比如说,限制这个字段不为空,那么插入数据的时候,必须插入值,或者设置默认数据。比如限制这个字段值是唯一的,即所有的记录中都是唯一的,不可重复。
常见的约束有如下几个:
- 非空约束:字段值不为空
- 唯一性约束:字段值必须是唯一的(允许为空Null)
- 主键约束:唯一地标识这个记录,即唯一性约束和非空约束的复合
- 外键约束:该字段是某个表中具有唯一性约束的字段(不一定是主键)
- 检查约束:
注意,设置外键之后,两张表之间就有了关系,具有外键字段的表称为子表,被引用的表称为父表。显然如果要删除父表,那么该表中的字段不能被其他表引用,所以必须要先删除子表,再删除父表;或者解除外键关系。
字段约束,可以看成是对具体数据的规范,方便规范数据,关联数据等。
3. 设计范式
除了单个数据字段的约束,整张数据表的字段设计也是有要求的,即范式。比如不同字段之间不能有关联(依赖),即字段A是月薪,字段B是年薪,显然这是不合理的,B完全可以由A计算得到。
在描述具体的范式之前,先说明几个概念:
- 依赖:如果确定了A,就可以确定B,此时就称为A决定B,或者B依赖A。
- 部分依赖:如果A中的某个子集确定之后,也可以确定B,那么就称为部分依赖。
- 完全依赖:如果A中没有子集可以确定B,那么就称为完全依赖。
- 传递依赖:A确定B,B确定C,此时A确定C就是一个传递依赖。
具体的设计范式有如下三种:
第一范式
必须有主键,每一个字段原子性不可再分。
第二范式
在第一范式的基础上,所有非主键字段,完全依赖主键,不要产生部分依赖。即主键没有冗余。
第三范式
在第二范式的基础上,所有非主键字段,直接依赖主键,不传递依赖。即主键直接和所有非主键直接依赖。
设计范式,可以看成是对数据表的规范,避免数据冗余、空间浪费。
不过设计范式是对空间的优化,有时候需要追求响应速度,显然可以不严格按照设计范式来做。
4. 索引和存储引擎
注意,这里的存储引擎,只是在MySQL中有,其他的数据库则不一定有。
MySQL底层采用的是B+Tree。注意B+Tree,B-Tree和红黑树三者之间的区别。
上面的字段约束和设计范式都是从数据逻辑层面上的规定。除了逻辑上,底层具体存储也是有要求的。
比如如何存储,使得查询效率高、插入效率高、删除效率高等?
其实这里的存储,指的是存储数据的内存地址,只要找到内存地址就可以很方便地存储数据了。
那么换句话说,检索数据,其实就是检索每一条记录的内存地址。那么通过什么检索内存地址呢?通过每一条记录的主键?这里引入索引的概念,即类似汉语字典中的拼音首字母。
因此,从外在形式上,根据某个条件查找某条记录,本质上是根据这条记录的索引来查找其内存地址。
4.1 索引
MYSQL的索引和存储引擎_TimeFriends的博客-CSDN博客_存储引擎和索引
索引是通过某种算法,构建出一个数据模型,用于快速查出在某个列中有一特定值的行,不使用索引,MYSQL必须从第一行记录开始读完整个表,直到找出相关的行,表越大,查询数据所花费的时间就越多,如果表中查询的列有一个索引,MYSQL能够快速到达一个位置去搜索数据文件,而不必查看所有数据,那么将会节省很大一部分时间.
索引类似一本书的目录,比如要查找student这个单词,可以先找到s开头的页然后向后查找,这个就类似索引.
索引相当于一本书的目录,是为了缩小扫描范围而存在的一种机制。索引是在数据库表的字段上添加的,是为了提高查询效率存在的一种机制。一张表的一个字段可以添加一个索引,当然,多个字段联合起来也可以添加索引。
1 | select * from t_user where name = 'jack'; |
上面的这条语句会从t_user表中的 name 字段扫描,因为指明了name字段。然后如果name字段中的数据有索引,那么就根据索引进行匹配 jack,如果没有,那么就会进行全扫描(全部数据扫描,不是全字段扫描),将 name 字段上的每个值都比对一遍,效率比较低。
在任何数据库当中主键上都会自动添加索引对象,在MySQL中,一个字段上如果有unique约束的话,也会自动创建索引。
除了上述在unique约束的字段上自动创建索引,什么时候会考虑给字段添加索引呢?
- 数据量庞大(到底多少算庞大,这个需要测试,因为每个硬件环境不同)。
- 该字段经常出现在where的后面,以条件的形式存在,也就是这个字段经常被扫描。
- 该字段有很少的DML(insert、delete、update)操作,因为DML操作后,索引需要重新排序(比如二叉树)。
注意,建议不要随意添加索引,因为索引也是需要维护的,太多的haul反而会降低系统的性能,建议通过主键查询,通过unique约束的字段进行查询,效率是比较高的。
有了索引,其实就是将某个字段(作为索引)和其内存地址进行关联,封装成一个对象。将这些对象以某种数据结构存储,这样,在匹配具体索引值的时候,可高效查找到具体的对象,然后,获取到其内存地址。
索引的数据结构有二叉树、红黑树、B树、B+树、哈希表。
4.2 存储引擎
MySQL支持的存储引擎有:MyISAM、InnoDB、MEMORY。
5. 事务、锁机制、MVCC
5.1 事务
这里引入事务的概念。事务指的是完成某个业务的一组操作。比如转账操作,先从某个账户划走一笔钱,然后在另一个账户新增一笔钱,因为涉及到两次表操作,所以必须保证这两个操作都成功,否则就会出现一个失败,一个成功,显然总金额无法保证。
因此事务有以下性质ACID:
原子性(Atomicity)
事务被视为不可分割的最小单元,要么成功要么失败,即事务中的所有操作要么全部成功(提交),要么全部失败(回滚成功的)。
一致性(Consistency)
即在事务操作的时候,数据库的状态要保持不变,即当前事务在操作数据库时不允许其他事务其他操作对当前数据库的状态产生影响。否则会影响当前事务的操作。
隔离性(Isolation)
一个事务在操作过程中(在最终提交之前)对其他事务是不可见的,即不同事务之间是隔离的。
持久性(Durability)
事务一定提交之后,所做的修改是持久的,永远保存在数据库中。
注意,事务的 ACID 特性概念简单,但不是很好理解,主要是因为这几个特性不是一种平级关系:
- 只有满足一致性,事务的执行结果才是正确的。
- 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。
- 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
- 事务满足持久化是为了能应对系统崩溃的情况。
5.2 并发一致性问题
上面提到,在并发环境下,如果隔离性无法保证,那么一致性也将无法保证,会出现以下几个问题:
丢失修改(修改覆盖)
T1事务和T2事务同时对数据进行修改,T1和T2同时读到原始数据,然后分别修改数据,最终提交的数据只能是其中一个事务修改后的数据,显然二者的修改有一个丢失了。比如银行卡(10000元)购物,两个人T1、T2,分别消费1000、2000元,最终只有一个生效,即最终要么是9000、要么是8000,显然丢失了其中一个修改。
正常情况下,应该是一个事务完成之后,另一个事务再执行,即事务之间有隔离性。
读脏数据
读脏数据指的是,T2在读取数据的时候,读取到了T1过程中的某个数据,并不是T1最终的结果。
不可重复读
不可重复读,和读脏数据类似,T2需要读取多次数据,而期间T1会修改数据,显然无法保证T2每次读取到的数据都是一样的。
幻影读
幻读本质上也属于不可重复读的情况,T1 读取某个范围的数据,T2 在这个范围内插入新的数据,T1 再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。【和不可重复读类似,不可重复读是改变了数据值,而幻读则是插入删除了记录条数。】
产生并发不一致性问题的主要原因是破坏了事务的隔离性,解决方法是通过并发控制来保证隔离性。并发控制可以通过封锁来实现,但是封锁操作需要用户自己控制,相当复杂。数据库管理系统提供了事务的隔离级别,让用户以一种更轻松的方式处理并发一致性问题。
5.3 隔离级别
因为,为了保证隔离性,从而保证一致性。针对上述问题,设置了事务之间的隔离级别。事务的隔离程度依次递增:
读未提交(read uncommitted)
最低的隔离级别,事务A读取到了事务B未提交的数据。即读脏数据,一般情况下不会设置这个隔离级别,都是二挡起步。
读已提交(read committed)
事务A只能读取事务B提交后的数据【即事务B过程中的数据是读取不到的】。但是在一段时间内,事务A可能会连续读,而事务B也会连续发生多次,事务A每次读取到的都不一样,此时就会发生新的不可重复读。
换句话说,类似:读锁不会进入写锁,而写锁,则会进入读锁【即写锁的优先级高】。即这个隔离级别只保证了写锁不会被干扰,从而保证了读已提交。
实际上,读已提交采用的是MVCC机制实现的。
可重复读(repeatable read)
保证在同一个事务中多次读取同一数据的结果是一样的。但是又不会限制写锁,这样其实就是相当于一个备份,每次读取都是备份数据。这样就会出现幻读,有滞后性。MySQL默认的就是这个。
实际上,可重复读采用MVCC机制+锁实现的。
序列化/串行化(serializable)
强制事务串行执行,这样多个事务互不干扰,不会出现并发一致性问题。该隔离级别需要加锁实现,因为要使用加锁机制保证同一时间只有一个事务执行,也就是保证事务串行执行。
实际上,串行化是通过对所有操作加锁实现的。
5.4 锁机制
这里详细介绍锁机制,解决并发一致性问题。数据本就是一种共享资源,因此为了保证数据安全,可加锁进行控制。
5.4.1 锁分类
从性能上说,分为:
- 乐观锁
- 悲观锁
从数据操作类型上说,分为:
- 读锁
- 写锁
- 意向锁:
- 意向读锁
- 意向写锁
从数据操作粒度上说,分为:
- 行锁
- 页锁
- 表锁
5.4.2 乐观锁和悲观锁
悲观锁是基于一种悲观的态度类来防止一切数据冲突,它是以一种预防的姿态在修改数据之前把数据锁住,然后再对数据进行读写,在它释放锁之前任何人都不能对其数据进行操作,直到前面一个人把锁释放后下一个人数据加锁才可对数据进行加锁,然后才可以对数据进行操作,一般数据库本身锁的机制都是基于悲观锁的机制实现的。
乐观锁是对于数据冲突保持一种乐观态度,操作数据时不会对操作的数据进行加锁(这使得多个任务可以并行的对数据进行操作),只有到数据提交的时候才通过一种机制来验证数据是否存在冲突(一般实现方式是通过加版本号然后进行版本号的对比方式实现)。
乐观锁是一种并发类型的锁,其本身不对数据进行加锁通而是通过业务实现锁的功能,不对数据进行加锁就意味着允许多个请求同时访问数据,同时也省掉了对数据加锁和解锁的过程,这种方式因为节省了悲观锁加锁的操作,所以可以一定程度的的提高操作的性能.
不过在并发非常高的情况下,会导致大量的请求冲突,冲突导致大部分操作无功而返而浪费资源,所以在高并发的场景下,乐观锁的性能却反而不如悲观锁。
换句话说,乐观锁就是不加锁,减少了加锁操作,在提交的时候验证版本号,如果版本号一样,那么就更新数据,如果版本号不一样,那么就更改失败。
乐观锁有两种方法实现:
版本号
1
update A set name='lisi', version=version+1 where id=\#{id} and version=\#{version}
即比较版本号,如果版本号没有修改过,那么就说明当前的更新操作没有被其他操作干扰,即此时是单线程的,直接修改并更新版本号即可。
CAS算法
三个参数,一个当前内存的值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。感觉和版本号类似,都是判断是否改变过旧值。
但是CAS算法有一个缺点,就是在此期间,如果另一个线程,将预期值改为B,然后又修改为A。虽然从结果上看仍然是A,但是实际上已经被修改过了。这里需要注意。
5.4.3 读锁、写锁和意向锁
读锁属于共享锁(Shared),针对同一份数据,多个读操作可以同时进行而不会相互影响,而对于写操作,则会进行阻塞。即加了读锁之后可以再加读锁,但不能加写锁。
写锁属于排他锁(eXclusive),当前事物的写操作没有完成前,其他事务不能对锁定行加任何锁,加锁会被阻塞。即加了写锁之后,任何锁都不能再加。
读锁和写锁是行级锁,属于细粒度,如果想要对整张表加读写锁该怎么办呢?
在存在行级锁和表级锁的情况下,事务 T 想要对表 A 加 X 锁,就需要先检测是否有其它事务对表 A 或者表 A 中的任意一行加了锁,那么就需要对表 A 的每一行都检测一次,这是非常耗时的。
使用意向锁(Intention Locks)可以更容易地支持多粒度封锁。
在Innodb存储引擎中,意向锁是一个表级别的锁,而它又分为两种:
- 意向共享锁(IS Lock):事务想要获得一张表中某几行的共享锁
- 意向排它锁(IX Lock):事务想要获得一张表中某几行的排它锁
Innodb引擎支持的是行级别的锁,因此意向锁虽然是表锁,但它并不会阻塞除全表扫描以外其他的任何请求。
意向锁在原来的 X/S 锁之上引入了 IX/IS,IX/IS 都是表锁,用来表示一个事务想要在表中的某个数据行上加 X 锁或 S 锁。有以下两个规定:
- 一个事务在获得某个数据行对象的 S 锁之前,必须先获得表的 IS 锁或者更强的锁;
- 一个事务在获得某个数据行对象的 X 锁之前,必须先获得表的 IX 锁。
通过引入意向锁,事务 T 想要对表 A 加 X 锁,只需要先检测是否有其它事务对表 A 加了 X/IX/S/IS 锁,如果加了就表示有其它事务正在使用这个表或者表中某一行的锁,因此事务 T 加 X 锁失败。
解释如下:
- 任意 IS/IX 锁之间都是兼容的,因为它们只表示想要对表加锁,而不是真正加锁;
- 这里兼容关系针对的是表级锁,而表级的 IX 锁和行级的 X 锁兼容,两个事务可以对两个数据行加 X 锁。(事务 T1 想要对数据行 R1 加 X 锁,事务 T2 想要对同一个表的数据行 R2 加 X 锁,两个事务都需要对该表加 IX 锁,但是 IX 锁是兼容的,并且 IX 锁与行级的 X 锁也是兼容的,因此两个事务都能加锁成功,对同一个表中的两个数据行做修改。)
5.4.4 行锁、页锁和表锁
行锁:每次操作锁定一行数据。开销大,加锁慢;会出现死锁;但是锁粒度最小,发生锁冲突的概率最低,并发度最高。
页锁:在意向锁中体现页锁的概念,在意向锁中,如果对一个记录进行加锁,则需要对粗粒度的对象进行加锁,依次是数据库加锁、数据表加锁、数据页加锁和记录加锁。但Innodb的意向锁实现比较简介,其意向锁即为表级别的锁。
表锁:每次操作需要锁住整张表,开销小,加锁快;不会出现死锁,锁粒度大,发生冲突的概率最高,并发度最低;一般用在数据迁移的场景。
5.4.5 记录锁、间隙所和临建锁
- 记录锁:锁的是一条记录上的索引
- 间隙锁:锁的是某个间隙,也就是记录之间的间隙,防止插入。
- 临建锁:即锁索引,也锁间隙,即上面二者的结合。
通过MVCC+临建锁,这样就可以解决幻读问题。
5.4.6 加锁协议
这里简单说一下上面加锁的过程,
三级封锁协议用于解决修改丢失、不可重复读和读脏数据问题,解决这些问题的焦点是给数据库对象何时加锁、加什么样的锁。
三级加锁协议
- 一级加锁协议:事务T在修改数据R之前必须对其加X锁,解决修改丢失问题。
- 二级封锁协议:在一级封锁协议的基础上,事务T在读取数据R前,必须对其加S锁,读完后即可释放,解决不可重复读问题。
- 三级封锁协议:在一级封锁协议的基础上,事务T在读取数据R前,必须对其加S锁,直到事务结束方可释放。解决读脏数据问题。
三级加锁协议并不能保证并发操作下事务最终的执行结果和这些事务串行的某个执行结果一致(如有事务A和事务B,串行先执行A,执行完成后再执行B,或者串行先执行B,执行完成后再执行A,两次结果中的某一次结果一致即可)
两段锁协议,用于解决事务调度问题。
两段锁协议
将加锁和锁的释放分为两个阶段,加锁阶段只加锁不释放,只要一个锁开始释放,进入释放阶段,只释放,不加锁,两段锁协议是确保执行结果和这些事务串行的某个执行一致的充分条件。
5.4.7 总结
总体上看,可以下分类:
5.5 MVCC机制
多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现读已提交和可重复读这两种隔离级别。而读未提交隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。
多版本并发控制,顾名思义,就是多个版本,读和写,针对不同的版本进行控制,读操作针对的旧版本,写操作针对的最新版本。这样来看,读写操作就不互斥了,而读读操作本来就不互斥。
在 MVCC 中事务的修改操作(DELETE、INSERT、UPDATE)会为数据行新增一个版本快照。脏读和不可重复读最根本的原因是事务读取到其它事务未提交的修改。在事务进行读取操作时,为了解决脏读和不可重复读问题,MVCC 规定只能读取已经提交的快照。当然一个事务可以读取自身未提交的快照,这不算是脏读。
5.5.0 版本号
MVCC机制有两个版本号:
- 系统版本号 SYS_ID:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
- 事务版本号 TRX_ID :事务开始时的系统版本号。
5.5.1 Undo日志
Undo日志可以理解成回滚日志,它存储的是老版本数据。MVCC 的多版本指的是多个版本的快照,快照存储在 Undo 日志中,该日志通过回滚指针 ROLL_PTR 把一个数据行的所有快照连接起来。
在表记录修改之前,会先把原始数据拷贝到undo log里,如果事务回滚,即可以通过undo log来还原数据。或者如果当前记录行不可见【即读操作】,可以顺着undo log链找到满足其可见性条件的记录行版本。
例如在 MySQL 创建一个表 t,包含主键 id 和一个字段 x。我们先插入一个数据行,然后对该数据行执行两次更新操作。
1 | INSERT INTO t(id, x) VALUES(1, "a"); |
因为没有使用 START TRANSACTION
将上面的操作当成一个事务来执行,根据 MySQL 的 AUTOCOMMIT 机制,每个操作都会被当成一个事务来执行,所以上面的操作总共涉及到三个事务。快照中除了记录事务版本号 TRX_ID 和操作之外,还记录了一个 bit 的 DEL 字段,用于标记是否被删除。
INSERT、UPDATE、DELETE 操作会创建一个日志,并将事务版本号 TRX_ID 写入。DELETE 可以看成是一个特殊的 UPDATE,还会额外将 DEL 字段设置为 1。
在InnoDB里,undo log分为如下两类:
- insert undo log : 事务对insert新记录时产生的undo log, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
- update undo log : 事务对记录进行delete和update操作时产生的undo log,不仅在事务回滚时需要,快照读也需要,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被删除。
5.5.2 ReadView
ReadView是事务在进行快照读的时候生成的记录快照, 可以帮助我们解决可见性问题的
我们知道,事务分为活跃和未活跃两种,活跃事务就是已经开始但是还没有提交的事务。未活跃事务就是没有开始的事务或者已经提交的事务。
MVCC维护了一个 ReadView 结构,主要包含了当前系统未提交的事务列表 TRX_IDs {TRX_ID_1, TRX_ID_2, …},还有该列表的最小值 TRX_ID_MIN 和最大值 TRX_ID_MAX。换句话说,就是下面的:
- trx_ids: 当前系统中那些活跃(未提交)的读写事务ID, 它数据结构为一个List。(
重点注意
:这里的trx_ids中的活跃事务,不包括当前事务自己和已提交的事务,这点非常重要) - low_limit_id: 目前出现过的最大的事务ID+1,即下一个将被分配的事务ID。
- up_limit_id: 活跃事务列表trx_ids中最小的事务ID,如果trx_ids为空,则up_limit_id 为 low_limit_id。
- creator_trx_id: 表示生成该 ReadView 的事务的 事务id
访问某条记录的时候如何判断该记录是否可见,具体规则如下:
- 如果被访问版本的
事务ID = creator_trx_id
,那么表示当前事务访问的是自己修改过的记录,那么该版本对当前事务可见; - 如果被访问版本的
事务ID < up_limit_id
,那么表示生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。 - 如果被访问版本的
事务ID > low_limit_id
值,那么表示生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。即表示这个数据已经被后续事务修改了。显然不能获取。 - 如果被访问版本的
事务ID在 up_limit_id和m_low_limit_id
之间,那就需要判断一下版本的事务ID是不是在 trx_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;
如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
总之,就是说,必须保证,要读取的这个记录的所在的事务,已经提交了,或者是自身读事务。自身事务,显然是可以读的;如果是已经提交的事务,显然可以读取。如果是没有提交的事务【即在活跃列表里面,或者大于最大值】,也就是还有事务正在操作这个最新数据,显然不可以读取,否则就是读未提交。
对于不能读取的数据,利用回滚指针,找到以前的版本,继续判断事务ID。
这里需要思考的一个问题就是 何时创建ReadView?
上面说过,ReadView是来解决一个事务需要读取哪个版本的行记录的问题的。那么说明什么?只有在select的时候才会创建ReadView。但在不同的隔离级别是有区别的:
在RC隔离级别下,是每个select都会创建最新的ReadView;
而在RR隔离级别下,则是当事务中的第一个select请求才创建ReadView(下面会详细举例说明)。因为可重复读就是要保证读取的是一样的,因此,只能在第一个select请求创建。后续的每个select都是基于这个ReadView。
那insert/update/delete操作呢?这样操作不会创建ReadView。但是这些操作在事务开启(begin)且其未提交的时候,那么它的事务ID,会存在在其它存在查询事务的ReadView记录中,也就是trx_ids中。
5.5.3 快照读与当前读
因为有不同版本,所以可以读最新版本,也可读以前的版本。
- 快照读,读的是快照中的数据,不需要加锁。
- 当前读,读的是最新快照中的数据。
因此,读操作快照读和当前读有两种选择:
- select lock in share mode(共享锁)
- select for update(排他锁)
- update(排他锁)
- insert(排他锁)
- delete(排他锁)
5.5.4 总结
因为每次事务,要么是查询,要么就会更改数据。而读已提交和可重复读,显然是需要读历史数据,不被当前线程影响,因此需要保存历史记录。MVCC就是在每次更新之前,将当前的数据保存到历史记录中。用回滚指针将最新数据以及历史记录连接起来。
错误理解:
感觉MVCC其实就是将每一条记录的每次事务操作保存起来,封装成对象,并用回滚指针将对象封装成链表。
更新、插入和删除,肯定是在最先版本上更新,即在链表的尾部新增事务操作即可。和加锁没有区别。
而查找操作,显然,此时有多个版本,该选择哪个版本呢?这个问题显然解决了读已提交和可重复读。
注意,虽然在RR级别下,MVCC解决了可重复读的问题,同时幻读似乎也就不出现了,但是实际上,如果当前线程插入哪条数据【另一个事务已经插入了,只不过解决了不可重复读,导致没有发现】,就会失败。似乎这样其实并没有解决幻读。