为什么需要MVCC
实现隔离性最简单的方式是串行化,而实现串行化最简单的办法就是加锁,但是很多应用的一个特点都是读多写少的场景,而读取数据间互相排斥显得不是很必要。所以就使用了一种读写锁的方法,读锁和读锁之间不互斥,而写锁和写锁、读锁都互斥。这样就很大提升了系统的并发能力。
但是之后人们发现并发读还是不够,又提出了能不能让读写之间也不冲突的方法,就是读取数据时通过一种类似快照的方式将数据保存下来,这样读锁就和写锁不冲突了,不同的事务会看到自己特定版本的数据,这样就又进一步提升了读-写事务的并发能力,这就是 MVCC 的基本原理。
InnoDB 实现MVCC原理
对于使用InnoDB 存储引擎来说,聚簇索引记录中都包含下面2个必要的隐藏列:
- trx_id
一个事务每次对某条聚簇索引记录进行改动时,都会把事务的事务ID赋值给trx_id
隐藏列。 - roll_pointer
每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo 日志中,这个隐藏列相当于一个指针,可以通过它找到该记录修改前的信息。
假设插入该记录的事务ID 为80,那么此刻该条记录如下:
假设之后,事务ID分别为100,200 的事务对这条记录进行 UPDATE 操作。操作流程如下:
时间 | trx100 | trx200 |
---|---|---|
1 | BEGIN | |
2 | BEGIN | |
3 | UPDATE hero SET name='关羽' WHERE number=1 | |
4 | UPDATE hero SET name='张飞' WHERE number=1 | |
5 | COMMIT | |
6 | UPDATE hero SET name='赵云' WHERE number=1 | |
7 | UPDATE hero SET name='诸葛亮' WHERE number=1 | |
8 | COMMIT |
(这里是否可以在两个事务中交叉更新同一条记录呢?不可以,因为这就是一个事务修改了另一个未提交事务修改过的数据,沦为脏写。InnoDB 使用锁来保证不会出现脏写现象。也就是第一个事务更新某条记录前,就会给这条记录加锁,另一个事务再次更新该记录时,就需要等待第一个事务提交。)
每对记录进行一次改动,都会记录一条 undo 日志。每条 undo 日志 也都有一个 roll_pointer
属性(inert 操作对应的undo 日志没有该属性,因为insert操作的记录没有更早的版本)。通过这些属性可以将这些 undo 日志串成一个链表:
这个链表被称为版本链,版本链的头节点就是当前记录的最新值,同时,每个版本中也还包括了生成该版本时对应的事务ID,之后会利用这个记录的版本链来控制并发事务访问相同记录时的行为。
ReadView
事务隔离级别 | 脏写 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|---|
未提交读(READ UNCOMMITTED) | 否 | 是 | 是 | 是 |
已提交读(READ COMMITTED) | 否 | 否 | 是 | 是 |
可重复读(REPEATABLE READ) | 否 | 否 | 否 | 是 |
串行化(SERIALIZABLE) | 否 | 否 | 否 | 否 |
对于使用 READ UNCOMMITTED 隔离级别来说,由于可以读到未提交事务修改过的记录(脏读),所以直接读取记录的最新版就好了。
对于使用 READ COMMITTED 和 REPEATABLE READ 隔离级别的事务来说,必须保证读到已经提交的事务修改过的记录,也就是说假设另一个事务已经修改了记录但还未提交,则不能直接读取最新版本的记录。所以对于 MVCC 而言,这里的核心问题是:需要判断版本链中哪个版本是当前事务可见的?
为了解决这问题,提出了 ReadView (一致性视图)的概念。
这个 ReadView 主要包括4个比较重要的内容:
- m_ids 在生成 ReadView 时,当前系统中活跃的读写事务的事务id列表
- min_trx_id 在生成 ReadView 时,当前系统中活跃的读写事务中最小的事务ID
- max_trx_id 在生成 ReadView 时,系统应该分配给下一个事务的事务id值
- creator_trx_id 生成该 ReadView 的事务的事务id
有了这个,在访问某条记录时,只需要按照下面的步骤来判断记录的某个版本的可见性:
如果被访问的版本的
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 时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见,那就顺着版本链找到下一个版本的数据,并继续执行上面的步骤来判断记录的可见性;依次类推,直到版本链中的最后一个版本,如果记录中最后一个版本也不可见,就意味着该条记录对当前事务完全不可见,查询结果就不包含该记录。
在 MySQL 中,READ COMMTTE 和 REPEATABLE READ 隔离级别之间一个非常大的区别就是它们生成的 Read View 时机不同。
假设现在表 hero 中只有一条事务id 为 80 的事务插入的记录。
READ COMMITTED -- 每次读取数据前都生成一个 ReadView
比如,现在系统中有两个事务id分别为100,200 的事务正在执行:
时间 | trx100 | trx200 |
---|---|---|
1 | BEGIN | |
2 | BEGIN | |
3 | UPDATE hero SET name='关羽' WHERE number=1 | |
4 | 更新一些其他表的记录 | |
5 | UPDATE hero SET name='张飞' WHERE number=1 |
那么此时 number 为 1 的记录对应版本链如图:
假设现在使用一个 READ COMMITTED 隔离级别的事务开始执行(是新事务,不是事务id 为100,200的那两个事务)SELECT 语句,查找这个 number 为 1 的记录,那么这个执行过程如下:
先生成一个 READVIEW,READVIEW 的
m_ids
列表内容就是 [100, 200],min_trx_id
为 100,max_trx_id
为201,creator_trx_id
为 0。然后从版本链中挑选可见的记录,最新版本的 name 列内容是 '张飞',该版本的
trx_id
值也为100,也在m_ids
列表内,因此也不符合要求,继续跳到下一个版本。下一个版本的 name 列的 内容是'关羽',该版本的
trx_id
值也为100,也在m_ids
列表内,因此不符合要求;继续跳到下一个版本。下一个版本的 ame 列的 内容是'刘备' ,该版本的
trx_id
值为80,小于READVIEW中的min_trx_id
值100,所以这个版本是符合要求的;最后返回给用户的就是这条记录
之后,我们把事务id 为100的事务进行提交(事务200的还是未提交):
时间 | trx100 | trx200 |
---|---|---|
1 | BEGIN | |
2 | BEGIN | |
3 | UPDATE hero SET name='关羽' WHERE number=1 | |
4 | 更新一些其他表的记录 | |
5 | UPDATE hero SET name='张飞' WHERE number=1 | |
6 | commit | |
7 | UPDATE hero SET name='赵云' WHERE number=1 | |
8 | UPDATE hero SET name='诸葛亮' WHERE number=1 |
此时,表 hero 中 number 为 1 的记录的版本链如图所示:
然后,再到刚才使用 READ COMMITTED 隔离级别的事务中执行 SELECT 语句,继续查找这个 number 为 1 的记录,那么这个 执行过程如下:
在执行 SELECT 语句时又会单独生成一个 READVIEW,READVIEW 的
m_ids
列表内容就是 [200],因为事务100 已经提交了,min_trx_id
为 200,max_trx_id
为201,creator_trx_id
为 0。然后从版本链中挑选可见的记录,最新版本的 name 列内容是 '诸葛亮',该版本的
trx_id
值也为200,在m_ids
列表内,因此也不符合要求,继续跳到下一个版本。下一个版本的 name 列的 内容是'赵云',该版本的
trx_id
值也为200,也在m_ids
列表内,因此不符合要求;继续跳到下一个版本。下一个版本的 ame 列的 内容是'张飞' ,该版本的
trx_id
值为100,小于READVIEW中的min_trx_id
值200,所以这个版本是符合要求的;最后返回给用户的就是这条记录
依次类推,如果事务id 为 200 的记录也提交了,再次使用 READ COMMITTED 隔离级别的事务中查询表 hero 中 number 为 1 的记录时,得到的结果就是 '诸葛亮' 了。
总结出来:使用 READ COMMITTED 隔离级别的事务在每次查询开始时都会生成一个独立的 ReadView
REPEATABLE READ -- 在第一次读取数据时生成一个 ReadView
对于使用 REPEATABLE READ 隔离级别的事务来说,只会在第一次执行查询语句时生成一个,之后的查询就不会重复生成 了。
还是,老例子,比如系统中有两个事务id 分别为100,200的事务正在执行:
此时,表 hero 中 number 为 1 的记录的版本链表如图所示:
假设现在有个一个使用 REPEATABLE READ 隔离级别的新事物开始执行,那么这个执行过程如下:
先生成一个 READVIEW,READVIEW 的
m_ids
列表内容就是 [100, 200],min_trx_id
为 100,max_trx_id
为201,creator_trx_id
为 0。然后从版本链中挑选可见的记录,最新版本的 name 列内容是 '张飞',该版本的
trx_id
值也为100,也在m_ids
列表内,因此也不符合要求,继续跳到下一个版本。下一个版本的 name 列的 内容是'关羽',该版本的
trx_id
值也为100,也在m_ids
列表内,因此不符合要求;继续跳到下一个版本。下一个版本的 name 列的 内容是'刘备' ,该版本的
trx_id
值为80,小于READVIEW中的min_trx_id
值100,所以这个版本是符合要求的;最后返回给用户的就是这条记录
之后,我们把事务id为100的 事务进行提交(事务200的还是未提交):
时间 | trx100 | trx200 |
---|---|---|
1 | BEGIN | |
2 | BEGIN | |
3 | UPDATE hero SET name='关羽' WHERE number=1 | |
4 | 更新一些其他表的记录 | |
5 | UPDATE hero SET name='张飞' WHERE number=1 | |
6 | commit | |
7 | UPDATE hero SET name='赵云' WHERE number=1 | |
8 | UPDATE hero SET name='诸葛亮' WHERE number=1 |
此时表中的 记录的版本链如图:
然后,再到刚才使用 REPEATABLE READ 隔离级别的事务中执行 SELECT 语句,继续查找这个 number 为 1 的记录,那么这个 执行过程如下:
REPEATABLE READ 隔离级别复用之前的READVIEW ,READVIEW 的
m_ids
列表内容就是 [100, 200],min_trx_id
为 100,max_trx_id
为201,creator_trx_id
为 0。然后从版本链中挑选可见的记录,最新版本的 name 列内容是 '诸葛亮',该版本的
trx_id
值也为200,也在m_ids
列表内,因此也不符合要求,继续跳到下一个版本。下一个版本的 name 列的 内容是'赵云',该版本的
trx_id
值也为200,也在m_ids
列表内,因此不符合要求;继续跳到下一个版本。下一个版本的 name 列的 内容是'张飞' ,该版本的
trx_id
值为100,也在m_ids
列表内,因此不符合要求;继续跳到下一个版本。同理,下一个版本 name 列的 内容是'刘备',也不符合要求,继续跳转到下一个版本。下一个版本的 name 列的 内容是'刘备' ,该版本的
trx_id
值为80,小于READVIEW中的min_trx_id
值100,所以这个版本是符合要求的;最后返回给用户的就是这条记录
上面看出,在 REPEATABLE READ 事务的两次查询结果是一样的,记录的name列值都是'刘备',这就是可重复读的含义。如果再把之前事务id为200的记录进行提交,然后使用刚才的隔离级别继续查找,那么得到的结果还是'刘备'。