数据准备
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真的值得细细品味,能流行这么多年,经典还得是经典。