MySQL 之多版本并发控制 MVCC
- [1、MVCC 中的两种读取方式](#1、MVCC 中的两种读取方式)
- [2、MVCC实现原理之 ReadView](#2、MVCC实现原理之 ReadView)
-
- 2.1、隐藏字段
- 2.2、ReadView
- [2.3、读已提交和可重复读隔离级别下,产生 ReadView 时机的区别](#2.3、读已提交和可重复读隔离级别下,产生 ReadView 时机的区别)
- [3、MVCC 解决幻读](#3、MVCC 解决幻读)
- 4、总结
MVCC(多版本并发控制) 没有正式的标准,在不同的 DBMS 中MVCC的实现方式可能不同,本文中讲解的是 InnoDB 中 MVCC 的实现机制
(MySQL 其它的存储引擎并不支持 MVCC).
1、MVCC 中的两种读取方式
MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能,用更好的方式去处理 读-写冲突,做到即使有读写冲突,也能做到 不加锁,非阻塞并发读,而这个读指的就是 快照读,而非当前读。
当前读实际上是一种加锁的操作,是悲观锁的实现。而MVCC本质是采用乐观锁思想的一种方式。
快照读和当前读是 MVCC 中的两种读取方式。MVCC 通过快照读和当前读两种方式,在保持事务隔离性的同时,提高了数据库的并发性能
。
1.1、快照读
快照读(又叫一致性读),读取的是快照数据,即不加锁的非阻塞读。不加锁的简单 select 都属于快照读
。快照读的实现是基于 MVCC(多版本并发控制),在很多情况下,快照读避免了加锁操作,降低了开销。
- 由于
快照读基于 MVCC(多版本并发控制)
,所以快照读可能读到的并不一定是数据的最新版本,很有可能读的是之前的历史版本数据。 快照读的前提是隔离级别不是串行级别
,串行级别下的快照读会退化成当前读。
1.2、当前读
当前读
读取的记录是最新版本的数据
,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。加锁的 SELECT,或者对数据进行增删改都会进行当前读。比如:
sql
# 共享锁
select * from tb_table lock in share mode;
# 排它锁
select * from tb_table for update;
# 排它锁
insert into tb_table values ...
# 排它锁
delete from tb_table where ...
# 排他锁
update tb_table set ...
2、MVCC实现原理之 ReadView
MVCC 的实现依赖于:隐藏字段、undo log、ReadView.
2.1、隐藏字段
对于InnoDB引擎来说,每个行记录除了记录本身的数据之外,还存在几个隐藏的列。
DB_ROW_ID
:如果没有为表显式的定义主键,并且表中也没有定义唯一索引,那么InnoDB会自动为表添加一个 row_id 的隐藏列作为主键。DB_TRX_ID
:每个事务都会分配一个事务ID,当对某条记录发生变更时,就会将这个事务的事务ID写入 trx_id 中。DB_ROLL_PTR
:回滚指针,本质上就是指向 undo log 的指针。
2.2、ReadView
在 MVCC 机制中,多个事务对同一行记录A,进行更新会产生多个历史快照,这个历史快照保存在 undo log 中。当一个事务想要查询 记录A 这行数据时,需要读取哪个版本的记录将是一个问题。ReadView 就可以解决以上 行可见性问题。
在读未提交的隔离级别下事务,查询的数据都是最新版本,所以不需要MVCC。在串行化隔离级别下的事务,InnoDB 规定使用加锁的方式来访问记录,也不需要MVCC。
ReadView 就是事务在使用 MVCC 机制进行快照读操作时产生的读视图。ReadView 主要包含了以下4个重要内容:
-
①
creator_trx_id
,创建该 ReadView 的事务ID.只要在对表中的记录做改动时(执行 INSERT、DELETE、UPDATE 这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id只都默认为0.
-
②
trx_ids
,表示生成 ReadView 时,当前系统中活跃的读写事务的事务id列表
。 -
③
up_limit_id
,活跃的事务中最小的事务ID. -
④
low_limit_id
,表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。low_limit_id 是系统最大的事务ID值.low_limit_id 并不是trx_ids 中的最大值,事务ID是递增分配的。比如:现在有ID为1、2、3这三个事务,之后ID为3的事务提交了,那么一个新的读事务在生成 ReadView 时,trx_ids 就包括1 和2,up_limit_id 的值就是1,low_limit_id的值就是4.
ReadView 的规则:在访问某条记录时,需要按照以下步骤(ReadView 的规则)判断记录的某个版本是否可见
- 如果被访问版本的 trx_id(db_trx_id) 属性值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
- 如果被访问版本的 trx_id(db_trx_id) 属性值小于 ReadView 中的 up_limit_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
- 如果被访问版本的 trx_id(db_trx_id) 属性值大于或等于 ReadView 中的 low_limit_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。
- 如果被访问版本的 trx_id(db_trx_id) 属性值在 ReadView 的 up_limit_id 和 low_limit_id 之间,就需要判断一下 trx_id 属性值是否在 trx_ids 列表中。
- 如果在,说明创建 ReadView 时,生成该版本的事务还是活跃的,该版本不可以被访问。
- 如果不在,说明创建 ReadView时,生成该版本的事务已经被提交,该版本可以被访问。
MVCC 整体操作流程:
查询一条记录时,系统通过MVCC机制查找记录的流程:
- ① 首先获取事务自己的版本号,也就是事务ID.
- ② 获取 ReadView.
- ③ 查询得到的数据,然后与 ReadView 中的事务版本号进行比较。
- ④ 如果不符合 ReadView 规则,就需要从 undo log 中获取历史快照。
- ⑤ 最后返回符合规则的数据。
如果某个版本的数据对当前事务不可见,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。
InnoDB 中,MVCC 是通过 Undo Log + ReadView 进行数据读取, Undo Log 保存了历史快照,而 Read View 规则帮我们判断当前版本的数据是否可见。
2.3、读已提交和可重复读隔离级别下,产生 ReadView 时机的区别
-
在隔离级别为
读已提交
(Read Commit)时,一个事务中的每一次 SELECT 查询都会重新获取一次 ReadView.步骤 事务 说明 1 begin; 2 select * from tb_table where id > 2; 获取一次 ReadView 3 ...... 4 select * from tb_table where id > 2; 获取一次 ReadView 5 commit; - 步骤2和步骤4,一样的查询语句,在读已提交的隔离级别下,会分别获取一次 ReadView,如果两次的ReadView 不同,就可能产生不可重复读或者幻读的情况。
-
当隔离级别为
可重复读
的时候,一个事务只在第一次 SELECT 时会获取一次 ReadView,而后面所有的 SELECT 都会复用这个 ReadView,所以可以避免不可重复读。
3、MVCC 解决幻读
使用例子说明 InnoDB 如何解决幻读,以下示例是在InnoDB引擎的可重复读隔离级别下讨论。现在 tb_table 表中已存在一条 主键id = 1 的数据,该条记录隐藏的 db_trx_id = 10。
db_trx_id = 10 | 数据:id = 1,name = 张三 | NULL |
---|
假设现有 事务A 和 事务B 并发执行,事务A 的事务id 为 20,事务B 的事务id 为 30.
-
步骤 1:事务A 开始第一次查询数据。
-- 事务A 开始第一次查询数据
select * from tb_table where id >=1;
① --> 在开始查询前,MySQL 会为事务A产生一个 ReadView,此时 ReadView 的内容如下:trx_ids = [20,30],up_limit_id = 20,low_limit_id = 31,creator_trx_id = 20.
② --> 执行select * from tb_table where id >=1;查出id=1,db_trx_id= 10的数据,按ReadView机制判断该行数据是否可见。
③ --> 按 ReadView 机制 该查询出的数据 db_trx_id = 10 ,小于事务A的ReadView 的up_limit_id,故该数据在事务A 开启之前,其他事务已提交,所以该行数据可见。
结论:事务A 的第一次查询,能读到一条数据,id=1,name= 张三。
-
步骤2:接着事务B(事务id=30),往tb_table 插入两条数据,并提交事务(假设不考虑 gap lock)。
sqlinsert into student(id,name) values (2,'李四'); insert into student(id,name) values (3,'王五');
此时 tb_table 中就要三条数据了,对应的 undo log 如下。
-
步骤3:接着事务A开启第二次查询,由于在可重复读隔离级别下,事务A不会再次生成ReadView.
-- 事务A 开始第二次查询数据
select * from tb_table where id >=1;
① --> 此时tb_table中的3条数据都满足 id>=1 的条件,因此会先查出来,然后根据 ReadView 机制,判断每条数据是不是都可以被事务A可见。
② --> 首先 id = 1 这条数据同步骤1,对事务A可见。
③ --> 然后是 id = 2 的数据,它的 trx_id = 30,介于 ReadView 的 up_limit_id = 20 和 low_limit_id = 31 之间,需要再次判断 trx_id = 30 是否处于 ReadView 的 trx_ids=[20,30] 数值内。结果在trx_ids数组内,表示 id= 2 这条数据是与事务A 在同一时刻启动的其他事务提交的,所以这条数据不能被事务A可见。
④ --> 同理,id = 3 这条数据,trx_id 也为 30,因此也不能被事务A 可见。
结论:最终事务A 的第二次查询,只能查询出 id = 1 的这条数据,这和事务A 的第一次查询的结果一样,因此没有出现不可重复读现象
注意 :只靠MVCC不能完全解决幻读问题
。虽然MVCC可以提供事务的一致性视图,并保持数据的一致性版本,但它本身并不能防止其他事务插入新的数据。通过结合 next-key locking 和 MVCC,InnoDB 在可重复读隔离级别下解决了幻读问题,确保了事务在多次读取相同范围数据时的一致性
。
间隙锁是在 innodb的 可重复读级别才会生效,且 Next-Key Locks 实际上是由间隙锁加行锁实现的
。MySQL 在 InnoDB 存储引擎下,可重复读的隔离级别下,不存在幻读问题
。
4、总结
MVCC(多版本并发控制)的核心点在于 ReadView 的原理,READ COMMITTED、REPEATABLE READ 这两个隔离级别的一个很大不同就是生成 ReadView 的时机不同:
READ COMMITTED
在每一次
进行普通 select 操作前,都会生成一个 ReadView
.REPEATABLE READ
只在第一次
进行普通 select 操作前生成一个 ReadView
,之后的查询操作都重复使用
这个 ReadView 就好了。
MVCC(多版本并发控制)解决的问题:
- ①
读写之间阻塞的问题
。通过 MVCC 可以让读写相互不阻塞,即读不阻塞写,写不阻塞读,可以提升事务并发处理能力。 - ②
降低了死锁的概率
。因为 MVCC 采用了乐观锁的方式,读取数据时并不需要加锁,对于写操作,也只锁定必要的行。 - ③
解决快照读的问题
。当查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结果,而不能看到这个时间点之后事务提交的更新结果。