MVCC
1. 什么是 MVCC
在可重复读隔离级别下,同一个事务里多次执行相同的查询 SQL,查询结果都是相同的,就算其它事务对数据进行了修改并提交,也不会影响当前事务的查询结果,这种隔离性就是靠 MVCC(Multi-Version Concurrency Control,多版本并发控制)机制来保证的。
对一行数据的读和写两个操作默认是不会通过加锁互斥来保证隔离性的,这样就避免了频繁加锁互斥的开销;而在串行化隔离级别下,为了保证较高的隔离性,是通过将所有操作加锁互斥来实现的。
MySQL 在读已提交和可重复读隔离级别下都实现了 MVCC 机制。


2. undo 日志版本链
一行数据被多个事务依次修改过后,在每个事务修改完后,MySQL 会保留修改前的数据 undo 回滚日志,并且用两个隐藏字段把这些 undo 日志串联起来,形成一个历史记录版本链:
trx_id:最近一次修改该行数据的事务 id。roll_pointer:回滚指针,指向该行数据的上一个历史版本。
以图中 blog 表为例,id = 1 的记录最初由事务 77 插入 title = juzi,之后又被事务 300、100、200 依次修改,每次修改前的数据都会保留到 undo 回滚日志中。图中版本按从旧到新从上往下排列,顺着当前记录的 roll_pointer 往前找历史版本的话,顺序是这样的:
当前记录(juzi-blog4, trx_id = 200) ──roll_pointer──>
undo 日志(juzi-blog3, trx_id = 200) ──roll_pointer──>
undo 日志(juzi-blog2, trx_id = 100) ──roll_pointer──>
undo 日志(juzi-blog1, trx_id = 100) ──roll_pointer──>
undo 日志(juzi-blog, trx_id = 300) ──roll_pointer──>
undo 日志(juzi, trx_id = 77) ──roll_pointer──>
insert undo log越往前版本越旧,最早的版本是 insert 时写入的,再往前就是 insert undo log 了。
对于删除的情况,可以认为是 update 的特殊情况:会将版本链上最新的数据复制一份,然后把 trx_id 修改成删除操作的事务 id,同时在该条记录的头信息(record header)里的 deleted_flag 标记位上写 true,表示当前记录已经被删除。查询时按照下面的规则比对到对应的记录后,如果 deleted_flag 标记位为 true,意味着记录已被删除,则不返回数据。
3. ReadView 机制
版本链保存了数据的历史版本,查询时怎么判断哪个版本对当前事务可见呢?这就轮到 ReadView(一致性视图)出场了。
在可重复读隔离级别下,当事务开启,执行任何查询 SQL 时会生成当前事务的一致性视图 ReadView,该视图在事务结束之前永远都不会变化;如果是读已提交隔离级别,在每次执行查询 SQL 时都会重新生成 ReadView。
这个视图由执行查询时所有未提交事务 id 数组(数组里最小的 id 为 min_id)和已创建的最大事务 id(max_id)组成,比如图中 readview:[100, 200], 300 就表示未提交事务数组为 [100, 200]、min_id = 100、max_id = 300。
ReadView 和可见性算法记录的其实就是 SQL 查询那一刻数据库里提交和未提交所有事务的状态。所以可重复读隔离级别下,事务里每次查询都使用第一次查询时生成的 ReadView,也就是以第一次查询时数据库里所有事务的提交状态来比对数据是否可见,多次查询自然就是相同的快照结果;读已提交隔离级别下,每次查询都会按照数据库当前状态重新生成 ReadView,每次查询都能读到已提交的最新数据。
4. 版本链比对规则
事务里的任何 SQL 查询结果,都需要从对应版本链里的最新数据开始,逐条跟 ReadView 做比对,从而得到最终的快照结果。比对规则如下:
- 如果 row 的
trx_id小于min_id,表示这个版本是已提交的事务生成的,这个数据是可见的; - 如果 row 的
trx_id大于max_id,表示这个版本是由将来启动的事务生成的,是不可见的,但如果 row 的trx_id就是当前自己的事务,则是可见的; - 如果 row 的
trx_id落在min_id和max_id之间,则包括两种情况:- 如果 row 的
trx_id在未提交事务数组中,表示这个版本是由还没提交的事务生成的,不可见,但如果 row 的trx_id就是当前自己的事务,则是可见的; - 如果 row 的
trx_id不在未提交事务数组中,表示这个版本是已经提交了的事务生成的,可见。
- 如果 row 的
举个例子:图中的 ReadView 为 [100, 200] 300,也就是 min_id = 100、max_id = 300、未提交事务数组为 [100, 200],从 blog 表版本链里的最新数据开始逐条比对:
juzi-blog4(trx_id = 200)、juzi-blog3(trx_id = 200):trx_id在未提交事务数组中,不可见;juzi-blog2(trx_id = 100)、juzi-blog1(trx_id = 100):trx_id也在未提交事务数组中,不可见;juzi-blog(trx_id = 300):trx_id落在min_id和max_id之间,但不在未提交事务数组中,说明是已提交事务生成的,可见。
所以最终比对到的快照结果就是 juzi-blog。
再结合上面的 MVCC 可见性算法示例图看 RR 和 RC 的区别:可重复读下的 select 1 一直使用第一次查询时生成的 ReadView [100, 200] 300,所以事务 100、200 提交后再次查询,结果仍然是 juzi-blog;读已提交下的 select 3 每次查询都会重新生成 ReadView,事务 100 提交后再查询就变成了 juzi-blog2。图中 select 2 是事务 100 提交后才进行第一次查询的可重复读会话,ReadView 为 [200] 300,所以它也一直读到 juzi-blog2。
5. 事务真正启动的时机
还需要注意:begin / start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个修改操作或加排它锁操作(比如 select ... for update)的语句时,事务才真正启动,才会向 MySQL 申请真正的事务 id,MySQL 内部是严格按照事务的启动顺序来分配事务 id 的。
MVCC 机制的实现就是通过 ReadView 机制与 undo 版本链比对机制,使得不同的事务会根据数据版本链对比规则,读取同一条数据在版本链上的不同版本数据。