学习了之后,你会对MySQL如何进行数据的访问控制有更深刻的了解;
本文中有大量截图,如果出现404的图片,请评论告知,谢谢;
当A需要给B转账50元的时候,后台应该进行如何处理?
-- 先判断用户还有多少余额(这里的user_id=A只是一个示例,知道什么意思就行)
select money from user where user_id=A;
-- 如果余额充足,那就给A扣50
update user set money = money - 50 where user_id=A;
-- 然后给B加50
update user set money = money + 50 where user_id=B;
这一个操作,需要3个SQL语句才能完成,但很明显,我们即便在当前的服务器端对这3条语句的操作进行加锁,也没有办法避免如下的情况
这时候,如果在执行第一条查询语句后就挂掉了,那还不会有啥问题;但如果是在执行完毕第二条扣钱的语句之后挂掉了,那问题就大了!
最终还需要程序员通过MySQL的日志一个个核对到底是谁出现了这样的情况,给人家把钱加回去,那太过麻烦;在数据量大的时候,这种工作更是不应该由人工手动完成的!
所以,MySQL就需要提供一种方法,让我们能够实现类似原子性的操作。在执行这3条语句的时候,只能出现两种情况:
这样才是靠谱的结果!
另外一个场景就是卖票,其也包含了多条SQL
select * from ticket where tid=?; -- 查询某某车次高铁的票还有没有余量
-- 有余量,进行售卖流程;这里可能要操作另外一张表来添加买票者的用户信息
update ticket set num=num-1 where tid=?; -- 售卖流程结束,扣去这张票
如果有两个客户端都同时来执行这两条SQL语句,那么就会出现问题;
这时候在客户端里面加锁是不顶用的,两个客户端都不是同一个进程,锁无法共享;需要MySQL解决这个问题,就还得提供更加严格的访问控制,在A没有完成整个卖票逻辑,票数没有扣掉的情况下,B不可以来查询票数;
事务(transaction)就是一组DML语句的集合,这些语句在逻辑上是一个整体;执行这组语句,必须全部成功,亦或者是全部失败。
在执行这组SQL的时候,MySQL需要将其视作一个原子性的操作;这个操作不能被其他事务打断,出现问题的时候还需要回滚到开始执行之前的初始状态;事务还能使不同客户端看到的数据是不同的,不同事务之间的操作不会相互影响;只有事务结束后,双方才能看到对方的操作(根据隔离级别不同,这点也会有区别);
正如上面的两个场景所提到的操作,同一个时刻对于MySQL的一个数据库来说,其可能有多个客户端进行不同业务的操作,如果都在访问同一个表,在不加访问控制的情况下,一定会出现访问临界资源的数据二义性问题。
事务的出现,是客户端在实际应用场景下的需要,
所以,一个完整的事务,除了是多条DML语句的集合,还需要满足下面4个特性
上面的四个属性,简称为ACID;
原子性(Atomicity,或称不可分割性)
一致性(Consistency)
隔离性(Isolation,又称独立性)
持久性(Durability)
其中,一致性是由原子性、隔离性、持久性来保证的,只要满足了这三个性质,自然能实现一致性。简单记忆,AID来保证C;
在MySQL中,只有Innodb
支持事务,MyISAM
是不支持事务的;
我们可以用如下语句来查询MySQL的引擎和支持的特性
MariaDB [hello_mysql]> show engines \G
*************************** 1. row ***************************
Engine: MEMORY
Support: YES
Comment: Hash based, stored in memory, useful for temporary tables
Transactions: NO
XA: NO
Savepoints: NO
*************************** 2. row ***************************
Engine: MRG_MyISAM
Support: YES
Comment: Collection of identical MyISAM tables
Transactions: NO
XA: NO
Savepoints: NO
*************************** 3. row ***************************
Engine: CSV
Support: YES
Comment: Stores tables as CSV files
Transactions: NO
XA: NO
Savepoints: NO
*************************** 4. row ***************************
Engine: BLACKHOLE
Support: YES
Comment: /dev/null storage engine (anything you write to it disappears)
Transactions: NO
XA: NO
Savepoints: NO
*************************** 5. row ***************************
Engine: MyISAM
Support: YES
Comment: Non-transactional engine with good performance and small data footprint
Transactions: NO
XA: NO
Savepoints: NO
*************************** 6. row ***************************
Engine: ARCHIVE
Support: YES
Comment: gzip-compresses tables for a low storage footprint
Transactions: NO
XA: NO
Savepoints: NO
*************************** 7. row ***************************
Engine: FEDERATED
Support: YES
Comment: Allows to access tables on other MariaDB servers, supports transactions and more
Transactions: YES
XA: NO
Savepoints: YES
*************************** 8. row ***************************
Engine: PERFORMANCE_SCHEMA
Support: YES
Comment: Performance Schema
Transactions: NO
XA: NO
Savepoints: NO
*************************** 9. row ***************************
Engine: SEQUENCE
Support: YES
Comment: Generated tables filled with sequential values
Transactions: YES
XA: NO
Savepoints: YES
*************************** 10. row ***************************
Engine: InnoDB
Support: DEFAULT -- 默认引擎
Comment: Supports transactions, row-level locking, foreign keys and encryption for tables -- 描述
Transactions: YES -- 支持事务
XA: YES
Savepoints: YES -- 支持事务中的保存点
*************************** 11. row ***************************
Engine: Aria
Support: YES
Comment: Crash-safe tables with MyISAM heritage
Transactions: NO
XA: NO
Savepoints: NO
11 rows in set (0.000 sec)
所谓保存点,就是在事务执行过程中,给当前的数据设置一个savepoint,这样在出现问题的时候,可以回退到当前的数据中;就好比之后的操作没有进行一样;
游戏中的保存点其实也是这样的功能,让玩家回退到打某个BOSS之前,包括当时你收集的材料,都会回退到打这个BOSS之前的状态;
你可以理解为,是给当前的数据拍了张照片,出现问题的时候,用这个照片里面的东西复写掉新的东西。这便是保存点的作用;
先提一下事务的开启和结束方式,以及如何设置保存点和回滚吧
-- 开启,这两种方式是一样的
begin;
start transaction;
-- 设置保存点(名字可以自由更改,但不能有重复)
savepoint save1; -- 创建一个保存点save1
rollback to save1; -- 回滚到保存点save1
rollback; -- 回滚到事务开始
-- 结束
commit; -- 提交事务
了解了事务为什么存在,下面就是来使用一下事务了;
事务的提交方式有两种方式
默认情况下,MySQL的自动提交是处于开启状态的
MariaDB [hello_mysql]> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
1 row in set (0.001 sec)
我们可以用下面的语句来设置是否开启自动提交(只对当前会话生效)
set autocommit = 0; -- 关闭自动提交
set autocommit = 1; -- 开启
这两个提交方式有啥区别呢?是不是开了自动提交就什么都不用管了?后文我们再通过实测来说明;
最开始测试的时候,先将事务隔离级别设置为读未提交;请注意,不要在有生产环境的MySQL中进行此项操作;
-- 设置全局隔离级别为读未提交
set global transaction isolation level READ UNCOMMITTED;
-- 重启mysql终端后查看隔离级别
select @@tx_isolation;
+------------------+
| @@tx_isolation |
+------------------+
| READ-UNCOMMITTED | -- 成功设置读未提交的隔离级级别
+------------------+
1 row in set (0.000 sec)
设置完毕后,进入测试数据库,创建如下测试表
create table test_ruc(
id int unsigned primary key,
user_id int unsigned not null,
name varchar(30) not null
);
先给表里面插入几个基本的数据:
insert into test_ruc values (1,10,'李华'),(2,20,'王五');
插入后,我们在两侧的终端中都可以看到已有的数据
随后在两个终端中都开启一个事务,左侧终端插入一个数据,右侧终端查看
insert into test_ruc values (3,30,'左侧终端插入');
可以看到,两侧的终端都可以看到这份数据
此时将左侧终端强制退出(使用CTRL+\
),右侧再次查询,会发现新插入的数据没有了;这里便是事务的原子性的体现,左侧的终端所执行的事务没有commit
就因为异常退出了,MySQL自动将数据回滚到了这个事务执行之前,即没有插入这份新数据;
左侧重新链接MySQL,开启一个事务;在右侧插入一个数据,随后创建保存点save1
;插入第二个数据,创建保存点save2
;
在左侧查询,我们可以看到右侧插入的两条新数据;
在右侧回滚到保存点save1,会发现插入的数据2没有了;这与保存点的预期操作相符合;
将右侧终端强制退出,再次查询数据,可以看到第一次插入的数据也没有了,数据又回到了右侧终端事务开始之前的样子。
这里能得出一个结论:如果终端异常退出,MySQL会将数据回滚到事务开始时;你可以理解为,我们使用begin;
的时候就已经创建了一个隐藏的保存点,MySQL在异常的时候只会将数据回退到这个事务开始之时的隐藏保存点,无论中途用户有没有设置其他保存点!
即:在异常退出的时候,MySQL会自动帮我们执行rollback
命令回退到事务开头!
rollback; -- 回退到事务开头
这样就能避免最开始提到的,转账系统中给A扣了钱但是没有给B加钱的问题!
接着我们再来看看,如果执行了commit之后又会是什么情况;
右侧提交了事务后退出,左侧依旧能查询倒已经提交的数据。代表数据已经被持久化写入到了磁盘(当然这个时候也不一定立刻刷盘了,但是这已经能代表MySQL将我们的数据加入到了持久化策略中,并不需要我们管啥时候刷盘的问题!)
前面提到了,MySQL的自动提交默认是开启的;那么这个自动提交有什么作用呢?是不是开启了自动提交,就不需要手动创建事务了?
并不是!在之前的测试中,autocommit
是开启的;我们已经证明了,手动begin
创建的事务,在没有手动commit
的时候不会被写入磁盘(不进行持久化);这代表我们手动创建的事务,不会受到autocommit
是否开关的影响;(开着的时候都没有自动提交,关闭的时候肯定更不会自动提交了)
autocommit
会影响谁呢?开启两个终端,左侧将自动提交关闭,随后插入一个数据;插入后会发现右侧看不到左侧新插入的数据!
备注:
set autocommit = 0;
语句只会影响当前终端,不会影响另外的终端。
此时手动进行commit
,右侧就看得到数据了!
而如果我们不手动执行commit
就把左侧终端CTRL+\
退出,那么新插入的这一条数据会丢失!
欸?我们明明没有begin
开启事务,为什么可以执行commit
呢?
这是因为,在MySQL中,每一个不主动使用事务的单条SQL语句,都会视作一个事务进行操作!既我们之前没学习事务时,执行的所有单条SQL也是一个个各自独立的事务!
知道了这一点,你就能理解自动提交的作用了:在执行单条SQL的时候,是否自动提交该SQL语句的事务。
将autocommit
重新开启,再进行测试;会发现左侧插入的数据右侧可以立马看到,无需手动commit
。这便是自动提交的作用的体现!
经过上面的这些简单测试,我们可以看到事务的原子性和持久性,做一个总结
begin/start transaction
手动创建的事务,必须通过commit
手动挡提交,才会持久化;这一点和autocommit
无关!InnoDB
存储引擎而言,所有的单条SQL语句都会被视作一个事务,自动提交;select
语句会有一些差别,因为MySQL有MVCC访问控制(后文讲解)操作事务的一些注意事项
InnoDB
才支持事务,MyISAM
不支持;rollback
;事务的隔离级别和事务的隔离性息息相关
开头就提到了,MySQL的事务有4种隔离级别
select
会出现不同结果);隔离级别是由MySQL进行各种类型的加锁来实现的,比如表锁、行锁、读写锁、间隙锁(GAP)、NEXT-KEY锁(GAP+行锁)等等;
这里还需要验证另外一件事,MySQL如果开启了串行化,客户端c/cpp操作还需要加锁吗?
mysqlquery
的操作是会阻塞还是直接错误退出?
在MySQL中,事务的隔离级别可以分别设置全局的隔离级别和当前客户端的隔离级别;如果没有主动设置客户端的隔离级别,则会继承全局的隔离级别。顾名思义,会话隔离级别只会影响当前的会话,不会影响另外的客户端;
另外,全局的隔离级别会在数据库服务重启后,被重置为配置文件中默认的隔离级别(一般是RR)
-- 设置 会话/全局 隔离级别
set [session | global] transaction isolation level [read uncommitted | read committed | repeatable read | serializable ]
-- 查看当前会话的隔离级别
select @@session.tx_isolation;
-- 查看全局的隔离级别
select @@tx_isolation;
这个隔离级别在第二大点里面就已经测试过了,左侧终端插入的数据,即便没有commit,在右侧终端中也能看得到,这里就不二次演示了
清楚已有的数据,来测试一下会话隔离级别的作用;默认情况下,全局和会话的隔离级别都是RR;
MariaDB [hello_mysql]> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set (0.000 sec)
MariaDB [hello_mysql]> select @@session.tx_isolation;
+------------------------+
| @@session.tx_isolation |
+------------------------+
| REPEATABLE-READ |
+------------------------+
1 row in set (0.000 sec)
将左侧的终端中的会话隔离级别设置为读未提交
set session transaction isolation level read uncommitted;
插入数据之前,执行一次select
全列查询,两侧都是空;
在左侧插入数据后,右侧查看会发现依旧是empty set
没有结果;只有左侧commit
之后,右侧才能看到数据;这是因为在之前测试的时候,我们将自动提交关闭了;右侧看不到我们还没有提交的事务;
右侧手动开启事务后,即便没有进行commit
,但是左侧因为设置了读未提交,所以可以直接看到右侧还没有提交的事务中的修改。这便是这个隔离级别的特性;
接下来再将全局隔离级别设置为读已提交,重新开启两个终端进行测试;
set global transaction isolation level read committed;
设置完毕,重启终端后,可以看到两侧的隔离级别都是读已提交,自动提交都是开启的;
两侧都开启一个事务,左侧插入数据,右侧查看,发现没有内容;
左侧commit之后,右侧才可以看到这个数据;
这符合我们对读已提交这个隔离级别的字面理解,但是这并不代表这个隔离级别没有问题!
来看看下面这个场景:
这个场景被称为不可重复读
,即在一个事务中,同一个查询语句,可能会因为其他事务的提交而产生不同的结果;换做SQL语句来描述,在读已提交的隔离级别中,同一个select语句只能执行一次,第二次执行的时候,可能会获取到和第一次不同的结果,所以这个问题才被称作不可重复读
(不可以重复执行select)问题;
来实际演示一下,两侧都开启一个新的事务,左侧终端更新表中的用户id,只要它不提交,右侧就看不到修改。
但是提交了之后,右侧就能看到这个用户id的改变,在进行数据筛选的时候,这种情形就会出现问题!
所以,为了避免上面提到的这个问题,MySQL还提供了一个可重复读的隔离级别,且默认采用的就是这个隔离级别;
set global transaction isolation level repeatable read;
可重复读就解决了上面提到的问题,即便其他事务提交了修改,当前事务也看不到这份修改,只有当前事务结束后,才能看到最新的数据;
看下图,两侧都开启一个事务,不管左侧是否有提交事务,右侧都看不到左侧新插入的数据;
只有右侧也提交了自己的事务,才能看到左侧插入的新数据;
这就保证了,右侧这个事务在运行的始终,它执行的相同select
语句的结果永远都是一样的,不会出现不可重复读的问题!
而如果在RR级别中,同时访问相同数据,会出现加锁的情况,下图中,右侧插入了主键为6的数据,此时左侧如果也尝试插入一个主键为6的数据,会进入阻塞状态;
如果很久都不继续操作,就会因为等待超时,跳出等待
MariaDB [hello_mysql]> insert into test_ruc values (6,60,'天数');
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
但当前事务中插入的数据依旧是存在的,当前事务也没有结束
再次尝试插入这个主键为6的数据,依旧会陷入阻塞态,只有右侧的事务被提交了之后,左侧才会出现主键冲突的提示,禁止插入;
左侧commit了之后,右侧才能看到左侧新插入的主键为5的数据;
这个就比较简单了,一个事务没结束,另外一个就阻塞等待;
串行化是最高的隔离级别,但其并发性能很低,实际上用的也不多;
set global transaction isolation level serializable;
两侧终端都开始事务,查询的时候是不会加锁的,但是左侧执行update
的时候,阻塞了(可以通过左侧update的执行时间观察到阻塞现象,截图看的不是很明显);右侧也执行一次update
,左侧的update就立马成功了,右侧显示报错检测到死锁,建议尝试重启事务;
再来试试,两侧都开启事务,左侧尝试插入一个数据,会阻塞;右侧commit
之后,左侧的插入就立马成功了!
这便是串行化的表现:两个事务如果都是在执行select的读取操作,则不会被阻塞,但如果有一个事务要执行增删改,那么就必须等待其他事务都结束了,这个操作才能被执行;
回到 1.1.2 卖票 的场景,在这个场景下,如果你需要保证客户端A的卖票逻辑完全执行完毕了,客户端B才能过来查询票数,那就需要使用串行化;
但是,串行化中,我们是可以执行查询操作的!那么在A没有结束的情况下,B不还是会查询到有剩余票数?
实际上,我们的卖票操作并不是直连数据库实现的,而是有一个服务端进程来提供API,供客户端来查询剩余票数,剩下的卖票操作其实都是服务端来实现的(让客户端直连MySQL来执行SQL是非常不可靠的,很有可能出现SQL注入攻击)
所以,这个问题得在服务端进程中加锁解决!MySQL本身即便使用串行化也是没有办法解决这个问题的;
但是,别忘了,MySQL还有另外一个东西——约束;你可以设置一个触发器,更新剩余票数的时候,拒绝将票数设置为负数;这样客户端B即便进入了卖票业务,最终将剩余票数的数据设置为负数的时候,也会被MySQL阻止插入,这个卖票的事务B就相当于错误退出了,所有操作都会被回滚!而隔离级别就是避免A和B同时修改一个数据而导致的错误;
二者相辅相成,就能解决这个问题;当然,在服务端API处理逻辑中加锁是更好的解决办法,既可以保证数据一致性,又能隔离客户端和MySQL服务;
后文讲到的当前读也能在某种程度上解决这个问题,反正解决的办法多多!
如果将 MySQL 的隔离级别(isolation level)设置为最高级别的串行化(SERIALIZABLE),那么并发的多线程操作可能会遇到以下情况之一:
所以,并不是说你将MySQL的隔离级别设置好了,那么客户端就啥事不用干了;最好的操作依旧是在客户端就加锁进行一定的访问控制,因为MySQL Query函数的错误退出+退出情况识别,相比于客户端进行访问控制更难以操作;
InnoDB
通过NEXT-KEY锁解决了幻读问题;commit
的时候)产生的影响;下面给出一个不同事务级别之间的区别的表格,Y代表会出现这个问题,N代表不会出现这个问题
隔离级别 | 脏读 | 不可重复读 | 幻读 | 加锁读 |
---|---|---|---|---|
读未提交 | Y | Y | Y | 不加锁 |
读已提交 | N | Y | Y | 不加锁 |
可重复读 | N | N | N | 不加锁 |
串行化 | N | N | N | 加锁 |
说完了隔离级别,接下来再深入理解一下隔离性
数据库并发的场景一共有下面三种
读-读
:不存在任何问题,不需要访问控制读-写
:有线程安全问题,可能会遇到隔离性中的脏读、幻读、不可重复读问题;写-写
:有线程安全问题,还有可能出现更新丢失问题;我们主要关注的是读写并发的情况,这也是数据库最常遇到的处理场景;
再MySQL中,采用了MVCC(多版本并发控制)来解决读写冲突,这是一种无锁的并发控制机制;
在了解MVCC之前,我们需要了解几个前置知识:
在MySQL中,每一个表都存在三个隐藏的列字段
undo log
中)假设有如下表结构
create table if not exists student(
name varchar(11) not null,
age int not null
);
-- 插入一条记录
insert into student (name, age) values ('张三', 19);
那么这个表中的实际数据是这样的
name | age | DB_TRX_ID(创建这个记录的事务ID) | DB_ROW_ID(隐藏主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 19 | NULL | 1 | NULL |
我们并不知道创建这个记录的事务ID,所以设置为NULL;因为是第一条记录,所以隐藏主键是1;因为是第一条记录,所以没有回滚的地方,回滚指针也是NULL(换句话说,如果回滚指针为NULL,代表这个就是这个表中的第一条记录了)
MySQL是以服务进程的方式在内存中运行的,我们对数据的CURD操作,都需要通过MySQL将其刷入到硬盘上进行持久化,MySQL为这些操作会提供一个专门的buffer pool
内存缓冲区;而undo log/redo log
也是内存中的一块区域,对于MySQL而言
先来说说redo log
,这里包含了MySQL中的所有尚未落盘的CURD操作;如果MySQL还没有写入数据的时候就挂了,那么下次启动的时候就会从redo log
里面恢复数据,来确保数据的一致性和完整性;
在MySQL存储路径/var/lib/mysql
中的ib_logfile0/ib_logfile1
就是redo log
;
-rw-rw----. 1 mysql mysql 50331648 Sep 11 03:16 ib_logfile0
-rw-rw----. 1 mysql mysql 50331648 Sep 9 21:03 ib_logfile1
而undo log
主要用于以下功能
假设我们来了一个update操作,将刚刚插入的张三的年龄改成30岁,对于MySQL而言就会在undo log
里面做如下处理;
DB_TRX_ID
是10;undo log
中,并将当前数据的DB_ROLL_PTR
指向旧数据的地址;DB_ROLL_PTR
找到旧数据,复写回去;如果再来一次修改,将张三的名字改成李四,也是依照这个逻辑在undo log
中新增旧数据,并链接DB_ROLL_PTR
指针;
undo log
中(头插),并将DB_ROLL_PTR
指针指向旧数据的地址;DB_TRX_ID
改成当前事务的ID 11;这样,我们就有了一个类似于链表的历史版本链;每次回退的时候,都可以找到历史数据,覆盖当前的数据。这些版本我们称之为快照
;
而插入和删除都可以写入undo log
,但是只有更新和删除能形成历史版本链;
undo log
中,如果需要回滚,将这行记录恢复;undo log
中,事务回滚的时候需要将新插入的数据删除;增删改都说了,那么select
呢?
一般而言,查询不会产生数据写入操作,也不需要设置历史版本链;
但是在之前关于RR级别隔离性的验证中,我们看到了select
无法看到另外一个事务已经提交了的数据,这说明select
有时候读取的是历史版本的数据,而并非当前最新的数据!
读取历史数据我们称之为快照读
,在RR级别中默认采用的就是快照读;读取最新数据叫做当前读(增删改都是当前读);还有一种读取是为了更新而查询;
select ... -- 在RR级别下,默认是快照读
select ... for update; -- 为了更新而查询
-- MySQL会将当前行上排他锁,上锁了之后,其他客户端只能查询该行,无法修改
select ... lock in share mode; -- 查询最新数据(当前读)
-- 是串行化的,只有其他事务完成了,才能查询到最新结果;
除了手动指定select
的查询方式以外,隔离级别会影响select
的默认行为,比如在串行化隔离级别的场景下,默认执行的就是当前读;
在RR模式下进行测试,会发现当前读会阻塞当前的客户端(右侧客户端阻塞);
只有左侧的事务在提交了之后,右侧的查询才会返回最新的结果;
这是因为当前读,包括增删改操作,是需要加锁的;如果要在RR模式下实现当前读,那么整个系统就需要串行化执行;
而快照读不需要加锁,因为它可以读取历史版本,而历史版本是不会被其他线程修改的,也就不需要维护访问控制,提高了并发效率;
那么,MySQL是如何实现快照读的呢?
Read View
就是MySQL为快照读生成的一个读视图;在事务执行快照读的时候(即执行select
的时候)将当前数据拍一张照,这样在这个事务中,后续的所有select
都只会看到这个照片里面的结果,看不到其他事务最新的修改(不管其他事务是否commit)。
快照读的现象我们已经在本文
3.4 可重复读
中演示过了,这里就不再二次演示了;
Read View
在MySQL的源码中其实就是一个类,该类中包含一些可读性判断的信息,内部有条件,来标明当前事务能够看到那个版本的数据,即有可能是当前最新的数据,也有可能是undo log
中的某个历史版本;
class ReadView {
// 省略...
private:
/** 高水位,大于等于这个ID的事务均不可见*/
trx_id_t m_low_limit_id
/** 低水位:小于这个ID的事务均可见 */
trx_id_t m_up_limit_id;
/** 创建该 Read View 的事务ID*/
trx_id_t m_creator_trx_id;
/** 创建视图时的活跃事务id列表,是一个位图 */
ids_t m_ids;
/** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
trx_id_t m_low_limit_no;
/** 标记视图是否被关闭*/
bool m_closed;
// 省略...
};
单独说明一下这里的4个重要的变量;其中需要注意,在这里面up是低水位,low是高水位,这是由他们保存的数据的性质决定的,请不要和这两个单词的本意混淆!
m_ids; // 一张位图,用来保存Read View生成时,系统正活跃(没有结束的)的事务ID
up_limit_id; // 记录m_ids列表中事务ID最小的ID
low_limit_id; // ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
creator_trx_id // 创建该ReadView的事务ID
要知道,当前事务是知道自己的事务ID的(DB_TRX_ID
),那么我们手中就有快照读中最大最小的事务ID,以及当前活跃中的事务ID;那么在读取版本链的时候,就进行如下匹配,设当前开启的这个事务为A:
m_ids
中,代表事务A开启的时候,这个事务已经提交了,所以我们可以读取这个事务操作的结果;m_ids
中,代表事务A开启的时候,这个事务是活跃状态,那么我就不应该读到这个事务的操作结果;low_limit_id
,代表事务A开启的时候,这个事务还不存在,所以也不应该看到这个在A之后来的事务的操作结果;(A还没操作完毕,这个新事务就来了)up_limit_id
,代表事物A开启的时候,这个事物都不知道是是它的父亲辈还是爷爷辈了,长辈给你留下的东西肯定得好好收着;说人话:这个是A来之前的之前就已经结束了的事物,肯定是可以读取其结果的;在MySQL中我们可以看到如下源码,链接也贴出来了,github上可以查看源码;这个函数就是用来判断当前事务应该能看到那些版本链的,具体的判断逻辑参考中文注释(英文注释是官方留的)
// 这个函数是ReadView类的成员函数 ReadView::changes_visible
// 源码链接 https://github.com/mysql/mysql-server/blob/ea1efa9822d81044b726aab20c857d5e1b7e046a/storage/innobase/include/read0types.h#L162
// [[nodiscard]] 代表这个函数的返回值不能被忽略,一定需要使用该返回值;否则编译器会爆警告;即告知程序员必须关注这个函数的返回值!
// 函数的返回值是当前事务能否看到某一个事务id值的版本链,入参是目标事务id
/** Check whether the changes by id are visible.
@param[in] id transaction id to check against the view
@param[in] name table name
@return whether the view sees the modifications of id. */
[[nodiscard]] bool changes_visible(trx_id_t id,
const table_name_t &name) const {
ut_ad(id > 0);
// 小于最小的事务id或者等于当前事务id,则代表这个事务我们可以看到
if (id < m_up_limit_id || id == m_creator_trx_id) {
return (true);
}
check_trx_id_sanity(id, name);
// 如果这个id比当前事务中的高水位还大,说明这个id是后来者,不应该看到
if (id >= m_low_limit_id) {
return (false);
}
// 如果当前事务创建时,没有活跃事务
// 且 m_up_limit_id <= id < m_low_limit_id
// 那么这个事务ID就可以被看到,返回真
else if (m_ids.empty()) {
return (true);
}
const ids_t::value_type *p = m_ids.data();
// 如果上面都没有匹配到,那就检查这个id是否在活跃列表m_ids中
// std::binary_search的返回值:是否在位图中,true代表在,false代表不在
// 如果在,那么这个事务是活跃状态,不能被看到
// 如果不在,那么这个事务是已经提交了,可以被看到
return (!std::binary_search(p, p + m_ids.size(), id));
}
如果查到不应该看到当前版本,接下来就是遍历下一个版本,直到符合条件,即可以看到。这便是我们select当前读的时候,会自动产生的ReadView
结构体的作用;
根据这份源码,我们也能知道m_ids
位图中,并不需要存放当前事务ID,因为会有额外的判断来处理当前事务ID!
假设当前的事务流程如下,事务2能看到事务1的操作结果吗?
事务1 | 事务2 |
---|---|
事务开始 | 事务开始 |
插入数据1并提交事务 | |
第一次查询 | |
重开一个新事务,插入数据2并提交 | |
第二次查询 |
来实操一下,开两个MySQL终端,隔离级别设置为RR;图中黄字标出了每一个SQL的执行顺序;我们会发现,当左侧终端提交了第一个插入的数据后,右侧才查询,是可以看到这条新插入的数据的!
而左侧插入的第二条数据右侧就看不到了,很明显,此时访问的就是快照中的旧数据,而不是新数据了;
这就告诉我们:快照读的快照是在事务中第一次执行select
语句的时候生成的!并不是事务开始的时候生成的!
这也非常合理,如果你的当前事务压根没有进行select
语句,那我也就没有必要生成快照了,毕竟快照的底层是ReadView
对象,也是会占用内存空间的!而增删改操作本身就是当前读,无需生成快照!
用实际例子来解释一下ReadView里面的4个成员变量到底应该存放什么值;
假设有下面这个数据
name | age | DB_TRX_ID(创建这个记录的事务ID) | DB_ROW_ID(隐藏主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 19 | NULL | 1 | NULL |
一共有4个事务同时运行,事务序号就是事务ID
事务2 | 事务3 | 事务4 | 事务5 |
---|---|---|---|
事务开始 | 事务开始 | 事务开始 | 事务开始 |
…… | …… | …… | 修改张三的年龄并提交 |
运行 | 快照读 | 运行 | …… |
…… | …… | …… | …… |
在这种情况下,事务3对某行数据进行了快照读,MySQL就会在这时候生成一个ReadView
,内部变量赋值如下
m_ids; // 2,4 (当前事务5已经提交了,所以除了自己就只有2和4活跃中)
up_limit_id; // 2 (m_ids中最小事务ID)
low_limit_id; // 5 + 1 = 6 (当前已经出现的最大事务ID+1)
creator_trx_id // 3 (当前事务ID)
当事务3进行快照读的时候,就会拿事务5的这份数据进行比较,最终得到的结果是,事务3的快照是可以看到事务5对数据进行的修改的!
而且对于全局来说,事务5提交的这份数据也是当前最新的数据!
// 比较流程(依照上方MySQL的源码)
5 < up_limit_id || 5 == 3; // 不小于最小的,且也不等于当前的事务ID 3
5 > low_limit_id; // 不大于最大的
// 判断5是否在m_ids里面
m_ids.contains(5); // 不在
// 结论:return true;
快照读的场景基本搞明白了,再来试试当前读(这里小提一嘴,写-写并发可以理解为所有操作都是当前读,需要串行化)
select ... lock in share mode;
下面是两个不同的操作流程,最终会得到不同的结果;操作之前,先执行如下两个sql
delete from test_ruc;
insert into test_ruc values (1,20,'张三');
在第一次测试中,我们在左侧终端更新数据之前就执行了一次快照读,此时MySQL会生成快照,即便在左侧终端的事务commit了之后,右侧也无法看到最新的user_id=18
的数据;此时使用当前读,就可以读取到最新的数据!
而在第二次测试中,我们让左侧终端完成所有操作,右侧终端再去查询,会发现快照读和当前读都能查询到最新的修改;
由此可见
关于MySQL事务的内容到这里基本就OVER了,内容多多,也需要多多理解和复习!
可以深入阅读如下文章
https://blog.csdn.net/SnailMann/article/details/94724197
https://www.cnblogs.com/f-ck-need-u/archive/2018/05/08/9010872.html
https://blog.csdn.net/chenghan_yang/article/details/97630626