对线面试官:通过MVCC数据库事务的一致性
undo 日志链表
我们在前面介绍 undo 日志的时候提到过,当 InnoDB 引擎底层开启一个新事务的时候,会分配一个全局唯一的事务 ID,该事务 ID 写入 undo 日志的同时也会存储到数据表记录簇拥索引的 trx_id
隐藏列中:
另一个隐藏列 roll_pointer
指针会指向该记录上一个版本的 undo 日志,发生事务回滚时我们可以通过该指针找到这条记录要回滚到的版本。
这么说有点抽象,我们举个具体的例子,还是以小明和小强之间的支付宝钱包转账为例。假设当前小明账户余额如下(470 元):
分别在两个事务中执行更新操作:
SQL 语句执行序列 | 事务 A | 事务 B |
---|---|---|
1 | BEGIN; | |
2 | BEGIN; | |
3 | UPDATE walltes SET balance = balance - 2000 WHERE id = 1; |
|
4 | UPDATE walltes SET balance = balance + 5000 WHERE id = 1; |
|
5 | COMMIT; | |
5 | UPDATE walltes SET balance = balance + 8000 WHERE id = 1; |
|
6 | COMMIT; |
事务 A 对应的操作是小明先花了 20 块钱,钱包又充值了 50 块钱,假设事务 ID 是 1000,事务 B 对应的操作时小强给小明转账了 80 块钱,假设 事务 ID 是 1200。
现在我们绘制出 wallets
簇拥索引中这条的记录和对应的 undo 日志,相应的结构如下所示:
可以看到,每次记录更新后,上一个版本的值就会被存放到 undo 日志中,并且将当前最新记录的 roll_pointer
指针指向该 undo 日志,这样一来,所有的 roll_pointer
指针串成一个链表,该链表被称作版本链,版本链的头节点就是当前记录最新版本的值。
版本链本质上就是个 undo 日志链表。
在继续深入介绍之前,我们先来看看不同隔离级别下当前事务可以读取到的最新版本记录的区别:
- READ UNCOMMITTED:对于使用该隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了,不管这个事务是不是当前事务(由于存在脏读问题,一般不会使用这种隔离级别);
- SERIALIZABLE:对于使用该隔离级别的事务来说,InnoDB 底层会使用加锁的方式来访问记录,具体细节后面讲到锁的时候介绍(由于性能问题,一般也不会使用这种隔离级别);
- READ COMMITTED 和 REPEATABLE READ:对于使用这两种隔离级别的事务来说,只能读取已提交事务的修改记录,也就是说如果另一个事务修改了记录但尚未提交,是不能直接读取它的最新版本记录的。
排除 READ UNCOMMITTED 和 SERIALIZABLE,我们重点关注 READ COMMITTED 和 REPEATABLE READ,MySQL 底层是如何保证这两种隔离级别事务可以正确读取对应的版本记录的呢?
结合上面的版本链就很好理解了:只需要判断版本链中哪个版本是当前事务可见的即可。
为了解决这个问题,InnoDB 引擎设计了 ReadView(可读视图) 的概念。
ReadView
判断记录的可见性
ReadView 实际上是当前系统中所有活跃事务的列表,主要包含以下组成部分:
m_ids
:在生成 ReadView 时当前系统中活跃的事务 ID 列表;min_trx_id
:在生成 ReadView 时当前系统中活跃的事务中最小的事务 ID,也就是m_ids
中的最小值;max_trx_id
:在生成 ReadView 时系统中应该分配给下一个事务的 ID 值;creator_trx_id
:生成 ReadView 的事务对应的事务 ID,也就是当前事务 ID。
有了这个 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 时生成该版本的事务已经被提交,该版本记录可以被访问。 - 如果某个版本的记录对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
那 ReadView 又是何时生成的呢?
对于 READ COMMITTED 和 REPEATABLE READ 两种隔离级别而言,最大的区别就是 ReadView 的生成时机不同。
ReadView 的生成时机
在 READ COMMITTED 隔离级别下,每个 SELECT 语句开始时,都会重新将当前系统中的所有的活跃事务拷贝到一个列表生成 ReadView。
在 REPEATABLE READ 隔离级别下,每个事务执行第一个 SELECT 语句时,会将当前系统中的所有的活跃事务拷贝到一个列表生成 ReadView,后续所有的 SELECT 都是复用这个 ReadView。
所以结合 READ COMMITTED 隔离级别下 ReadView 的生成时机,以及如何基于 ReadView 判断记录的可见性,也就不难理解为什么 READ COMMITTED 隔离级别下会出现不可重复读了吧。因为每次 SELECT 语句执行之前都会重新生成新的 ReadView,对应的 m_ids
会不断纳入新提交事务的 ID,从而导致每次 SELECT 的查询结果不一样,进而出现不可重复读。
而 REPEATABLE READ 隔离级别下,只有第一次 SELECT 才会生成 ReadView,后续 SELECT 都会复用这个 ReadView,也就不存在新提交事务对这个 ReadView 的影响了。
MVCC 机制
所谓的 MVCC(Multi-Version Concurrency Control,多版本并发控制)指的就是在使用 READ COMMITTD 和 REPEATABLE READ 这两种隔离级别的情况下,事务在执行普通SELECT 操作时访问数据库记录的版本链的过程,这样一来,我们就可以不通过加锁,而是通过 MVCC 机制使得不同事务的读写操作可以并发执行,从而提升 MySQL 系统在并发场景下的吞吐性能。
以下面两个事务为例:
事务 A 可以和事务 B 并发执行,在事务 A 中可以读取到事务 B 提交的更改,而不需要在事务 B 执行之后再执行事务 A(这是串行化),并且不管是 READ COMMITTED 和 REPEATABLE READ 隔离级别,都可以读取到,因为事务 A 第一次执行 SELECT 语句的时候,事务 B 已经提交了,此时生成的 ReadView 不包含事务 B 对应的事务 ID。
可以看到,在不同的隔离级别下,MySQL 通过 MVCC 让事务之间的并行操作遵循了某种规则,从而保证单个事务内前后数据的一致性。这个规则就是当前事务的查询可以看到自己之前所有已提交的事务所做的更改,而看不到未提交的事务所做的更改或者在查询开始之后提交的事务所做的更改,这种基于时间点的查询快照也被称作一致性快照读。
有人会说 READ COMMITTED 隔离级别下是可以看到后续事务提交的更改的,这是因为该隔离级别下每次 SELECT 查询都会刷新并读取最新的数据库快照(已提交事务所做的更改);而在 REPEATABLE READ 隔离级别下,同一个事务中所有 SELECT 查询都会基于第一次查询生成的快照,如果要刷新快照,必须提交该事务然后通过新的查询获取最新查询快照(这里的查询快照可以对应前面的 ReadView)。从这个角度看 READ COMMITTD 和 REPEATABLE READ 两种隔离级别的区别在于查询快照刷新策略不同,不过本质上和 ReadView 生成时机不同是一样的。
- 注:在 MySQL InnoDB 引擎中,只有 READ COMMITTD 和 REPEATABLE READ 这两种隔离级别才可以使用 MVCC,应对高并发事务,MVCC 比单纯的加行锁更有效,开销更小。
欢迎关注我的公众号
本作品采用《CC 协议》,转载必须注明作者和本文链接
推荐文章: