专栏系列文章:MySQL系列专栏
MVCC
MVCC 介绍
前面在 事务原子性之UndoLog 这篇文章中多次提到过MVCC
这个东西了,我们说执行DELETE
语句或者更新主键
的UPDATE
语句并不会立即把对应的记录直接从页面中删除,而是将记录头的delete_mask
设置为1
,做标记删除,这主要就是为MVCC
服务的。
然后在 事务基础 这篇文章中介绍了并发事务会有 脏写、脏读、不可重复读、幻读
四个问题,脏写
可以通过乐观锁或悲观锁的方式来解决,脏读、不可重复读、幻读
三个问题需要数据库提供一定的事务隔离机制来解决,也就是事务的隔离性。
SQL标准的事务隔离级别有四个,分别能解决并发事务的一些问题:
脏读、不可重复读、幻读
说的都是并发读取的问题,最简单的方式就是给记录加一把锁,不管是更新、读取记录都需要竞争到这把锁之后才能操作。但这种方式的并发性能可想而知会有多么低。
于是 InnoDB 就设计了MVCC
来解决并发读取的问题,MVCC
就是多版本并发控制(Multi-Version Concurrency Control
)。在 RC
、RR
这两种隔离级别下执行SELECT
查询时,通过访问记录的版本链
,而不需要加锁,这样使得不同事务的读-写
操作可以并发执行,从而提升数据库的性能。
undo log 版本链
前面说 MVCC 是读取记录的版本链
实现的,这个版本链其实就是由undo log
形成的一个版本链条。
以 事务原子性之UndoLog 文章中的这幅图为例,很直观的可以看到,增删改产生的 undo log 通过old roll_pointer
连成一个单向链表,记录中的隐藏列roll_pointer
则指向最新的一个undo log
,就是undo版本链的头结点。
记录中始终都是最新更新的值,可能更新这条记录的事务还未提交:
-
对于使用
RU
隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。 -
对于使用
SERIALIZABLE
隔离级别的事务来说,InnoDB 使用加锁的方式来访问记录。这个后面再说。 -
对于使用
RC
、RR
隔离级别的事务来说,都必须保证读到已提交
事务修改过的记录,如果另一个事务修改的记录还未提交,是不能直接读取记录的最新版本的,此时就可以沿着undo版本链查找当前事务可见的版本。
ReadView
上一节说到在RC、RR
隔离级别下,要保证读到已提交
事务修改过的记录,就要在undo版本链上找到当前事务可见的版本。那如何判断版本链上的哪个版本是当前事务可见的呢?
InnoDB 设计了一个 ReadView
,在执行一个事务的时候就会创建一个ReadView
。
ReadView 有四个关键属性:
m_ids
:在生成 ReadView 时当前系统中活跃的事务的事务ID列表。min_trx_id
:生成 ReadView 时当前系统中活跃的事务中最小的事务ID,也就是m_ids
中的最小值。max_trx_id
:生成 ReadView 时系统中分配给下一个事务的ID值,就是全局事务ID(Max Trx Id
),注意并不是m_ids
中的最大值。creator_trx_id
:生成该 ReadView 的事务的事务ID。事务中只有在执行了增删改操作时才会分配一个事务ID,如果是一个只读事务,那 creator_trx_id 默认就为0
。
MVCC 原理
undo版本链+ReadView机制
有了ReadView
后,在事务中查询的时候,就可以沿着 undo 版本链查找当前事务可见的版本。这时 undo log 中的隐藏列 trx_id
就派上用场了,它表示产生这条 undo log 时的事务的事务ID。判断此版本是否可访问的依据就是用 undo log 中的 trx_id
属性值与 ReadView 中的各个属性做比较。
通过如下步骤来判断版本是否可被访问:
-
① 如果
trx_id
等于creator_trx_id
,说明当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。 -
② 如果
trx_id
小于min_trx_id
,说明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。 -
③ 如果
trx_id
大于或等于max_trx_id
,说明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。 -
④ 如果
trx_id
在min_trx_id
和max_trx_id
之间,此时再判断一下trx_id
是不是在m_ids
列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
大致的流程图如下:
RC 和 RR 隔离级别
READ COMMITTED
和 REPEATABLE READ
隔离级别的区别就是它们生成ReadView
的时机不同。
READ COMMITTED
是每次查询前都会生成一个独立的 ReadView。REPEATABLE READ
则只在第一次查询前生成一个 ReadView,之后的查询都重复使用这个 ReadView。READ UNCOMMITTED
则不需要生成 ReadView,直接读取行记录的数据。
还是以之前的 account
表为例,下面按照操作发生的时间顺序来进行说明。
1、T1
现在 account 表中的初始状态如下,最后更新这条数据的事务ID为100
,card 的值为 AA
。
系统下一个要分配的事务ID为150
,然后系统有两个事务正在运行,事务ID分别为130、135
。
2、T2
此时新开一个事务A
,查询ID=1的数据,就会生成一个 ReadView 如下图所示:
此时会先判断记录中的 trx_id(100) 与 creator_trx_id(0) 是否相等,这个条件不满足;
接着判断 trx_id(100) < min_trx_id(130),这个条件满足,那就直接读取这行数据。
此时在事务A
中查询 balance=0 的数据,也只会返回1
条数据。
3、T3
接着另一个事务B
(trx_id=150)更新这条数据,将 AA 更新为 BB,事务还未提交。
接着在事务A
中再次查询ID=1的这条数据。
- 在
RC
隔离级别下,会生成一个新的 ReadView:
先判断行记录,发现 trx_id(150) 在 min_trx_id(130) 和 max_trx_id(160) 之间,同时在
m_ids(130,135,150) 列表中,所以记录行上的数据对本事务不可见;然后继续对比之前的版本,发现 AA 这条版本的 trx_id(100) < min_trx_id(130),所以就返回 AA 这个版本。所以在 RC
隔离级别下多次读取,看不到别的事务未提交
的更新,可避免脏读
的问题。
- 在
RR
隔离级别下,ReadView 不变:
先判断行记录,发现 trx_id(150) 等于 max_trx_id(150),所以记录行上的数据对本事务不可见;然后继续对比之前的版本,发现 AA 这条版本的 trx_id(100) < min_trx_id(130),所以就返回 AA 这个版本。所以在 RR
隔离级别下多次读取,看不到别的事务未提交
的更新,可避免脏读
的问题。
4、T4
接着事务B
提交了更新。
接着在事务A
中再次查询ID=1的这条数据。
- 在
RC
隔离级别下,会生成一个新的 ReadView:
先判断行记录,发现 trx_id(150) 在 min_trx_id(130) 和 max_trx_id(165) 之间,同时不在
m_ids(130,135) 列表中,所以记录行上的数据对本事务可见,返回 BB 这个版本。所以在 RC
隔离级别下多次读取,是可以看到别的事务已提交
的更新,会有不可重复读
的问题。
- 在
RR
隔离级别下,ReadView 不变:
此时的判断跟上一步中的判断是一样的,最后也是返回 AA 个版本。所以在 RR
隔离级别下多次读取,看不到别的事务已提交
的更新,避免了不可重复读
的问题。
5、T5
接着一个新的事务(trx_id=175)更新ID=1这条数据,将 BB 更新为 CC,同时还插入了ID=2这条数据,且事务已提交。
这时在事务A
中查询 balance=0 的数据。
- 在
RC
隔离级别下,会生成一个新的 ReadView:
查询ID=1这行记录时,先判断行版本,由于 trx_id(175) 在 min_trx_id(170) 和 max_trx_id(200) 之间,且不在 m_ids(170,180) 列表中,所以返回 CC 这个版本。查询ID=2这行记录时,同样的判断过程,会返回 MM 这个版本。最终查询返回2
条数据,而最开始查询只返回1
条数据,所以在 RC
隔离级别下多次读取,会有幻读
的问题。
- 在
RR
隔离级别下,ReadView 不变:
查询ID=1这行记录时,最终会沿着版本链找到 AA 这个版本。查询ID=2这行记录时,trx_id(175) > max_trx_id(150),所以ID=2这行记录不匹配。最终查询只返回1
条数据,所以在 RR
隔离级别下多次读取,不会有幻读
的问题。
6、T6
接着一个新的事务(trx_id=205)删除了ID=1这条数据,但删除的时候并不是真正的删除,只是将delete_mask
标记为 1
。
接着在事务A
中再次查询ID=1的这条数据。
由于行记录中 delete_mask
标记为 1
了,是不能被查询的,所以只能沿着版本链查询之前的版本。之后的匹配过程跟前面的描述是类似的,就不在赘述了。在 RC
隔离级别下,会返回 trx_id=175,值为 CC 这个版本。在 RR
隔离级别下,会返回 trx_id=100,值为 AA 这个版本。
MVCC 总结
从上面示例的演示过程就可以看出,MVCC 就是通过 undo log 版本链 + ReadView 实现的一套并发读取的机制。
在 READ COMMITTD
隔离级别下,每次查询都生成一个新的 ReadView,不能读到别的事务未提交的修改,因此解决了 脏读
的问题。但是能读取到别的事务已提交的修改,会有 不可重复读、幻读
的问题。
在 REPEATABLE READ
隔离级别下,只在第一次查询时生成一个 ReadView,之后的查询都重复使用这个 ReadView。别的事务未提交、已提交、新插入的修改都读取不到,因此解决了 脏读、不可重复读、幻读
的问题。
前面介绍 undo log 的文章说过,执行 DELETE 语句或者更新主键的 UPDATE 语句并不会立即把对应的记录完全从页面中删除,而是将 delete_mask
设置为 1,做标记删除。这时就清楚是为什么了,这主要就是为MVCC服务的,因为可能有其它并发运行的事务,要通过版本链读取当前事务可见的版本。
快照读和当前读
有一点需要注意的是,前面的示例中的查询都是简单的SELECT
查询,这种就是读取undo版本链上的一个快照版本,可以称为快照读
或一致性非锁定读
。由于是读取的快照,因此在RR
隔离级别下可以避免幻读的发生。
但如果是INSERT、DELETE、UPDATE
语句,例如下面的SQL,这个 UPDATE 语句会更新 balance=0 的记录,这种方式就称为当前读
,读取的是最新的数据。当前读
能读取到别的事务已提交的修改,就可能会产生幻读的问题。
UPDATE account SET balance=100 WHERE balance = 0;
例如,在默认RR
隔离级别下,按如下顺序执行SQL,Session B 两次普通查询的结果都是一样的,没有幻读的问题。这是因为 Session B 第二次查询读取的是快照版本,即快照读
,不会读取到别的事务提交的修改。
Timeline | Session A | Session B | Session C |
---|---|---|---|
t1 | TUNCATE TABLE account; INSERT INTO account(card) VALUES ('AA'); | ||
t2 | BEGIN; | BEGIN; | |
t3 | SELECT * FROM account WHERE balance=0; (返回AA这条记录) | ||
t4 | INSERT INTO account(card) VALUES ('BB'); | ||
t5 | COMMIT; | ||
t6 | SELECT * FROM account WHERE balance=0; (返回AA这条记录) | ||
u7 | COMMIT; |
再按照如下顺序执行SQL,Session B 第一次查询 balance=0 的数据只有AA这一条,然后更新 balance=0 的数据,按理来说只更新一条才对,会发现更新了两条数据,而且再次查询返回了AA、BB这两条数据,此时就产生了幻读的问题。这是因为中间那次更新是当前读
,更新时的查询可以读到其它事务提交的更新,此时MVCC是无法解决这个问题的。
Timeline | Session A | Session B | Session C |
---|---|---|---|
t1 | TUNCATE TABLE account; INSERT INTO account(card) VALUES ('AA'); | ||
t2 | BEGIN; | BEGIN; | |
t3 | SELECT * FROM account WHERE balance=0; (返回AA这条记录) | ||
t4 | INSERT INTO account(card) VALUES('BB') | ||
t5 | COMMIT; | ||
t6 | UPDATE account SET balance=100 WHERE balance=0; (会看到更新了两行数据) | ||
t7 | SELECT * FROM account WHERE balance=100; (返回AA、BB这两条记录) |
那当前读这种问题如何解决呢?这就要用到下篇文章中介绍的锁
了。