搞懂Mysql之InnoDB MVCC

1. 前言

事务有四大特性ACID: 原子性Atomicity一致性Consistency隔离性Isolation持久性Durability

其中隔离性是通过数据库加上MVCC(多版本并发控制)来保证的

1.1 当前读

当前读读取的是记录的最新版本,同时在读取的时候还要保证其他的并发事务不能更改当前记录,那么当前读会对它要读取的记录进行加锁。不同的操作会加上不同类型的锁:

  1. 共享锁:SELECT ... LOCK IN SHARE MODE

  2. 排它锁:SELECT ... FOR UPDATE、UPDATE、INSERT、DELETE

  3. 串行化事务隔离级别

1.2 快照读

简单的不加锁的SELECT就是快照读,快照读读取的是快照生成时的数据,不一定时最新的数据,有可能时之前的历史版本数据,它是不加锁的非阻塞度。而在不同隔离级别下,创建快照的时机也不同:

  • READ-COMMITTED(读已提交):事务每次SELECT时创建ReadView

  • REPEATABLE-READ(可重复读):事务第一次SELECT时创建ReadView,后续一致使用

在Mysql默认隔离级别(REPEATABLE-READ)下,快照读保证了数据的可重复读。

2. 什么是MVCC

MVCC全称:Multi-Version Concurrency Control,即多版本并发控制。它是一种并发控制的方法,它可以维护一个数据的多个版本,用更好的方式去处理读写冲突,做到即使有读写冲突也能不加锁。

Mysql中MVCC的具体实现还需要依赖于表中的三个隐藏字段Undo log日志ReadView

下面就来详细的讲解一下MVCC的具体实现。

2.1 三个隐藏字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mysql> SHOW CREATE TABLE t_stock \G;
*************************** 1. row ***************************
Table: t_stock
Create Table: CREATE TABLE `t_stock` (
`id` int NOT NULL AUTO_INCREMENT,
`commodity_code` varchar(255) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
`count` int DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `commodity_code` (`commodity_code`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8
1 row in set (0.00 sec)

mysql> select * from t_stock;
+----+----------------+--------+-------+
| id | commodity_code | name | count |
+----+----------------+--------+-------+
| 1 | C201901140001 | 水杯 | 1000 |
+----+----------------+--------+-------+
1 row in set (0.00 sec)

在上面的商品表中可以看到只有:id、commodity_code、name、count字段,实际上除了几个字段外,InnoDB引擎还自动为我们添加了三个隐藏字段。

字段 含义
DB_TRX_ID 最近修改事务ID,记录插入这条记录或最后一次修改该记录的事务ID
DB_ROLL_PTR 回滚指针,指向这条记录的上一个版本,用于配合Undo Log,指向上一个版本
DB_ROW_ID 隐藏主键,如果表结构没有指定主键,将会生成该隐藏字段

先查看表数据的存放地址:

1
2
3
4
5
6
mysql> show variables like 'datadir';
+---------------+------------------------+
| Variable_name | Value |
+---------------+------------------------+
| datadir | /usr/local/mysql/data/ |
+---------------+------------------------+

然后使用ibd2sdi工具查看表空间文件中提取序列化的字典信息(SDI):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
xiaoyuge@xiaoyuge-2 / % cd /usr/local/mysql/data/seata 
xiaoyuge@xiaoyuge-2 seata % ibd2sdi ./t_stock.ibd
["ibd2sdi"
,
{
true"type": 1,
true"id": 4466,
true"object":
truetrue{
"mysqld_version_id": 80023,
"dd_version": 80023,
"sdi_version": 80019,
"dd_object_type": "Table",
"dd_object": {
"name": "t_stock",
"mysql_version_id": 80023,
"created": 20230701012559,
"last_altered": 20230701012559,
"hidden": 1,
"options": "avg_row_length=0;encrypt_type=N;key_block_size=0;keys_disabled=0;pack_record=1;stats_auto_recalc=0;stats_sample_pages=0;",
"columns": [
{
"name": "id",
"type": 4,
"is_nullable": false,
"is_zerofill": false,
"is_unsigned": false,
"is_auto_increment": true,
"is_virtual": false,
"hidden": 1,
//... 省略
},
{
"name": "commodity_code",
"type": 16,
"is_nullable": true,
"is_zerofill": false,
"is_unsigned": false,
"is_auto_increment": false,
//... 省略
},
{
"name": "name",
"type": 16,
"is_nullable": true,
"is_zerofill": false,
"is_unsigned": false,
"is_auto_increment": false,
//... 省略
},
{
"name": "count",
"type": 4,
"is_nullable": true,
"is_zerofill": false,
"is_unsigned": false,
"is_auto_increment": false,
//... 省略
},
{
"name": "DB_TRX_ID", #最近修改事务ID
"type": 10,
"is_nullable": false,
"is_zerofill": false,
"is_unsigned": false,
"is_auto_increment": false,
//... 省略
},
{
"name": "DB_ROLL_PTR", #回滚指针
"type": 9,
"is_nullable": false,
"is_zerofill": false,
"is_unsigned": false,
"is_auto_increment": false,
"is_virtual": false,
"hidden": 2,
//... 省略
}
],
//... 省略
}
}
}
}
]

注意:因为这张表里已经指定了主键为id列,所以不会生成隐藏主键DB_ROW_ID列。

2.2 Undo Log回滚日志

回滚日志在增、删、改操作的时候产生的便于数据回滚的日志。当INSERT操作的时候,产生的回滚日志在事务提交后可以被立即删除。而UPDATEDELETE操作的时候,产生的Undo Log日志不仅在进行数据回滚时需要,在进行快照读也需要,所以不会被立即删除

具体关于Undo Log内容可以查看【Mysql日志之Undo log(回滚日志)

2.3 MVCC 版本链

当有多个并发事务操作一行数据时,对这行数据的修改会产生多个版本,多个版本通过上述的一个隐藏字段DB_ROLL_PTR回滚指针指向Undo Log数据地址形成一个链表,即MVCC版本链

如图,每次更新数据,除了修改数据本身,还会记录DB_TRX_ID(事务ID)DB_ROLL_POINTER(回滚指针)。上图表示数据由水杯变成水杯4的过程,trx_id=1,2,3,4即为每次事务的ID,而roll_pointer记录上次记录的回滚指针。版本链就是通过undo log回滚指针把数据的多次更新操作连接起来

2.4 ReadView读视图

ReadView读视图是快照读SQL执行时MVCC提取数据的依据。记录并维护系统当前活跃的事务(未提交的)ID。

上面讲过的Undo Log和MVCC版本链,一条数据经过多次修改会产生多个版本,而快照读时根据不同时机创建的快照读取数据的,那么快照读SQL在执行时该读取哪个版本的数据就是靠ReadView读视图来决定的。

ReadView读视图包含来四个核心字段,也是读取数据的判断依据

字段 含义
m_ids 当前活跃的事务ID集合
min_trx_id 最小活跃事务ID
max_trx_id 预分配事务ID,当前最大事务ID+1(因为事务ID是自增的),分配给下一个事务使用
creator_trx_id 生成ReadView的事务ID

注意点:

  • m_ids:当前系统中活跃事务的事务ID,意思就是当前系统中所有没有提交的事务,即没有commit的事务的事务ID
  • max_trx_id:这个ID不是m_ids中最大的ID,而是下一次事务分配的ID。比如:示例中一共有4个事务,对应1,2,3,4四个事务ID,那么m_ids = [1,2,3,4], 而max_trx_id = 5

ReadView一共有四种匹配规则:

条件 能否访问 说明
trx_id==creator_trx_id 可以访问该版本 说明数据是当前这个事务更改的
trx_id < min_trx_id 可以访问该版本 说明数据已经提交了
trx_id > max_trx_id 不可以访问该版本 说明该事务是在ReadView生成后才开启的
min_trx_id <= trx_id <= max_trx_id 如果trx_id不在m_ids中,那么可以访问该版本 成立,说明数据已经提交

举个简单的例子:

有一个user表如下:

id name age
1 张三 18

开启两个事务:

Transaction 20 Transaction 60
#手动开启事务
BEGIN;
#手动开启事务
BEGIN;
update user set name = ‘李四’ where id = 1;
update user set name = ‘王五’ where id = 1;

上面两个事务都没有提交,对应的版本链信息如下:

那么可以得到如下内容:

m_ids min_trx_id max_trx_id creator_trx_id
[20, 60] 20 61 0

然后根据ReadView匹配规则进行分析

版本 trx_id == creator_trx_id trx_id < min_trx_id trx_id > max_trx_id min_trx_id <= trx_id <= max_trx_id
王五 20 == 0? 20 < 20? 20 > 60? 20 < 20 < 60?
李四 20 == 0? 20 < 20? 20 > 61? 20 < 20 < 60?
张三 10 == 0? 10 < 20?

通过以上逻辑分析可知:只有张三这条数据是当前可用版本。这就是实现读已提交的过程。

3. 不同隔离级别下MVCC分析

  • RC:事务中每次select的时候会生成一个ReadView,生成ReadView的是以select查询为单位的。

  • RR:ReadView的生成是以事务为单位的,只在第一次select时生成ReadView,所以一个事务中多次select,所使用的ReadView都是同一个,正是基于这种机制,实现了可重复读。

3.1 READ-COMMITTED隔离级别

前面有提到过在READ-COMMITTED隔离级别下事务在每次快照读SQL执行时创建ReadView,每次创建的ReadView的四个字段对应的值也是不同的,所以在READ-COMMITTED隔离级别下每次快照读SQL获取的数据可能也是不同的。

下面通过一个READ-COMMITTED隔离级别下并发事务的案例来详细看看:

现有四个并发事务同时访问一条数据:

id name age DB_TRX_ID DB_ROLL_POINTER
1 张三 18 1 NULL

在上述并发事务中,事务5查询了两次id=1的数据,因为当前的隔离级别设置为了READ-COMMITED,事务在每次快照读SQL执行时创建一个ReadView,每次生成的ReadView中的四个字段都不相同。那么两次快照读都会根据生成的ReadView中的字段进行规则匹配,从而决定返回的数据。

3.1.1 第一次快照读

事务5第一次进行查询时生成的ReadView以及原数据如下图:

在匹配版本数据前,先与表中数据进行匹配:

该数据对应的DB_TRX_ID为3, 此时MVCC就会通过ReadView带着这条数据去进行规则匹配:

  • 首先是第一条规则db_trx_id == creator_trx_id,db_trx_id(3)不等于creator_trx_id(5)故不成立;

  • 第二条规则db_trx_id < min_trx_id,db_trx_id(3)不小于min_trx_id(3)故不成立;

  • 第三条规则db_trx_id > max_trx_id,db_trx_id(3)小于max_trx_id(6)故不成立;

  • 第四条规则min_trx_id <= db_trx_id <= max_trx_id,db_trx_id(3)在min_trx(3)与max_trx_id(6)之间,但是同时处于m_ids(3,4,5)集合之中故也不成立

经过这次匹配:表中最新的数据无法匹配到,顾遥与MVCC版本链中前一个的数据进行规则匹配

与MVCC版本链中前一个的版本进行匹配:

  1. 第一条规则db_trx_id(2)不等于creator_trx_id(5)故不成立;

  2. 第二条规则db_trx_id(2)小于min_trx_id(3),该版本的数据满足匹配规则中的第二条,说明数据已经提交,此时匹配将终止并返回这个版本对应的数据。

3.1.2 第二次快照读

因为当前事务的隔离级别为READ-COMMITTED(读已提交),所以在每次快照读的时候都会创建一个ReadView,所以事务5第二次查询的时候生成的ReadView以及原数据如下图:

在匹配版本数据前,先与表中数据进行匹配:

该数据对应的DB_TRX_ID为4, 此时MVCC就会通过ReadView带着这条数据去进行规则匹配:

  • 首先是第一条规则db_trx_id == creator_trx_id,db_trx_id(4)不等于creator_trx_id(5)故不成立;

  • 第二条规则db_trx_id < min_trx_id,db_trx_id(4)不小于min_trx_id(4)故不成立;

  • 第三条规则db_trx_id > max_trx_id,db_trx_id(4)小于max_trx_id(6)故不成立;

  • 第四条规则min_trx_id <= db_trx_id <= max_trx_id,db_trx_id(4)在min_trx(4)与max_trx_id(6)之间,但是同时处于m_ids(4,5)集合之中故也不成立

经过这次匹配,表中最新的数据无法匹配,故要与MVCC版本链中前一个的数据进行规则匹配

与MVCC版本链中前一个的版本进行匹配:

  1. 第一条规则db_trx_id(3)不等于creator_trx_id(5)故不成立;

  2. 第二条规则db_trx_id(3)小于min_trx_id(4),该版本的数据满足匹配规则中的第二条,说明数据已经提交,此时匹配将终止并返回这个版本对应的数据。

3.2 REPEATABLE-READ隔离级别

现在再来看下REPEATABLE-READ可重复读隔离级别有什么区别的地方, 同样有四个事务同时访问一条数据:

在上述并发事务中,事务5查询两次id为1的数据,因为隔离级别设置为REPEATABLE-READ,事务在第一次快照读SQL执行时创建ReadView,后续该事务所有的快照读都复用该ReadView,接下来看看具体流程。

3.2.1 第一次快照读

事务5第一次进行查询时生成的ReadView以及原数据如下图:

在匹配版本数据钱,先于表中数据进行匹配:

该数据对应的DB_TRX_ID为3, 此时MVCC就会通过ReadView带着这条数据去进行规则匹配:

  • 首先是第一条规则db_trx_id == creator_trx_id,db_trx_id(3)不等于creator_trx_id(5)故不成立;

  • 第二条规则db_trx_id < min_trx_id,db_trx_id(3)不小于min_trx_id(3)故不成立;

  • 第三条规则db_trx_id > max_trx_id,db_trx_id(3)小于max_trx_id(6)故不成立;

  • 第四条规则min_trx_id <= db_trx_id <= max_trx_id,db_trx_id(3)在min_trx(3)与max_trx_id(6)之间,但是同时处于m_ids(3,4,5)集合之中故也不成立

经过这次匹配:表中最新的数据无法匹配到,顾遥与MVCC版本链中前一个的数据进行规则匹配

与MVCC版本链中前一个的版本进行匹配:

  1. 第一条规则db_trx_id(2)不等于creator_trx_id(5)故不成立;

  2. 第二条规则db_trx_id(2)小于min_trx_id(3),该版本的数据满足匹配规则中的第二条,说明数据已经提交,此时匹配将终止并返回这个版本对应的数据。

第一次快照读整体上和READ-COMMITED的第一次快照读一致,接下来看下第二次快照读。

3.2.2 第二次快照读

因为当前事务的隔离级别为REPEATABLE-READ(可重复读),所以第二次快照读也会沿用第一次快照读时创建的ReadView,如下:

在匹配版本数据前,先与表中数据进行匹配:

该数据对应的DB_TRX_ID为4,此时MVCC就会通过ReadView带着这条数据去进行规则匹配:

  • 首先是第一条规则db_trx_id == creator_trx_id,db_trx_id(4)不等于creator_trx_id(5)故不成立;

  • 第二条规则db_trx_id < min_trx_id,db_trx_id(4)不小于min_trx_id(3)故不成立;

  • 第三条规则db_trx_id > max_trx_id,db_trx_id(4)小于max_trx_id(6)故不成立;

  • 第四条规则min_trx_id <= db_trx_id <= max_trx_id,db_trx_id(4)在min_trx(4)与max_trx_id(6)之间,但是同时处于m_ids(4,5)集合之中故也不成立。

经过这次匹配,表中最新的数据无法匹配,故要与MVCC版本链中前一个的数据进行规则匹配

与MVCC版本链中前一个的版本进行匹配:

  1. 第一条规则db_trx_id(3)不等于creator_trx_id(5)故不成立;

  2. 第二条规则db_trx_id(3)不小于min_trx_id(4)故不成立;

  3. 第三条规则db_trx_id小于max_trx_id(6)故不成立;

  4. 第四条规则db_trx_id(3)在min_trx(3)与max_trx_id(6)之间,但是同时处于m_ids(3,4,5)集合之中故也不成立。

经过第二次匹配,MVCC版本链中上一层的数据版本也无法匹配,故要与第二条版本进行匹配

与MVCC版本链中第二条版本进行匹配:

  1. 第一条规则db_trx_id(2)不等于creator_trx_id(5)故不成立;

  2. 第二条规则db_trx_id(2)小于min_trx_id(3),该版本的数据满足匹配规则中的第二条,说明数据已经提交,此时匹配将终止并返回这个版本对应的数据