对线面试官:通过MVCC数据库事务的一致性
对线面试官:通过MVCC数据库事务的一致性
MVCC(多版本并发控制)是MySQL InnoDB存储引擎实现事务隔离的重要机制。本文将通过undo日志链表、ReadView的生成和使用,以及不同隔离级别下的事务可见性规则,深入浅出地讲解MVCC的核心原理。
undo 日志链表
在介绍undo日志时提到,当InnoDB引擎底层开启一个新事务时,会分配一个全局唯一的事务ID。这个事务ID不仅写入undo日志,还会存储到数据表记录簇拥索引的trx_id
隐藏列中。另一个隐藏列roll_pointer
指针会指向该记录上一个版本的undo日志,发生事务回滚时可以通过该指针找到要回滚到的版本。
举个具体的例子,假设小明账户余额为470元,分别在两个事务中执行更新操作:
SQL 语句执行序列 | 事务 A | 事务 B |
---|---|---|
1 | BEGIN; | |
2 | BEGIN; | |
3 | UPDATE wallets SET balance = balance - 2000 WHERE id = 1; | |
4 | UPDATE wallets SET balance = balance + 5000 WHERE id = 1; | |
5 | COMMIT; | |
6 | UPDATE wallets SET balance = balance + 8000 WHERE id = 1; | |
7 | COMMIT; |
事务A对应的操作是小明先花了20块钱,钱包又充值了50块钱,假设事务ID是1000;事务B对应的操作是小强给小明转账了80块钱,假设事务ID是1200。
每次记录更新后,上一个版本的值就会被存放到undo日志中,并且将当前最新记录的roll_pointer
指针指向该undo日志。这样一来,所有的roll_pointer
指针串成一个链表,该链表被称作版本链,版本链的头节点就是当前记录最新版本的值。版本链本质上就是个undo日志链表。
ReadView
在继续深入介绍之前,我们先来看看不同隔离级别下当前事务可以读取到的最新版本记录的区别:
- READ UNCOMMITTED:对于使用该隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了,不管这个事务是不是当前事务(由于存在脏读问题,一般不会使用这种隔离级别);
- SERIALIZABLE:对于使用该隔离级别的事务来说,InnoDB底层会使用加锁的方式来访问记录,具体细节后面讲到锁的时候介绍(由于性能问题,一般也不会使用这种隔离级别);
- READ COMMITTED 和 REPEATABLE READ:对于使用这两种隔离级别的事务来说,只能读取已提交事务的修改记录,也就是说如果另一个事务修改了记录但尚未提交,是不能直接读取它的最新版本记录的。
排除READ UNCOMMITTED和SERIALIZABLE,我们重点关注READ COMMITTED和REPEATABLE READ,MySQL底层是如何保证这两种隔离级别事务可以正确读取对应的版本记录的呢?结合上面的版本链就很好理解了:只需要判断版本链中哪个版本是当前事务可见的即可。
为了解决这个问题,InnoDB引擎设计了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的生成时机不同:
- 在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 COMMITTED和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 COMMITTED和REPEATABLE READ两种隔离级别的区别在于查询快照刷新策略不同,不过本质上和ReadView生成时机不同是一样的。
- 注:在MySQL InnoDB引擎中,只有READ COMMITTED和REPEATABLE READ这两种隔离级别才可以使用MVCC,应对高并发事务,MVCC比单纯的加行锁更有效,开销更小。