undo log 与隔离性

88 阅读24分钟

1. 什么是隔离性

隔离性定义的是,如果多个事务并发执行时,事务之间不应该出现相互影响的情况,它其实就是数据库的并发控制。

在计算机领域中原子性表示的是不可被中断的一个或者一系列操作,它包含了两个层面的意思。

  • 整体的不可分割性。一个原子操作的所有操作,要么全部执行,要么就一个都不执行,即 all-or-nothing 。
  • 可串行化的隔离性,即线程安全。原子操作是在单核 CPU 时代定义的,由于原子操作是不可中断的,那么系统在执行原子操作的过程中,唯一的 CPU 就被占用了,这就确保了原子操作的临界区,不会出现竞争的情况。原子操作自带了线程安全的保证,即最严格的隔离级别的可串行化,所以我们在编程的时候,就不需要对原子操作加锁,来保护它的临界区了。

在数据库领域中的事务的定义中,就将原子操作的不可分割性和隔离性,分别定义出了两个特性,即原子性和隔离性

在应用程序的开发中,我们通常会利用锁进行并发控制,确保临界区的资源不会出现多个线程同时进行读写的情况,这其实就对应了事务的最高隔离级别:可串行化,它能保证多个并发事务的执行结果和一个一个串行执行是一样的。

隔离性是我们日常开发中经常碰到的一个概念,那么你肯定会有一个疑问,为什么应用程序中可以提供可串行化的隔离级别,而数据库却不能呢?

其实根本原因就是

  • 应用程序对临界区大多是内存操作

  • 数据库要保证持久性(即ACID 中的 Durability),需要把临界区的数据持久化到磁盘。可是磁盘操作比内存操作要慢好几个数量级,一次随机访问内存、 SSD 磁盘和 SATA 磁盘,对应的操作时间分别为几十纳秒、几十微秒和几十毫秒,这会导致临界区持有的时间变长,对临界区资源的竞争将会变得异常激烈,数据库的性能则会大大降低。

2. 什么是事务并发

MySQL是一个客户端/服务器架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称之为一个会话(Session)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说可能同时处理多个事务。

由隔离性的定义,理论上在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样子的话对性能影响太大,我们既想保持事务的隔离性,又想让服务器在处理访问同一数据的多个事务时性能尽量高些。

所以,数据库的研究者就对事务定义了隔离级别这个概念,也就是在高性能与正确性之间提供了一个缓冲地带,相当于明确地告诉使用者,我们提供了正确性差一点但是性能好一点的模式,以及正确性好一点但是性能差一点的模式,使用者可以按照自己的业务场景来选择。

隔离性是高性能与正确性之间的一个权衡

3.事务并发执行遇到的问题

3.1 脏写(Dirty Write)

如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了脏写。

mysql> select * from student;
+----+------+-------+
| id | name |  sex  |
+----+------+-------+
|  1 | evan | major |
+----+------+-------+

我们先得看一下访问相同数据的事务在不保证串行执行(也就是执行完一个再执行另一个)的情况下可能会出现哪些问题:

发生时间编号SessionASessionB
1begin;
2begin;
3update student set name='bob' where id = 1;
4update student set name='pop' where id = 1;
5commit;
6rollback;
  • Session A和Session B各开启了一个事务
  • Session B中的事务先将id=1的记录的name更新为'bob',
  • 然后Session A中的事务接着又把将id=1的记录的name更新为'pop'
  • 如果之后Session B中的事务进行了回滚,那么Session A中的更新也将不复存在,这种现象就称之为脏写。

这时Session A中的事务就很懵逼,我明明把数据更新了,最后也提交事务了,怎么到最后说自己什么也没干呢?

3.2 脏读(Dirty Read)

如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了脏读。

发生时间编号SessionASessionB
1begin;
2begin;
3update student set name='bob' where id = 1;
4select * from student where id = 1;(如果读到name='bob',则发生脏读)
5commit;
6rollback;
  • Session A和Session B各开启了一个事务
  • Session B中的事务先将id=1的记录的name更新为'bob'
  • Session A中的事务再去查询这条id=1,如果得到name='bob',
  • 而Session B中的事务稍后进行了回滚,那么Session A中的事务相当于读到了一个不存在的数据,这种现象就称之为脏读。

3.3 不可重复读(Non-Repeatable Read)

如果一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,那就意味着发生了不可重复读。

发生时间编号SessionASessionB
1begin;
2select * from student where id = 1;(此时读到name='evan')
3update student set name='bob' where id = 1;
4select * from student where id = 1;(如果读到name='bob',则发生不可重复读)
5update student set name='pop' where id = 1;
6select * from student where id = 1;(如果读到name='pop',则发生不可重复读)

Session B中提交了几个隐式事务(注意是隐式事务,意味着语句结束事务就提交了),这些事务都修改了number列为1的记录的列name的值,每次事务提交之后,如果Session A中的事务都可以查看到最新的值,这种现象也被称之为不可重复读。

3.4 幻读(Phantom)

如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读,示意图如下

发生时间编号SessionASessionB
1begin;
2select * from student where id>0;(此时读到name='evan')
3insert into student vales(2,'bob','man');
4select * from student where id>0;(此时读到name='evan',name='bob')
  • Session A中的事务先根据条件id>0这个条件查询表student,得到了name='evan'的记录;
  • 之后Session B中提交了一个隐式事务,该事务向表student中插入了一条新记录;
  • 之后Session A中的事务再根据相同的条件id>0查询表student,
  • 得到的结果集中包含Session B中的事务新插入的那条记录,这种现象也被称之为幻读。

如果Session B中是删除了一些符合id > 0的记录而不是插入新记录,那Session A中之后再根据id > 0的条件读取的记录变少了,这种现象算不算幻读呢? 明确说一下,这种现象不属于幻读,幻读强调的是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录。

3.5 丢失更新(Loss of Update)

即有两个事务 T1 和 T2 , T1 先读 x = 0 ,然后 T2 读 x = 0 ,接着 T1 将 x 加 3 后提交, T2 将 x 加 4 后提交,这时 x 的值最终为 4 , T1 的更新丢失了,如果 T1 和 T2 是串行的话,最终结果为 7 。

4. 事务的隔离级别

隔离性是高性能与正确性之间的一个权衡,那么它都提供了哪些权衡呢

我们上面所说的舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越低,越严重的问题就越可能发生。有一帮人(并不是设计MySQL的大佬们)制定了一个所谓的SQL标准,在标准中设立了4个隔离级别:

  • 读未提交(Read Uncommitted)

  • 读已提交(Read Committed)

  • 可重复读(Repeatable Read)

  • 串行化(Serializable)

隔离级别脏读不可重复读幻读
READ UNCOMMITTEDPossiblePossiblePossible
READ COMMITTEDNot PossiblePossiblePossible
REPEATABLE READNot PossibleNot PossiblePossible
SERIALIZABLENot PossibleNot PossibleNot Possible

也就是说:

  • READ UNCOMMITTED隔离级别下,可能发生脏读、不可重复读和幻读问题。
  • READ COMMITTED隔离级别下,可能发生不可重复读和幻读问题,但是不可以发生脏读问题。
  • REPEATABLE READ隔离级别下,可能发生幻读问题,但是不可以发生脏读和不可重复读的问题。
  • SERIALIZABLE隔离级别下,各种问题都不可以发生。

4.1 如何避免异常情况

现在我们已经知道了每一个隔离级别可能会出现的异常情况,如果当前数据库使用了某一个隔离级别,我们也知道它有哪些异常情况,是否有办法来避免呢?

其实这是一个非常好的问题,不过有些异常情况只能通过提升隔离级别来避免,那么接下来,我们就针对每一种异常情况来一一讨论一下。

  • 其一,对于脏写,几乎所有的数据库都可以防止异常的出现,并且我们可以理解为出现脏写的数据库是不可用的,所以这里就不讨论脏写的情况了。

  • 其二,对于脏读,提供“读已提交”隔离级别及以上的数据库,都可以防止异常的出现,如果业务中不能接受脏读,那么隔离级别最少在“读已提交”隔离级别或者以上。

  • 其三,对于不可重复读或读倾斜,“可重复读”隔离级别及以上的数据库都可以防止问题的出现,如果业务中不能接受不可重复读和读倾斜,那么隔离级别最少在“可重复读”隔离级别或者以上。

  • 其四,对于丢失更新,如果数据库的隔离级别不能达到“可重复读”隔离级别或者以上,那么我们可以考虑以下的几种方法来避免。

5. 如何来实现隔离性

既然事务的隔离性是用来确保多个事务并发执行时的正确性的,那么我们就可以依据应用程序开发中经常使用的并发控制策略,来思考事务的隔离性如何实现,这样就可以轻松得出如下的几个方法。

既然事务的隔离性是用来确保多个事务并发执行时的正确性的,那么我们就可以依据应用程序开发中经常使用的并发控制策略,来思考事务的隔离性如何实现,这样就可以轻松得出如下的几个方法。

首先,最容易想到的是通过锁来实现事务的隔离性。对于锁的方案,最简单的策略是整个数据库只有一把互斥锁,持有锁的事务可以执行,其他的事务只能等待。但是这个策略有很明显的问题,那就是锁的粒度太粗,会导致整个数据库的并发度变为 1 。

不过,我们可以进行优化,为事务所操作的每一块数据都分配一把锁,通过降低锁的粒度来增加事务的并发度。同时,相对于互斥锁来说,读写锁是一个更好的选择,通过读写锁,多个事务对同一块数据的读写和写写操作会相互阻塞,但却能允许多个读操作并发进行。

这样我们就得到了一个事务的并发模型,但是一个事务通常由多个操作组成,那么一个事务在持有锁修改某一个数据后,不能立即释放锁。如果立即释放锁,在其他的事务读到这个修改或者基于这个修改进行写入操作,当前事务却因为后续操作出现问题而回滚的时候,就会出现脏读或脏写的情况。

对于这个问题有一个解决方法,即事务对于它持有的锁,在当前的数据操作完成后,不能立即释放,需要等事务结束(提交或者回滚)完成后,才能释放锁。这个加锁的方式就是两阶段锁(2PL):第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。

那么现在是否就得到了可串行化的隔离性呢?其实还不是的,我们现在还没有解决幻读和写倾斜的问题,幻读指的是其他的事务改变了当前事务的查询结果,关于这个问题,我们可以通过谓词锁(Predicate Lock)来解决。

其次,我们可以通过多版本并发控制(MVCC , Multi-Version Concurrency Control)实现隔离性。数据库为每一个写操作创建了一个新的版本,同时给每一个对象保留了多个不同的提交版本,读操作读取历史提交的版本,这样对同一个数据来说,只有写写事务会发生冲突,读读事务和读写事务是不会发生冲突的。对于写写冲突的问题,可以通过加锁的方式来解决,不过对于 MVCC 来说,相对于悲观锁,乐观锁是一个更常见的选择。

如何来实现隔离性

  1. 通过锁来实现事务的隔离性
  2. 多版本并发控制(MVCC , Multi-Version Concurrency Control)实现隔离性

6. MVCC原理

6.1 版本链

我们前面说过,对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(row_id并不是必要的,我们创建的表中有主键或者非NULL的UNIQUE键时都不会包含row_id列):

  • trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。
  • roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

比方说我们的表student现在只包含一条记录:

mysql> select * from student;
+----+------+-------+
| id | name |  sex  |
+----+------+-------+
|  1 | evan | major |
+----+------+-------+

假设两个事务id分别为20、40的事务对这条记录进行UPDATE操作,操作流程如下:

发生时间编号SessionA(trx=20)SessionB(trx=40)
1begin;
2begin;
3update student set name='bob' where id = 1;
4update student set name='pop' where id = 1;
5commit;
6update student set name='eva' where id = 1;
7update student set name='jay' where id = 1;
8commit;

每次对记录进行改动,都会记录一条undo日志,每条undo日志都有一个roll_pointer属性,可以将这些undo日志都连起来,串成一个链表,所以现在的情况就像下图一样:

对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务id。

6.2 ReadView

对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;对于使用SERIALIZABLE隔离级别的事务来说,设计InnoDB的大佬规定使用加锁的方式来访问记录;

对于使用READ COMMITTED和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。为此,设计InnoDB的大佬提出了一个ReadView的概念,这个ReadView中主要包含4个比较重要的内容:

  • m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。

  • min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。

  • max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。

    • 注意max_trx_id并不是m_ids中的最大值,事务id是递增分配的。比方说现在有id为1,2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。
  • creator_trx_id:表示生成该ReadView的事务的事务id。

    • 只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。

有了这个ReadView,这样在访问某条记录时,只需要按照下面的步骤判断记录的某个版本是否可见:

  • 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值大于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上面的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。

在MySQL中,READ COMMITTED和REPEATABLE READ隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。我

们还是以表student为例来,假设现在表student中只有一条由事务id为10的事务插入的一条记录:

mysql> select * from student;
+----+------+-------+
| id | name |  sex  |
+----+------+-------+
|  1 | evan | major |
+----+------+-------+

接下来看一下READ COMMITTED和REPEATABLE READ所谓的生成ReadView的时机不同到底不同在哪里。

6.3 READ COMMITTED —— 每次读取数据前都生成一个ReadView

比方说现在系统里有两个事务id分别为20、40的事务在执行:

# Transaction 20
BEGIN;

UPDATE student SET name = 'bob' WHERE id = 1;

UPDATE student SET name = 'pop' WHERE id = 1;
# Transaction 40
BEGIN;

# 更新了一些别的表的记录
...

此刻,表student中id=1的记录得到的版本链表如下所示:

假设现在有一个READ COMMITTED隔离级别的事务开始执行:

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 20、40未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值为'evan'

这个SELECT1的执行过程如下:

  • 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[20, 40],min_trx_id为20,max_trx_id为41,creator_trx_id为0。
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'pop',该版本的trx_id值为20,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  • 下一个版本的列name的内容是'bob',该版本的trx_id值也为20,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列name的内容是'evan',该版本的trx_id值为10,小于ReadView中的min_trx_id值20,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为'evan'的记录。

之后,我们把事务id为20的事务提交一下,就像这样:

# Transaction 20
BEGIN;

UPDATE student SET name = 'bob' WHERE id = 1;

UPDATE student SET name = 'pop' WHERE id = 1;

COMMIT;

然后再到事务id为40的事务中更新一下表student中id为1的记录:

# Transaction 40
BEGIN;

# 更新了一些别的表的记录
update student set name='eva' where id = 1;
update student set name='jay' where id = 1;

使用READ COMMITTED隔离级别的事务中继续查找这个id=1的记录,如下:

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 20、40均未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值为'evan'

# SELECT2:Transaction 20提交,Transaction 40未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值为'pop'

这个SELECT2的执行过程如下:

  • 在执行SELECT语句时会又会单独生成一个ReadView,该ReadView的m_ids列表的内容就是[40](事务id为20的那个事务已经提交了,所以再次生成快照时就没有它了),min_trx_id为40,max_trx_id为41,creator_trx_id为0。
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'jay',该版本的trx_id值为40,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  • 下一个版本的列name的内容是'eva',该版本的trx_id值为40,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列name的内容是'pop',该版本的trx_id值为20,小于ReadView中的min_trx_id值40,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为'pop'的记录。

以此类推,如果之后事务id为40的记录也提交了,再此在使用READ COMMITTED隔离级别的事务中查询表student中id值为1的记录时,得到的结果就是'jay'了。

总结一下就是:使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。

6.4 REPEATABLE READ —— 在第一次读取数据时生成一个ReadView

对于使用REPEATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView,之后的查询就不会重复生成了。我们还是用例子看一下是什么效果。

比方说现在系统里有两个事务id分别为20、40的事务在执行:

# Transaction 20
BEGIN;

UPDATE student SET name = 'bob' WHERE id = 1;

UPDATE student SET name = 'pop' WHERE id = 1;
# Transaction 40
BEGIN;

# 更新了一些别的表的记录
...

假设现在有一个 REPEATABLE READ隔离级别的事务开始执行:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 20、40未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值为'evan'

这个SELECT1的执行过程如下:

  • 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[20, 40],min_trx_id为20,max_trx_id为41,creator_trx_id为0。
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'pop',该版本的trx_id值为20,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  • 下一个版本的列name的内容是'bob',该版本的trx_id值也为20,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列name的内容是'evan',该版本的trx_id值为10,小于ReadView中的min_trx_id值20,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为'evan'的记录。

之后,我们把事务id为20的事务提交一下,就像这样:

# Transaction 20
BEGIN;

UPDATE student SET name = 'bob' WHERE id = 1;

UPDATE student SET name = 'pop' WHERE id = 1;

COMMIT;

然后再到事务id为40的事务中更新一下表student中id=1的记录:

# Transaction 40
BEGIN;

# 更新了一些别的表的记录
update student set name='eva' where id = 1;
update student set name='jay' where id = 1;

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 20、40均未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值为'evan'

# SELECT2:Transaction 20提交,Transaction 40未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值为'jay'

这个SELECT2的执行过程如下:

  • 因为当前事务的隔离级别为REPEATABLE READ,而之前在执行SELECT1时已经生成过ReadView了,所以此时直接复用之前的ReadView,之前的ReadView的m_ids列表的内容就是[20,40],min_trx_id为20,max_trx_id为41,creator_trx_id为0。
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'jay',该版本的trx_id值为40,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  • 下一个版本的列name的内容是'eva',该版本的trx_id值为40,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列name的内容是'pop',该版本的trx_id值为20,而m_ids列表中是包含值为20的事务id的,所以该版本也不符合要求,同理下一个列name的内容是'bob'的版本也不符合要求。继续跳到下一个版本。
  • 下一个版本的列name的内容是'evan',该版本的trx_id值为10,小于ReadView中的min_trx_id值20,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name的内容为'evan'的记录。

也就是说两次SELECT查询得到的结果是重复的,记录的列name的内容为'evan',这就是可重复读的含义。如果我们之后再把事务id为40的记录提交了,然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为1的记录,得到的结果还是'evan'。

6.5 MVCC小结

从上面的描述中我们可以看出来,所谓的MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行普通的SEELCT操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。

READ COMMITTD、REPEATABLE READ这两个隔离级别的一个很大不同就是:生成ReadView的时机不同,READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView,而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。