目录
- 一、前言
- 二、MVCC解决了什么问题
- 三、MVCC核心 Undo Log 和 Read View 介绍
- 3.1、Undo Log(日志版本链)
- 3.2、Read View(一致性视图)
- 3.2.1、设计思路
- 3.2.2、ReadView判断规则
- 四、数据准备
- 五、举例探究MVCC机制
- 5.1、例子执行流程
- 5.2、关键步骤分析
- 5.2.1、第6步 #select 1中查询id为1的数据底层处理
- 5.2.2、第10步 #select 1中查询id为1的数据底层处理
- 5.2.3、第12步 # Transaction 30中事务内查询id为1的数据底层处理
- 5.2.4、第13步 # select 2中查询id为1的数据底层处理
- 5.2.5、第15步 # select 2中查询id为1的数据底层处理
- 5.3、已提交读的ReadView是如何生成的
一、前言
MySQL在读已提交和可重复读隔离级别下都实现了MVCC机制,MySQL在可重复读隔离级别下如何保证事务较高的隔离性,同样的sql查询语句在一个事务里多次执行查询结果相同,就算其它事务对数据有修改也不会影响当前事务sql语句的查询结果。
这个隔离性就是靠MVCC(Multi-Version Concurrency Control)机制来保证的,对一行数据的读和写两个操作默认是不会通过加锁互斥来保证隔离性,避免了频繁加锁互斥,而在串行化隔离级别为了保证较高的隔离性是通过将所有操作加锁互斥来实现的。
MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读,而这个读指的就是快照读, 而非当前读。当前读实际上是一种加锁的操作,是悲观锁的实现。而MVCC本质是采用乐观锁思想的一种方式。
-
快照读
快照读又叫一致性读,读取的是快照数据。不加锁的简单的 SELECT 都属于快照读,即不加锁的非阻塞读。
之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于MVCC,它在很多情况下,避免了加锁操作,降低了开销。既然是基于多版本,那么快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。 -
当前读
当前读读取的是记录的最新版本(最新数据,而不是历史版本的数据),读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。加锁的 SELECT,或者对数据进行增删改都会进行当前读。
事务隔离级别与锁机制,有需要可以跳转MySQL 事务隔离级别与锁机制详解查看。
二、MVCC解决了什么问题
MVCC 是通过数据行的多个版本管理来实现数据库的并发控制,简单来说它的思想就是保存数据的历史版本,我们可以通过比较版本号决定数据是否显示出来(具体的规则后面会介绍到),读取数据的时候不需要加锁也可以保证事务的隔离效果。
-
1、读写之间阻塞的问题,通过 MVCC 可以让读写互相不阻塞,即读不阻塞写,写不阻塞读,这样就可以提升事务并发处理能力。
-
2、降低了死锁的概率。这是因为 MVCC 采用了乐观锁的方式,读取数据时并不需要加锁,对于写操作,也只锁定必要的行。
-
3、解决一致性读的问题。一致性读也被称为快照读,当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结果,而不能看到这个时间点之后事务提交的更新结果。
三、MVCC核心 Undo Log 和 Read View 介绍
3.1、Undo Log(日志版本链)
undo日志版本链是指一行数据被多个事务依次修改过后,在每个事务修改完后,MySQL会保留修改前的数据undo回滚日志,并且用两个隐藏字段trx_id和roll_pointer把这些undo日志串联起来形成一个历史记录版本链(见下图)。
3.2、Read View(一致性视图)
在可重复读隔离级别,当事务开启,执行任何查询sql时会生成当前事务的一致性视图read-view,该视图在事务结束之前都不会变化(如果是读已提交隔离级别在每次执行查询sql时都会重新生成),InnoDB为每个事务构造了一个数组,用来记录并维护系统当前活跃事务的ID(“活跃”指的就是,启动了但还没提交)。
3.2.1、设计思路
-
使用
读未提交(READ UNCOMMITTED)
隔离级别的事务,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。 -
使用
串行化(SERIALIZABLE)
隔离级别的事务,InnoDB规定使用加锁的方式来访问记录。 -
使用
读已提交(READ COMMITTED)
和可重复读(REPEATABLE READ)
隔离级别的事务,都必须保证读到已经提交了的事务修改过的记录。假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是需要判断一下版本链中的哪个版本是当前事务可见的,这是ReadView要解决的主要问题。
这个ReadView中主要包含4个比较重要的内容,分别如下:
-
creator_trx_id
,创建这个 Read View 的事务 ID。说明:只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。
-
trx_ids
,表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。 -
up_limit_id
,活跃的事务中最小的事务 ID。 -
low_limit_id
,表示生成ReadView时系统中应该分配给下一个事务的id值。low_limit_id 是系统最大的事务id值,这里要注意是系统中的事务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。
3.2.2、ReadView判断规则
有了这个ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见。
- 如果被访问版本的
trx_id
属性值与ReadView中的creator_trx_id
值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。 - 如果被访问版本的
trx_id
属性值小于ReadView中的up_limit_id
值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。 - 如果被访问版本的
trx_id
属性值大于或等于ReadView中的low_limit_id
值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。 - 如果被访问版本的
trx_id
属性值在ReadView的up_limit_id
和low_limit_id
之间,那就需要判断一下trx_id属性值是不是在 trx_ids 列表中。- 如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。
- 如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
四、数据准备
先准备好数据用于后面做举例。
# 创建表
DROP TABLE IF EXISTS `account`;
CREATE TABLE `account` (
`id` INT ( 11 ) NOT NULL AUTO_INCREMENT,
`name` VARCHAR ( 255 ) DEFAULT NULL,
`balance` INT ( 11 ) DEFAULT NULL,
PRIMARY KEY ( `id` )
) ENGINE = INNODB COMMENT = '账户表';
# 插入数据
INSERT INTO `account` (`id`,`name`, `balance`) VALUES (1,'Kerwin',1000);
INSERT INTO `account` (`id`,`name`, `balance`) VALUES (2,'Alia',800);
INSERT INTO `account` (`id`,`name`, `balance`) VALUES (3,'Ross',900);
五、举例探究MVCC机制
这里会先分析可重复读事务隔离级别下的MVCC机制,已提交读流程基本一致除了每次查询时都会生成新的Read View(一致性视图)。
注意:还要明白一点begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个修改操作InnoDB表的语句,事务才真正启动,才会向MySQL申请事务id,MySQL内部是严格按照事务的启动顺序来分配事务id的,并且如果一个事务中只有查询操作是不会生成显式的事务id的。
5.1、例子执行流程
- 事务id是有序自增的我这里为了便于区分写成了20、30、100。
# Transaction 20 | # Transaction 30 | # Transaction 100 | # select 1 | # select 2 | |
---|---|---|---|---|---|
1 | begin; | begin; | begin; | begin; | begin; |
2 | update account set name=‘Alia20’ where id = 2; | ||||
3 | update account set name=‘Ross30’ where id = 3; | ||||
4 | update account set name=‘Kerwin100’ where id = 1; | ||||
5 | commit; | ||||
6 | select name from account where id = 1; --readview:[20,30], 100 name=‘Kerwin100’ | ||||
7 | update account set name=‘Kerwin20’ where id = 1; | ||||
8 | update account set name=‘Kerwin21’ where id = 1; | ||||
9 | commit; | ||||
10 | select name from account where id = 1; --readview:[20,30], 100 name=‘Kerwin100’ | ||||
11 | update account set name=‘Kerwin30’ where id = 1; | ||||
12 | select name from account where id = 1; --readview:[30], 100 name=‘Kerwin30’ | ||||
13 | select name from account where id = 1; --readview:[30], 100 name=‘Kerwin21’ | ||||
14 | commit; | ||||
15 | select name from account where id = 1; --readview:[30], 100 name=‘Kerwin21’; |
5.2、关键步骤分析
5.2.1、第6步 #select 1中查询id为1的数据底层处理
这一步查询出来的name=‘Kerwin100’,但是# Transaction 100和# select 1是同时开启事务的,在第6步# Transaction 100提交了事务,# select 1是能查询出来,这就代表了Read View(一致性视图)是在第一次查询的时候产生的,如果是在开启事务的时候就产生的那么这里查询出来的name应该还是Kerwin。
来分析一下这一步操作的Undo Log(日志版本链)和 Read View(一致性视图)长什么样:
我们一共有3个事务id,20、30、100,执行到第7步,事务id为100的事务已经提交了并且是最大事务id,所以我们这里的ReadView为 [20,30], 100,活跃事务id有20、30,最大事务id为100,当我们查询的时候会拿着UndoLog中的数据和这个ReadView比对,UndoLog中第一条数据事务id为100在未提交与已提交事务区间,在判断是否存在活跃事务id中,这里活跃事务id有20、30,那么事务id为100的事务就已经提交了,取出第一条数据即可。
5.2.2、第10步 #select 1中查询id为1的数据底层处理
在这一步中查询出来还是name=‘Kerwin100’,但是# Transaction 20事务已经提交, #select 1中查询name还是Kerwin100,这就说明了读取的是副本数据不是最新的数据。
来分析一下这一步操作的Undo Log(日志版本链)和 Read View(一致性视图)长什么样:
因为# Transaction 20中执行了两次更新name操作,这里UndoLog也会增加两条数据,因为我们的事务隔离级别是可重复读所以#select 1在第一次查询时就会生成一个ReadView,后面再查询都是使用的第一次查询生成的ReadView,所以这里ReadView还是为 [20,30], 100,第11步 #select 1中查询id为1的数据,先会取出UndoLog中第一条数据事务id为20和ReadView比对,因为事务id为20在活跃事务数组中,所以不满足会继续取出下一条数据进行一样的判断,直到取到事务id为100的数据,事务id为100不在活跃事务id数组中,那么事务id为100的事务就已经提交了。
5.2.3、第12步 # Transaction 30中事务内查询id为1的数据底层处理
在#Transaction 30中先更新了id为1的数据,接着就能查询出刚刚更新的数据,这是因为ReadView中还会存储一个creator_trx_id(创建这个 Read View 的事务 ID,可以理解成当前事务ID),逐条拿UndoLog中的数据和这个ReadView比对,比对是会先判断UndoLog数据的事务id是否为creator_trx_id,如果为creator_trx_id则取出数据,如果不为creator_trx_id则进行后续判断。
来分析一下这一步操作的Undo Log(日志版本链)和 Read View(一致性视图)长什么样:
因为在#Transaction 30中先更新了id为1的数据还没有提交,这里UndoLog也会增加一条,但是因为#Transaction 30还未提交所以更新后在当前事务查询ReadView为 [30], 100,creator_trx_id=30,取出UndoLog第一条数据和ReadView比对,事务id等于creator_trx_id所以第一条数据满足条件直接取出。
5.2.4、第13步 # select 2中查询id为1的数据底层处理
在这一步中查询出来还是name=‘Kerwin21’,因为这一步是 # select 2第一次查询,并且# Transaction 20事务已经提交,所以这里可以获取到# Transaction 20提交后的数据。
来分析一下这一步操作的Undo Log(日志版本链)和 Read View(一致性视图)长什么样:
因为# Transaction 20已经提交事务,# select 2第一次查询时生成的ReadView的活跃事务id数组就只有30了,最大事务id还是100,所以这里ReadView为 [30], 100,先会取出UndoLog中第一条数据事务id为30和ReadView比对,事务id为30的事务还在活跃事务数组中,所以不满足会继续取出下一条数据进行一样的判断,第二条事务id为20的事务小于最小活跃事务id事务已提交,取出第二条数据即可。
5.2.5、第15步 # select 2中查询id为1的数据底层处理
在这一步中查询出来还是name=‘Kerwin21’,因为 # select 2第一次查询已经生成了ReadView,就算# Transaction 30事务已经提交,ReadView也是不会改变的。
来分析一下这一步操作的Undo Log(日志版本链)和 Read View(一致性视图)长什么样:
这一步和第13步逻辑是一样的。
5.3、已提交读的ReadView是如何生成的
在可重复读中,一个事务开启后第一次查询就会生成一个ReadView,在之后的查询中都不会在改变,而已提交读会在每次查询的时候都生成一个新的ReadView,保证了每次都能读取到已经提交的数据。
例如:
# Transaction 100 | # select 1 | |
---|---|---|
1 | begin; | begin; |
2 | select name from account where id = 1; --readview:[100], 100 name=‘Kerwin’ | |
3 | update account set name=‘Kerwin100’ where id = 1; | |
4 | commit; | |
5 | select name from account where id = 1; --readview:[], 100 name=‘Kerwin100’ |