MVCC
MVCC(Multiversion Concurrency Control 多版本并发控制)是一种思想,为了解决读写的并发控制,做到即使有读写冲突时,也能通过不加锁,实现非阻塞并发读。
在 MySQL 通过 Read View 隐藏字段和 undolog 实现了 MVCC。大致思路就是通过生成一个 Read View,并判断当时哪些记录是可见的。
下面详细讲一下。
什么是当前读和快照读
-
当前读
像
select lock in share mode
(共享锁),select for update
;update
;insert
;delete
(排他锁),这些操作都是一种当前读,为啥叫当前读?因为他要读取的是当前记录的最新版本,读取的时候还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。 -
快照读
不加锁的
select
就是快照读。快照读的前提是隔离级别不是串行级别,串行级别下会退化成当前读。(都已经这么严格的串行级别了嘛)快照读是为了提高并发性能,是基于 MVCC 实现的,可以认为 MVCC 是行锁的一个变种,在很多情况下,避免了加锁操作,降低了开销。既然是基于多版本,所以快照读可能读到的不一定是最新的数据,可能是之前的历史版本。
MVCC 能在大部分场景下解决幻读的问题,其主要方式就是通过 ReadView 来判断当前能看到哪些记录:
- 快照读:直接读 ReadView 内记录即可,也不会有不一致问题。
- 当前读:为了防止其他事务同时修改相同数据,此时会通过加上临键锁(Next-Key Lock)来锁住当前记录及其周边范围的记录(Gap Lock + Record Lock)。
MVCC 的实现原理
主要依赖:
- 3个隐式字段
- undo日志
- ReadView
隐式字段
- db_trx_id:最近已提交的事务ID,记录最后一次创建/修改这条记录的事务ID
- db_roll_ptr:回滚指针,指向这条记录的上一个版本(存储在 rollback segment里)
- db_row_id:隐含的自增主键
undo log
主要分两种:
-
insert undo log
事务在
insert
新记录时产生的undo log
,只在事务回滚时需要,事务提交后可以被立即丢弃。 -
update undo log
事务在
update
或delete
时产生的undo log
,不仅在事务回滚时需要,在快照读时也需要;不能随便删,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被purge
线程统一清除。
Read View
主要理解三个属性(名字瞎给的,不一定对):
-
active_trx_ids
在创建 Read View 时,当前数据库中「活跃事务」的
事务 id 列表
。「活跃事务」指的就是,启动了但还没提交的事务。 -
min_active_trx_id
在创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是
active_trx_ids
的最小值。 -
max_trx_id
在创建 Read View 时,当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 +1。
不是
active_trx_ids
中的最大值,active_trx_ids
只是「活跃事务」的事务 id 列表,当前数据库可能还有其他已提交的事务,所以不能认为max_trx_id
是trx_ids
中的最大值 +1。 -
createtor_trx_id
创建该 Read View 事务的事务 id。
所有的事务 id 分布示意如下图所示: 根据上图,当一个事务创建 Read View 去访问数据库记录时,除了自己的更新记录总是可见外,还会有以下几种情况:
-
访问的记录的
db_trx_id
小于 Read View 中min_active_trx_id
值,表示这个版本的记录是在创建 Read View 前就已经提交事务了,所以该版本的记录对当前事务是可见的。 -
访问的记录的
db_trx_id
大于等于 Read View 中max_trx_id
值,表示这个版本的记录是在创建 Read View 后的事务中生成的,所以该版本的记录对当前事务是不可见的。 -
访问的记录的
db_trx_id
在min_active_trx_id
和max_trx_id
之间,需要进一步判断:- 如果
db_trx_id
在active_trx_ids
中,表示该版本的记录的事务还活跃着,所以该版本的记录对当前事务不可见。 - 如果
db_trx_id
不在active_trx_ids
中,表示该版本的记录的事务已提交,所以该版本的记录对当前事务是可见的。
- 如果
上述讲的只是 ReadView 中的记录可见范围哦,和 RC、RR 这种都没关系。
RC 能读每次提交后的数据,是因为 RC 会在每次 select 前去创建 ReadView。
RR 能保证在同一个事务中重复读都一样,是因为 RR 在创建事务的时候就去创建了 ReadView。
回顾下上面说 MVCC 也可能会出现幻读问题,什么样的场景呢?
-> 当前读和快照读交替场景下。
假设数据库没有 id = 2 的记录,现在事务 A 先快照读:
select empty where id = 2;
,很明显返回空(但事务还没关闭哦)。然后事务 B 插入了一条 id = 2 的记录。
接着事务 A 这么去更新了 id = 2 的记录:
update T set name = 'Jack' where id = 2;
,然后再 select id = 2 的记录,此时事务 A 内就能看到这条记录了。因为事务 A 更新了 id = 2 的记录后,那条记录上的
db_trx_id
就是事务 A 的了,当后面再去快照读的时候,自然而然能看到这条数据了。
举例说明
示例一
person 表中初始有条记录:{"name": "Jerry", "age": 17}
,经历了以下几个操作:
- 事务 1 修改 name 为 Tom
- 事务 2 接着修改 age 为 18
数据库的操作流程如下:
可以看到,对同一记录的不断修改,会在 undo log
不断增长这条记录的版本链表,链首就是最新的上一条记录,链尾就是最早的记录(最早的这条记录不一定是数据最开始的记录,事务已结束的 undo log 可能会被 purge 线程清理掉)。
示例二
事务一 | 事务二 | 事务三 | 事务四 |
---|---|---|---|
事务开始 | 事务开始 | 事务开始 | 事务开始 |
... | ... | ... | 修改且已提交 |
进行中 | 快照读 | 进行中 | |
... | ... | ... |
当「事务二」开始「快照读」时,假设当前事务 id 为 2,此时 事务 1
和 事务 3
还在活跃中,事务 4
在 事务 2
快照读前提交了,所以当前的事务 id 分布如下:
- 事务 2 查看到此时这条记录的
1 < db_trx_id = 4 < 5
,所以继续看当前事务下这条记录的db_trx_id
是否在active_trx_ids
里面; db_trx_id = 4
不在active_trx_ids
里面,该记录版本对当前事务可见。
所以可得出当前事务 2 创建的 Read View 读取到的这条记录是 db_trx_id = 4
时记录的值。