深入浅出剖析mysql事务隔离级别和MVCC机制

121 阅读11分钟

数据准备

CREATE TABLE `test_order` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增id',
  `resourceid` char(32) NOT NULL COMMENT 'resourceid',
  `balance` double NOT NULL COMMENT '金额',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否被删除',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COMMENT='订单'

REPLACE INTO `test_order` (`id`, `resourceid`, `balance`, `create_time`, `update_time`, `deleted`) VALUES 
	(1,'de1d4958e2664ac085a4cd2647102f50',13.3,'2022-04-26 09:17:56','2022-04-26 09:17:56',0),
	(2,'de1d4958e2664loutwa4cd2647102f50',8.3,'2022-04-26 09:17:56','2022-04-26 09:17:56',0),
	(3,'1qwr4958e2664loutwa4cd2647102f50',187.3,'2022-04-26 09:17:56','2022-04-26 09:17:56',0),
	(4,'dmga4958e2664ac085a4cd2647102f50',19.3,'2022-04-26 09:17:56','2022-04-26 09:17:56',0),
	(5,'de1d49125asv4loutwa4cd2647102f50',33.3,'2022-04-26 09:17:56','2022-04-26 09:17:56',0),
	(6,'1qwr4958e2664loutwa4cd26ihk12f50',78.3,'2022-04-26 09:17:56','2022-04-26 09:17:56',0);

可重复读

可重复读是5.7版本后mysql默认的隔离级别,表示在同一事务下,多次查询相同记录的结果是一致的,可以解决脏读,但是依然无法解决幻读。直接上案例演示

查看隔离级别

mysql> SELECT @@tx_isolation;
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)

开启两个窗口查询

A,B两个窗口,开启事务 start transaction;

查询id = 1的 test_order表, 此时A,B查询出来都是13.3

在A窗口更新id = 1的行记录中balance = 14.4,查询A,B中id = 1 的balance值,看看如何? A中变成14.4,B还是13.3

此时我提交A窗口事务,再次查询B中id = 1 的balance值,B窗口还是13.3

将B中事务提交,再次查询id = 1的记录,事务提交后,查询得到14.4

结论:可重复读中,同一事务下,多次查询相同条件的记录,记录是一致的

幻读案例

开启A,B控制,开启事务,查看目前存在的记录id = 1-7

在A,B开启事务,B中先插入id = 8的数据,成功,A在插入,发现报错,然后在A中查询id = 8的数据,发现并没有数据,这就会让人产生幻觉,这就是幻读

读未提交

更改隔离级别

mysql> set session transaction isolation level read uncommitted;
Query OK, 0 rows affected (0.02 sec)

mysql> SELECT @@tx_isolation;
+------------------+
| @@tx_isolation   |
+------------------+
| READ-UNCOMMITTED |
+------------------+
1 row in set, 1 warning (0.02 sec)

脏读案例

设置隔离级别为RU,A,B窗口开启事务,查询id = 1的数据 balance = 14.4,A窗口更新id = 1的balance = 15.6,还未提交。此时B窗口去查询id = 1的数据,发现balance = 15.6,这就是脏读。

结论:读未提交,会引起脏读,同时幻读也会引起

读已提交

读已提交能避免脏读

  • step1:A 开启事务,查询id = 1的数据,balance = 14.4
  • step2:B开启事务,查询id = 1的数据,balance = 14.4
  • step3:A 更新id = 1的balance 为 15.6,不提交事务
  • step4:B 查询id = 1的balance 还是14.4,避免了脏读

读已提交避免不了幻读

  • step1:A 开启事务,查询id ,有1-7的数据
  • step2:B开启事务,查询id ,有1-7的数据
  • step3:B 插入一条id = 8的数据,未提交
  • step4:A 插入一条id = 8 的数据,发现出错
  • step5:A 查询id = 8的数据,发现根本没有呀,产生幻觉,幻读数据
  • step6:B 查询id = 8 的数据,有数据

MVCC版本控制

版本链

在innodb引擎下,它的聚簇索引记录中包含两个隐藏列,trx _id,roll_pointer

  • trx_id: 每一次事务对某条聚簇索引记录进行更改,都会把该事务id赋值给trx_id隐藏列。
  • roll_pointer:每一次对某条聚簇索引记录进行更改,都会把旧的版本写入到undolog,roll_pointer指向上一个版本信息。

ReadView

对于Read-uncommit隔离级别来讲,由于可以读到事务未提交的数据,所以每次查询只需要查出最新的数据即可。

对于SERIALIZABL 串行化隔离级别,是通过枷锁方式来访问记录。

但是对于read-commit,repeatable-read,必须保证读到已经提交的事务修改的数据,这里会有个问题,在版本链中,那些版本是当前事务可见的呢?对于这种情况,在innodb中是通过一个读视图readView解决。

ReadView基本构造

class ReadView{
    //当前readView中活跃的事务Id列表
    var m_ids:List<Any>;
    //表示生成readView时,当前系统中活跃的读写事务中最小的事务Id,也是m_ids中最小值
    var min_trx_id: String;
    //表示生成readView时,当前系统中活跃的读写事务中最大的事务Id,也是m_ids中最大值
    var max_trx_id: String;
    //表示生成readView的事务的事务Id
    var creator_trx_id: String;
}

如何判断是否可见

通过readView,在访问某条记录时,只需要按照下列步骤进行判断某个版本是否可见。

  • 当访问版本的trx_id隐藏列值和creator_trx_id相同,说明访问版本等于当前事务,该版本是可见的
  • 当访问的版本的trx_id的值小于 min_trx_id ,说明访问版本在当前事务之前就已经提交,该版本是可见的
  • 当访问的版本的trx_id的值大于 max_trx_id,说明访问版本在当前事务之后才有的,该版本是不可见的
  • 当访问的版本的trx_id的值处于[min_trx_id , max_trx_id], 判断trx_id的值,在不在m_ids中,如果在,说明创建readView时,生成该版本的事务还处于活跃状态(也就是还没提交)不能被访问,如果不在,该版本可见

如果某个版本的数据对于当前事务是不可见的,那么继续按照上面的规则进行判断可见性,直到找到可见版本。如果到版本链最后一个版本依然不可见,那就意味着,这条记录对该事务完全不可见,查询结果就不会包含该记录。

REPEATABLE-READ下ReadView

新建表

-- 新增表
CREATE TABLE `test_user` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增id',
  `age` int(11) NOT NULL COMMENT '年龄',
  `name` char(32) NOT NULL COMMENT '名字',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否被删除',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'

-- 初始化数据
INSERT INTO `test_user` ( `id`, `age`, `name`, `create_time`, `update_time`, `deleted` )
VALUES
	( 1, 10, 'wadewhy', '2022-05-01 14:50:24', '2022-05-01 14:50:24', 0 );

验证步骤

  • step 1 开启事务,查询id = 1的数据,name = wadewhy
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from test_user where id = 1;
+----+-----+---------+---------------------+---------------------+---------+
| id | age | name    | create_time         | update_time         | deleted |
+----+-----+---------+---------------------+---------------------+---------+
|  1 |  10 | wadewhy | 2022-05-01 14:50:24 | 2022-05-01 14:50:24 |       0 |
+----+-----+---------+---------------------+---------------------+---------+
1 row in set (0.00 sec)
  • step 2 / step 3 和step 1一样,查询初始时,id = 1的数据,name = wadewhy
  • step 4 事务执行过程中,只有第一次真正修改数据时(删改查),才会分配一个单独的事务Id,假设此时这个事务Id为 100
mysql> UPDATE `test_order` SET  `balance` = 29.3, `create_time` = '2022-04-26 09:17:56', `update_time` = '2022-04-26 09:17:56', `deleted` = 0 WHERE `id` = 4;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0
  • step 5 第一个窗口执行两个更新命令,此时会分配一个事务Id 假设为200
mysql> update test_user set name = 'wadewhy1' where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> update test_user set name = 'wadewhy2' where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0
  • step 6,查询id = 1的数据,发现name = wadewhy,如何来的呢

窗口3中,select查询生成的readView 详细信息如下:

  • creator_trx_id = 0,(此时还没有删改查,分配事务Id)
  • m_ids数据【100,200】
  • min_trx_id = 100
  • max_trx_id = 201

name = wadewhy2的记录 trx_id = 200, 还处于 m_ids中,处于活跃的事务id,继续往下找,name = wadewhy1 仍然不符合,name= wadewhy 的trx_id = 80 ,小于 min_trx_id,所以这个版本的记录可见,查询出来的结果name = wadewhy

现在把第一个窗口的事务提交,也就是事务id = 200 的提交,第二个窗口,更新id = 1的数据,发现第三个窗口查询出来的还是wadewhy

mysql> update test_user set name = 'kobe' where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> update test_user set name = 'kobe1' where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

可以看到第三个窗口查询出来的 id =1 的name = wadewhy,为什么我们第一个窗口提交了事务,第三个窗口查询出来的还是wadewhy呢?

在窗口三,再次select 查询,此时的readView会延用之前的,【这是根本原因】这也是 RR级别下是可重复读的原因。

既然是沿用之前的readView

  • creator_trx_id = 0,(此时还没有删改查,分配事务Id)
  • m_ids数据【100,200】
  • min_trx_id = 100
  • max_trx_id = 201

按照查询规则,那就是name = wadewhy

READ-COMMITTED下ReadView

准备工作

将隔离级别设置为READ-UNCOMMITTED,注意设置完记得退出重新登陆

mysql> set global transaction isolation level read committed;
Query OK, 0 rows affected (0.01 sec)

INSERT INTO `test_user` ( `id`, `age`, `name`, `create_time`, `update_time`, `deleted` )
VALUES
	( 2, 20, 'pual', now(), now(), 0 );

验证

步骤1 窗口1:开启事务(假设事务Id = 100),更新id = 2的数据

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update test_user set name = "pual11" where id = 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> update test_user set name = "pual22" where id = 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

步骤2 窗口2:开启事务(假设事务Id = 200),随意更新另外一张表数据,目的是只有在INSERT,UPDATE,DELETE才会生成事务Id

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update test_order set update_time = now() where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

步骤3 窗口3:开启事务,查询id = 2的记录,name = pual

mysql> select * from test_user where id = 2;
+----+-----+------+---------------------+---------------------+---------+
| id | age | name | create_time         | update_time         | deleted |
+----+-----+------+---------------------+---------------------+---------+
|  2 |  20 | pual | 2022-05-05 09:45:30 | 2022-05-05 09:45:30 |       0 |
+----+-----+------+---------------------+---------------------+---------+
1 row in set (0.00 sec)

步骤4 窗口1:commit提交

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

步骤5 窗口3:再次查询id = 2的记录,此时name = pual22

mysql> select * from test_user where id = 2;
+----+-----+--------+---------------------+---------------------+---------+
| id | age | name   | create_time         | update_time         | deleted |
+----+-----+--------+---------------------+---------------------+---------+
|  2 |  20 | pual22 | 2022-05-05 09:45:30 | 2022-05-05 09:45:30 |       0 |
+----+-----+--------+---------------------+---------------------+---------+
1 row in set (0.00 sec)

步骤6 窗口2:更新id = 2的数据

mysql> update test_user set name = "pual23" where id = 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> update test_user set name = "pual24" where id = 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

步骤7 窗口3:查询id = 2的值

mysql> select * from test_user where id = 2;
+----+-----+--------+---------------------+---------------------+---------+
| id | age | name   | create_time         | update_time         | deleted |
+----+-----+--------+---------------------+---------------------+---------+
|  2 |  20 | pual22 | 2022-05-05 09:45:30 | 2022-05-05 09:45:30 |       0 |
+----+-----+--------+---------------------+---------------------+---------+
1 row in set (0.00 sec)

这里也可以看出,读已提交,不可重复读,窗口3在同一事务下,多次查询出来的结果并不全相同。

在REPEATABLE-READ下,一个事务下,只在 第一次普通SELECT 操作时生成,后续都是用同一个。

在READ-COMMITTED下,一个事务下,每次查询会生成新的readView。

不可重复读

这也就能解释为什么RR级别下,在第一个事务提交后,第二个事务更新了(未提交),第三个窗口查询出来的数据还是原始数据,没有查询到第一个事务提交的数据,还是原始数据【满足可重复读】。而在RC级别,在第一个事务提交后,第二个事务更新了(未提交)第三个窗口查询出来的是第一个事务提交后的数据。

不可重复读和幻读区别在于,不可重复读侧重于修改,幻读侧重新增或删除。

  • 不可重复读,相同条件下,读取过的数据,再次读取发现值不一样
  • 幻读,相同条件下,读取过的数据,再次读取,发现满足的条数不一样

MVCC总结

从上面可以看出,mvcc是一个多版本并发控制思想,在RC和RR两种隔离级别事务执行select操作时访问版本链的过程。两类概念,undolog和readView读视图。还有几点需要注意。

  • insert undo再事务提交之后就可以被释放。但是uddate undo不会立即删除,因为要支持mvcc,另外对于删除来讲,只是执行了delete mark,将行格式删除标记置为删除,也是为了支持mvcc。
  • 细心的人可能会发现,在之前有写普通的select查询才生效,比如类似select * from t for update 不是普通的select,就要另外讨论。
  • 随着继续运行,在确定最早产生的那个readView的事务不会再访问某些update undo log以及被打了删除标记的记录后,会又一个线程(purge线程),会把它们真正的删除。

后言

mysql真的值得细细品味,能流行这么多年,经典还得是经典。