事务
- 前言
- 正式开始
-
- 事务的四大特性
- 为什么会出现事务
- 事务的版本支持
- 事务提交方式
- 事务常见操作方式
- 事务的隔离性
-
- 隔离级别
-
- 查看隔离级别
- 修改隔离级别
- 验证四种隔离级别
-
- [读未提交(read uncommitted) ------ 缩写为RU](#读未提交(read uncommitted) —— 缩写为RU)
- [读提交(read committed) ------ 缩写为RC](#读提交(read committed) —— 缩写为RC)
- [可重复读(repeatable read) ------ 缩写为RR](#可重复读(repeatable read) —— 缩写为RR)
- 串行化(serializable)
- 小总结
- 一致性(Consistency)
- 多版本并发控制(MVCC)
- 推荐阅读
前言
显示中编写sql的时候不一定是一条sql就能解决问题,有时需要一批sql才有意义。
比如说转账,需要对两个账户进行更新,用两个update,对一个用户账户中加钱,再对一个用户账户中扣钱,两个update合到一块才能表示转账,如果拆开只是在技术层面上上对某一条记录进行更新,而对于上层业务逻辑来说单条的sql体现不出来意义。只有两个拼到一块才是上层的转账逻辑。
所以多条DML语句拼到一块就是一个上层的完整逻辑,而这多条sql拼到一块的东西就叫做事务。
看待事务一定要站在MySQL的上层去看待sql语句,处理一件事分多步,也就是多条sql语句,这样多条sql拼到一块就是为了解决一个具体的应用场景,所有的sql包装到一块,就是一个事务。
可能你还不太明白,没关系,后面用sql进行演示的时候你就懂了。
正式开始
一个很重要的问题,MySQL是一个网络服务,这在我前面博客也讲过,我们用netstat是可以查到本机中的mysql服务器的:
上面mysqld的端口默认是3306的,不过我前面将我的配置文件中的修改成8080了,如果你也想修改,配置文件为/etc/my.cnf,vim打开之后修改:
那么这就意味着mysql客户端一次是可以连接多个的。不光是本地主机中的mysql可以连接多个:
远端主机上的mysql客户端也可以进行连接,我上面用的是Linux云服务器,我也可以用我Windows下的mysql进行连接:
本地和远端的都能够看到一个库:
那么当多个mysql连接的时候,就可能会发生多个客户端并发的对一个表进行读写操作,那么此时就可能会出问题,类似于多线程的问题,比如说:
上面就是一个并发执行可能出问题的情况,而客户端A和客户端B都是在执行一个事务,两个事物之间影响到了对方。
那么如何避免这样的问题呢?
- 买票的过程得是原子的
- 买票互相应该不能影响
- 买完票应该要永久有效
- 买前,和买后都要是确定的状态
事务的四大特性
事务就是一组DML语句组成,这些语句在逻辑上存在相关性,这一组DML语句要么全部成功,要么全部失败,是一个整体。MySQL提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。
事务就是要做的或所做的事情,主要用于处理操作量大,复杂度高的数据。假设一种场景:你毕业了,学校的教务系统后台 MySQL 中,不在需要你的数据,要删除你的所有信息(一般不会:) ), 那么要删除你的基本信息(姓名,电话,籍贯等)的同时,也删除和你有关的其他信息,比如:你的各科成绩,你在校表现,甚至你在论坛发过的文章等。这样,就需要多条 MySQL 语句构成,那么所有这些操作合起来,就构成了一个事务。
正如我们上面所说,一个 MySQL 数据库,可不止你一个事务在运行,同一时刻,甚至有大量的请求被包装成事务,在向 MySQL 服务器发起事务处理请求。而每条事务至少一条 SQL ,最多很多 SQL ,这样如果大家都访问同样的表数据,在不加保护的情况,就绝对会出现问题。甚至,因为事务由多条 SQL 构成,那么,也会存在执行到一半出错或者不想再执行的情况,那么已经执行的怎么办呢?
所以一个完整的事务,绝对不是简单的 sql 集合,还需要满足如下四个属性(下面的四个特性mysql已经帮我们实现了,我们使用者不必关心其实现细节):
-
原子性:一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。中间的过程的sql所造成的影响不能暴露给别的事物,如果暴露了原子性就没了。至于回滚是什么等会会有专门的sql来演示,先不要急。
-
一致性:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。比如说张三给李四转账50,那么张三的账户中就要减50块钱,李四的账户中就要加50块钱,结果一定是符合预期的。
-
隔离性:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交( Read uncommitted )、读提交( read committed )、可重复读( repeatable read )和串行化( Serializable )。隔离性后面会重点讲解,不懂的同学先不要急。
-
持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。持久性就是将内存中的内容刷新到磁盘上。
上面四个属性,可以简称为 ACID 。
- 原子性(Atomicity,或称不可分割性)
- 一致性(Consistency)
- 隔离性(Isolation,又称独立性)
- 持久性(Durability)。
实际上mysql并没有对一致性做技术上的实现,不过只要保证了原子性、隔离性和持久性就可以在技术上保证数据的一致性。
为什么会出现事务
为了上层在编写代码的时候更舒服。
事务被 MySQL 编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题。
可以想一下当我们使用事务时,要么提交,要么回滚,我们不会去考虑网络异常了,服务器宕机了,同时更改一个数据怎么办对吧?因此事务本质上是为了应用层服务的.而不是伴随着数据库系统天生就有的.
我们在编写上层代码的时候不需要关心原子性、隔离性、一致性等问题,只要告诉mysql要干什么,把sql语句提交给mysql就行了。mysql会自己将这些sql封装成事务并运行。
事务的版本支持
在 MySQL 中只有使用了 Innodb 数据库引擎的数据库或表才支持事务, MyISAM 不支持。
我们可以用show engines来查看所有的存储引擎:
其中transaction(transaction就是事物的意思)字段就是表示是否支持事务的字段,可以看到只有InnoDB存储引擎的这个字段才是yes,其他的都是no或NULL。
事务提交方式
两种提交方式:
- 手动提交
- 自动提交
我们可以用show variables like 'autocommit'来查看当前的提交方式:
我这里现在是打开的状态。
想要修改提交方式可以用set autocommit=x,x为0的时候就是关闭,为1就是打开。
这个后面会有应用场景,目前事务的sql还没有演示,等事务演示了之后再说说这个有什么用。
事务常见操作方式
为了便于演示,这里将mysql的默认隔离级别设置成读未提交(前面讲事务的四大特性中的隔离性的时候提到了,这里先不要管这是什么东西,后面正式讲隔离性的时候会说)。
(可以不看)修改隔离级别:
transaction isolation level,就是事务隔离级别。
(可以不看)这里是设置全局的隔离级别,想要让当前会话的隔离级别也变成读未提交需要重新登录一下mysql(还有其他方法):
tx为transaction的简写,isolation就是隔离。上面的读未提交隔离级别是最低的隔离级别,只是为了方便后续观察。
这里为了模拟一下多个mysql客户端并发执行事务的过程,登录两个mysql客户端:
可以用show processlist可以查看当前有多少人连接,这里再开一个客户端:
其中ID为22的就是新开的mysql,15和16的为刚刚开的两个客户端。其中Time表示某客户端上次命令执行完毕到现在经过了多少秒。
上面两个mysql客户端连接的都是my_test。
左边来创建一张表,右边用desc查看:
看一下二者的事务提交方式:
都是读未提交的。
启动事务
启动事务,可以用两种方式。
- start transaction
- begin
两种方式效果一样,都是启动事物。
演示一下:
注意从现在开始,两边的两个客户端就在同时执行各自的事务了,此时就是并发访问。
左边来插入点数据,右边查看:
右边能够看到。也就是这里读(select)写(insert)能够并发执行。
回滚演示
接着上面的来,如果我想要对数据进行回滚,那么就要设置保存点,就像游戏中的存档一样,你觉得你存了档之后玩得不好,就可以回档,这样就能回到你存档时刻的游戏状态。
事务中设置保存点可以用savepoint:
这里设置了s1保存点,如果我想要等会回滚就可以直接回滚到现在的状态。
那么我来多插入点数据:
直接进行回滚:
这里还可以再多搞几个保存点,就像游戏中可以有多个存档一样,想回档哪个就可以回档哪个,比如我这里插入一个数据就保存一个数据:
此时我有4个保存点,也就是s1 ~ s4,下面我来一一进行回滚,s4后面再来插入一个:
回滚到s4:
可以看到田七这条记录没有了。
也可跨保存点进行回滚:
不能继续回滚到s4,因为回滚到s1的时候还没有s4:
如果说你想要放弃本次事务中的所有操作,全部进行回滚,可以直接用rollback,这样就会回滚到最开始启动事务的时候,这里再来搞两个保存点:
左侧的操作全部回滚:
所有的记录就会全部消失。
这就是回滚操作。
提交事务
刚刚手动启动的事务,也要手动提交,用commit来手动提交:
commit操作就是进行持久化的操作,会将事务中所有写操作所造成的结果刷新到磁盘当中,不过因为左侧的事务回滚了,所有的数据都没有保留下来,所以也就没有往磁盘中刷新什么东西。
此时右侧的事务还没有提交,将右侧事务提交之后再看看:
还是什么东西都没有查到。
再演示一下不进行回滚,直接commit的操作,还是这两个客户端,启动事务:
这里演示一下左用begin,右用start transaction,其实都一样。
左边插入,右边查看:
左边不回滚,直接提交,此事件就是执行了持久化工作,左侧事务的数据就会被刷新到磁盘上:
可以看到,提交之后,右侧的事务中照样能看到刚刚插入的数据。
让右侧的事务也提交:
因为已经刷新到磁盘上了,提交之后再查也是能查到的。
此时左侧还能进行rollback操作,不过刚刚的事务已经commit了,提交之后事务就结束了,此时再回滚也不会影响到刚刚的数据:
这就是事务的启动、回滚和提交操作。
事务的异常
证明未commit,客户端崩溃,MySQL自动会回滚(隔离级别设置为读未提交)
还是左侧事务插入数据,右侧进行查询:
此时我直接关闭掉左侧的会话:
赵六这条记录没了。因为左侧事务出现了异常,会话直接关闭了,就会直接自动进行回滚。所以数据也就查不到了。
再来一个异常信号关闭的,先来搞出刚刚的场景:
直接对左侧ctrl + \:
可以看到会自动进行回滚。赵六记录消失。
证明commit了,客户端崩溃,MySQL数据不会在受影响,已经持久化
就搞一个ctrl + \的演示。
commit后数据就持久化了,持久化后的数据不会因为客户端的崩溃/异常而进行数据回滚。所以就算异常了右侧事务也能查到持久化后的数据。
autocommit
我前面都是设置了autocommit的。但是我手动启动之后,客户端异常崩溃并没有自动提交,这是为啥?
我这里再演示一下关掉autocommit的情况:
还是两个会话,都关掉:
插入演示:
这里是没有自动提交的。
刚刚在演示有自动提交ctrl+\的时候添加赵六,结果照样是没有添加成功。
其实不管是否开启自动提交,结果都是不会自动提交的,我们手动begin或start启动的事务必须由我们手动来提交。
其实我前面博客中的所有的单sql语句其实就是一个事务,比如我现在不用手动begin或start:
这里和前面的区别就是没有begin,这样的话其实insert语句就是一个完整的事务,只不过是一个单sql的事务。
我再来打开autocommit,并用单sql插入:
这里打开了autocommit,insert是一个单sql的事务,执行完毕之后会自动提交,ctrl+\的前已经自动提交了,所以5, 张三的这条记录已经持久化了,所以右侧怎么查都能查到。
所以这两次都是单sql的事务,没有进行begin和commit,但是autocommit打开就会自动提交进行持久化, autocommit没有打开就会直接进行回滚,所以前面博客中所讲的单sql语句都是事务,只是前面还没讲事务相关的概念,我们不知道而已。
再来验证一下,关掉autocommit,然后将刚刚插入的id为5的记录删除掉。
这里是关掉autocommit的,没有commit,异常的时候自动回滚了。
我再手动commit:
这里能够成功删除,所以说没有手动begin后的单个的sql就是一个事务,如果有autocommit就会自动提交,如果没有autocommit就会回滚。
这里只要记住自动提交是给单sql的事务用的就行,我们手动启动的事务必须手动提交。
再来说个比较怪的,手动begin后不提交,插入数据后再begin启动一个新事务,会自动提交(这里是打开autocommit了):
关闭autocommit,照样会自动提交:
事务的隔离性
增删查改四个操作,其实就是读(查)和写(增删改),一个事务中读写都可以执行。
假如说一个事务要进行查操作,另一个事务要进行写(增删改)操作,如果是读先执行,那么读到的一定是陈旧的数据;如果是写先执行,那么读到的一定是最新的数据。
但如果一个事务中要进行多次查找,其他的事务中进行写,那么这个查找的事务在查找的时候是要保证每次查找的结果都一样,还是如果有修改了就直接查看到新的数据?先不说应该是哪种。
来结合生活说说。
假如说你出生在2008年而且能活到100岁,那么你能肉眼看到的事情一定是发生在2008~2108年的。如果在你出生前和入土后的事情还能被你亲眼所见,那就有点离谱了。所以说在你出生前的所有事情和你入土后的所有事情应该是和你隔离开的,不能直接被你看到,也就是说你没有权利去看到你生命周期以外的信息,而且每个人所获取到的信息都不一样。
把事务当成一个人来看,事务也有生命周期。
一个事务要进行写,一个事务要进行读,谁先执行完全取决于谁先来,不同人之间的生命周期有交叉(在2008 ~ 2108期间,不止你一个人存活,还可以有其他人,而且每个人在不同时刻的年龄都不同),那么不同事务之间的生命周期也是会有交叉。
假如写的事务是在update,那么当update没有执行或者是没有完成时,此时select就应该查看到update之前的数据,只有是update结束后执行select事务才会看到更新后的数据,而这两个操作都是在不同事务中的,update之后select能直接看到update后的数据,这其实就是一种隔离性的体现。不过这里的隔离性比较弱。
一个事务有自己的执行范围,且执行时一定要具备原子性,前面我所演示的左侧事务进行一部分操作,右边事务进行一部分操作,其实就是两个事务在并发执行。只有是两个事物在某一时间段同时并发执行的时候才需要谈隔离性,在事务运行中,为了避免事务出现相互干扰,就要有隔离性,而干扰的程度不同,隔离级别也就不同。类似于你的有些信息可以公开给他人,还有的信息是过一段时间后可以公布给他人,还有的信息是永远也不回公开的一样。
隔离性要存在,而且还要有隔离级别,只要一个事务将数据改了,另一个事务能够立马看到,不分谁先谁后,这就是最低的隔离级别。还有其他级别,比如果一个事务将数据改了,另一个事务查的时候不能直接查到改了之后的事务,当改事务提交后查的事务才能看到修改后的数据,这里高了一个隔离级别。
所以:
数据库中,为了保证事务执行过程中尽量不受干扰,就有了一个重要特征:隔离性
数据库中,允许事务受不同程度的干扰,就有了一种重要特征:隔离级别
隔离级别
mysql中分四种隔离级别。
读未提交【Read Uncommitted】:我们上面为了做实验方便,用的就是这个隔离性:
在演示的时候左侧事务做的任何动作都会影响到右侧事务的读,这就是读未提交隔离性,没有提交的写所产生的影响都能够读到。这个隔离级别可以理解为多个事务间其实没有任何的隔离性。
读提交【Read Committed】 :该隔离级别是大多数数据库的默认的隔离级别(不是 MySQL 默认的)。它满足了隔离的简单定义:一个事务只能看到其他的已经提交的事务所做的改变。对应到上面的演示就是只有左侧的事务提交了之后右侧才会看到左侧写后所产生的影响,这个隔离性后面也会演示,这里先不要急。
可重复读【Repeatable Read】: 这是 MySQL 默认的隔离级别,它确保同一个事务,在执行中,多次读取操作数据时,会看到同样的数据行。对应上面的演示就是左侧不管提交不提交右侧看到的都是最初的数据记录。也是等会演示。
串行化【Serializable】: 所有的事务必须按照到来的先后顺序执行,一个事务执行完之后才能执行下一个事务,虽然这样做能够保证数据的绝对安全,但是效率会非常低,而且还可能会出现超时和锁竞争。也是等会演示。
根据前三个隔离级别的名字可以看出,好像隔离级别都是和读有关的,其实对于增删改这样的写操作,必须是串行执行的,而查这样的读操作是可以和写并发执行的。所以隔离级别是对于读写两种操作而言的,在数据安全的前提下实现读写并发操作,对于一个数据库而言是要重点实现的,纯写的场景很少,大多情况下是读写并发,所以读写并发必须要设计好,不然这个数据库就没法用。
也就是说:读写可并发,写写必串行。
所以我后续说的都是一个事务读一个事务写的场景。
演示四个隔离级别之前先说说怎么查看mysql的隔离级别。
查看隔离级别
其实前面都讲过了。
查看当前的隔离级别,有三种方式:
sql
select @@global.tx_isolation; #查看全局的隔离级别
上面这是查看全局的隔离级别。还有查看当前会话的隔离级别:
sql
select @@session.tx_isolation; #查看当前会话的隔离级别
当前会话,意思就是当前连接的这个客户端的隔离级别。还有一种查看当前会话隔离级别的方式:
sql
select @@tx_isolation; #查看当前会话的隔离级别
就是这上面的三种查看方式,你可以理解为第二种和第三种查看方式是一样的,而且全局的隔离级别就是当前会话的默认隔离级别(登录mysql时的默认隔离级别)。
可以想象成有一个全局变量来表示全局的隔离级别,当启动一个会话时,这个会话中还会有一个局部的变量来表示该会话的隔离级别,而且这个局部变量的初始值就是全局变量。
修改隔离级别
sql
set 当前会话(session)或者全局(global) transaction isolation level 四种级别;
如果只是设置当前会话的隔离级别就用session,如果是设置全局隔离级别就用global。
四种级别就是刚刚介绍的read uncommitted(读未提交)、read committed(读提交)、repeatable read(可重复读)、serializable(串行化)。
演示一下,设置当前会话的隔离级别为读提交:
此时设置的是当前会话的隔离级别,其他会话的隔离级别和全局的隔离级别是不会受影响的(前面设置了全局的隔离级别为读未提交):
设置全局的会影响后续连接会话的默认隔离级别,但不会影响当前已开启的所有会话的隔离级别,这里将全局的隔离级别设置成可重复读的:
再来新加一个会话:
一般不要随便更改隔离级别,如果要设置那就尽量将所有的会话都设置成一致的。
如果想要同时将多个会话的隔离级别都修改成同样的话,可以直接先用一个会话将全局的隔离级别设置成你想要的,然后将所有会话关掉,再重新连接新会话就行。
验证四种隔离级别
下面来验证不同隔离级别的读写并发情况。
还是首先要有两个事物并发的场景,这里像前面演示的一样,左边一个右边一个:
读未提交(read uncommitted) ------ 缩写为RU
其实前面讲事务的时候演示的都是读未提交的。
启动事务:
此时这两个事务就是并发在跑。
还是用刚刚的account表演示,只不过我将其中的数据都删除了,插入一条记录:
再更新一下:
左边还没有commit提交,右边就立马能够看到。这就是读未提交。
前面说了事务要有原子性,要么事务已经做完了,要么是还没有开始做,中间做的过程不能让外界看到,所以这里读未提交的隔离级别导致了事务原子性的丢失,故这个隔离级别是不合理的。
一个事务在执行中,读到另一个执行中事务的更新(或其他操作)但是未commit的数据,这种现象叫做脏读。
我直接进行回滚:
右边查的时候全都没了,所以说读未提交这个隔离级别会产生很多问题,严重不推荐。
读提交(read committed) ------ 缩写为RC
就是读取的时候只有写的事务提交了才会影响到读的事务。
先将两个会话的隔离级别设置为读提交:
还是account这张表:
可以看到插入后右侧事务立即读取并没有直接生消,等左侧事务提交了之后才生效的。
如果我左侧没有提交,查的时候看到的是有张三这条记录的,我重来一下:
可以看到,左侧插入之后左侧能看到,但是右侧事务是看不到的。
左侧事务提交,右侧事务才能看到:
当然右侧事务提交之后换成其他事务也是能看到的:
因为左侧一提交就已经将数据持久化了,持久化后的数据任何事物都可以看到。
执行中的事务看到不到其他执行中的修改后的数据,但是只要修改的事务提交了,读的事务就可以看到修改后的数据,这就是读提交(读取提交后的数据)。
但是,同一个事务内,同样的读取,在不同的时间段,(依旧还在事务操作中!),读取到了不同的值,这种现象叫做不可重复读(non reapeatable read)!!
那么不可重复读是不是问题?
我来讲个例子。
假如说公司到年底了,要按照每个人的月薪发年终奖。
[4000, 8000)的发一个公司定制水杯;
[8000, 12000)的发一个阿迪书包;
[12000, 20000)的发一个智能手环;
20000以上的发一部遥遥领先(假如说公司很有钱);
此时需要统计出来各个薪资端的人员都有谁,而且这个任务落到了小张的头上。
小张一看说,简单啊,直接启动一个事务用4个select就行:
于是就这么干了。
但此时公司一名员工叫张三,人家月薪为18000,而且人家技术很好但是没有得到重用,一年改了不少错,但是老板没看到。于是张三就去找老板,给老板诉说想要加薪,老板一看张三确实薪资和实例不匹配,于是就给张三的薪资,涨到21000。此时负责涨薪的小王直接去更新薪资了:
假如说小王和小强同时起的事务:
但是小王更新并提交的时刻正好在挑选[12000, 20000)和20000以上的两条sql之间:
那么此时:
这两条语句挑选出来的结果中就会有两个张三。
如果说小张直接将结果交给了其领导,其领导一看,怎么[12000, 20000)范围的和20000以上的有两个张三,而且还是同一个张三,此时小张就要挨骂喽。
所以说不可重复读是问题。我们要保证同一个事务中的读取结果要是相同的。
可重复读(repeatable read) ------ 缩写为RR
这个隔离级别是mysql的默认隔离级别,这里来验证一下。
我直接重启服务器:
这里重启之后再登录mysql,默认的隔离级别为可重复读。
再来继续演示,还是两个会话:
插入数据演示:
可以看到,左侧commit了之后,右侧照样没有查到张三。
写的事务提交与否都不会影响正在运行的事务的读,这就是可重复读。并发运行时能够保证同一事物多次读取的结果都是一致的,这样小张就不会挨骂了。
此时左侧已经将数据持久化了,右侧commit,事务结束后,其他事务是可以看到张三的:
再来演示一下删除:
左侧提交,右侧照样能找到张三:
右侧提交,再查看:
这就是可重复读,保证同一事务下查看到的数据都是一样的。
再来说说幻读是啥。
一般的数据库在可重复读情况的时候,无法屏蔽其他事务insert的数据(为什么?因为隔离性实现是对数据加锁完成的,而insert待插入的数据因为并不存在,那么一般加锁无法屏蔽这类问题),会造成虽然大部分内容是可重复读的,但是insert的数据在可重复读情况被读取出来,导致多次查找时,会多查找出来新的记录,就如同产生了幻觉。这种现象,叫做幻读(phantom read)。
很明显,MySQL在RR级别的时候,是解决了幻读问题的(解决的方式是用Next-Key锁(GAP+行锁)解决的。这块比较难,有兴趣同学了解一下)。这里只要知道幻读是insert时会出现的问题就行。
串行化(serializable)
串行化很简单,对所有操作全部加锁,进行串行化,数据上不会出现问题,但是效率上会出问题。
只要是并发的事务,发生了写操作就会阻塞,但是并发的读并不会阻塞:
如果一写:
右侧就直接阻塞了,左侧提交了右侧的才会执行:
只有一个事务能顺利执行写操作,执行完之后才能让其他事务执行写操作,读操作不会受影响。
如果写操作长时间没有成功,会出现超时的情况:
再来看一个:
再来看一下刚刚可重复读,两边可以对一张表同时进行插入操作:
不过上面只有在写入的数据不冲突时才可以,如果冲突了就会有一方卡住,没有卡住的一方提交了才能继续执行写入,本质上就是没有卡住的一方持有锁,卡住的一方没有锁:
可以看到,update动作经过了9秒多才执行,也就是我右边的事务经过了这么多时间才commit的,可惜这里没有动图,有动图看着更方便。
注意对同一张表中的写操作都是串行的,即使是在最低隔离级别RU下:
可以看到左侧和右侧对同一条记录进行了修改,而左侧阻塞了,二者都是在RU隔离级别下的,也就是说对于同一条记录的写操作必须是串行的。
小总结
-
其中隔离级别越严格,安全性越高,但数据库的并发性能也就越低,往往需要在两者之间找一个平衡点。平衡点mysql说了不算,实际的应用场景说的才算,mysql只是提供多种解决方案,根据需求选择合适的方案。
-
不可重复读的重点是修改和删除:同样的条件, 你读取过的数据,再次读取出来发现值不一样了幻读的重点在于新增:同样的条件, 第1次和第2次读出来的记录数不一样。
-
说明: mysql 默认的隔离级别是可重复读,一般情况下不要修改。
-
上面的例子可以看出,事务也有长短事务这样的概念。事务间互相影响,指的是事务在并行执行的时候,即都没有commit的时候,影响会比较大。
一致性(Consistency)
事务执行的结果,必须使数据库从一个一致性状态,变到另一个一致性状态。当数据库只包含事务成功提交的结果时,数据库处于一致性状态。如果系统运行发生中断,某个事务尚未完成而被迫中断,而改未完成的事务对数据库所做的修改已被写入数据库,此时数据库就处于一种不正确(不一致)的状态。因此一致性是通过原子性来保证的。
其实一致性和用户的业务逻辑强相关,一般MySQL提供技术支持,但是一致性还是要用户业务逻辑做支撑,也就是,一致性,是由用户决定的。
而技术上,通过AID保证C。
多版本并发控制(MVCC)
数据库并发的场景有三种:
-
读-读 :不存在任何问题,也不需要并发控制。
-
读-写 :有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读。
-
写-写 :有线程安全问题,可能会存在更新丢失问题。
多版本并发控制( MVCC )是一种用来解决 读-写 冲突的无锁并发控制。
事务有原子性,必须按照一定的先后顺序执行,那么如何做到按序执行?
为每一个事务分配单项增长的事务ID,ID越小来的越早,通过ID来判断不同事务到来的先后顺序。
比如说两个事务,事务A和事务B,事务A先begin的话,就会给事务A一个ID,假如说是1,那么事务B后begin,获得的事务ID就是2。
每一条SQL语句都是有对应的事务来执行的,比如说事务A执行了select、update、select、delete、select这五个sql,那么这五个sql会有记录,这五个sql的执行事务都是A,在记录的时候会保存事务A的事务ID,也就是1。
mysqld可能会面临处理多个事务的场景,所以需要对多个事务进行管理,还是和os一样要先描述再组织,所以事务在mysqld中一定是对应的一个或者一套结构体/类对象。所以有事务到来的时候就先new一个事务的对象,然后再申请对应的事务ID,并将对应的sql语句初始化这个事务对象的等等字段。
理解MVCC要知道三个前提知识:
- 3个记录隐藏字段
- undo 日志
- Read View
三个记录隐藏字段
我们创建一张表的时候,表中不光会有我们自己设置的列,还会有三个列,分别是:DB_TRX_ID 、DB_ROLL_PTR、DB_ROW_ID 。下面这三个介绍看不懂没关系,要结合者等会讲的undo日志来讲才能听懂。
DB_TRX_ID :6 byte,最近修改( 修改/插入 )事务ID,记录创建这条记录/最后一次修改该记录的事
务ID。
DB_ROLL_PTR : 7 byte,回滚指针,指向这条记录的上一个版本(简单理解成,指向历史版本就
行,这些数据一般在 undo log 中)。
DB_ROW_ID : 6 byte,隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB 会自动以
DB_ROW_ID 产生一个聚簇索引。如果手动指明了主键,那么这个隐藏主键就不需要了,因为只能有一个主键。
比如我现在创建一个新的student表:
里面不仅会有name和age这两列,还会有刚刚提到的三列。
其实还有一列,未删flag列,这列表示数据到底删没删除,如果数据删除了就为0,没删就为1,一个提高效率的东西,这里不做讨论。
undo日志
下面我在讲的时候都是按照可重复读的隔离级别来讲的。
当我们对表中数据做修改的时候不会直接对原表中的数据进行操作,而是类似于写时拷贝一样,先对原表中的数据拷贝一份做记录,比如说刚刚的student表,插入一条记录:
插入的时候insert这单条sql是一个事务,会有其对应的事务ID,假如说就是10,那么记录的时候就是这样:
name | age | DB_TRX_ID(创建该记录的事务ID) | DB_ROW_ID(隐式主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 18 | 10 | 1 | null |
第一条记录也没有其他版本,这里就设置回滚指针为null。
假如我这里对张三这一条记录做了更新:
此时不光是张三这条记录被改了,在undo log中还会保存原来张三的那条旧记录:
同时update这条单sql语句也是一个事务,那么就也会有对应的事务ID,假如说就是11,而且修改后的张三这条记录算是张三的第二条记录,那么新记录的前一条记录就是刚刚的旧记录,此时回滚指针DB_ROLL_PTR 就能派上用场,直接指向前一条记录的地址(假如说是0xffbc327b),隐式的自增主键就还是1,那么此时张三这条记录就应该是这样的:
name | age | DB_TRX_ID(创建该记录的事务ID) | DB_ROW_ID(隐式主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 28 | 11 | 1 | 0xffbc327b |
对应到刚刚的图中就是这样:
如果再次对张三这条记录做修改:
此时这条新记录就算是张三这条记录的第三条记录了。同理,这个单条的update也是一个事务,也有其事务ID,假如说就是12,第二条记录也有其地址,假如说是0xffbc328c,第三条记录中的DB_ROLL_PTR列的值就应该是0xffbc328c,记录的数据就应该是这样:
name | age | DB_TRX_ID(创建该记录的事务ID) | DB_ROW_ID(隐式主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 20 | 12 | 1 | 0xffbc328c |
对应刚刚的那张图:
这样,我们就有了一个基于链表记录的历史版本链(第几条记录就是第几个版本)。所谓的回滚,无非就是用历史数据,覆盖当前数据。
上面的一个一个版本,我们可以称之为一个一个的快照(专业术语中版本就是快照)。
undo log中保存的是历史的数据,历史的数据是稳定的,不能修改,只能修改最新的版本。上面只是演示了update,当我们进行insert的时候日志中会记录一个相反的delete,当进行delete的时候日志会记录一个相反的insert,这样就会形成多版本的数据,这些数据完全是由mysql帮我们维护的,而这就是MVCC中非常重要的一个机制。
undo log可能会被装满吗?
不太可能,一个事务中的所有sql写操作对应一套undo log,如果当前事务结束,对应的undo log中的数据也就被清空了,而且一个事务中也不会有太多的sql写操作,所以一般不用担心。读操作不会记录到undo log中,因为读操作不需要进行回滚,没有意义。
那一个事务select读取时,是读取最新的版本呢?还是读取历史版本?
当前读:读取最新的记录,就是当前读。增删改,都叫做当前读,select也有可能当前读,比如:select lock in share mode(共享锁,这个后面会有演示)。
快照读:读取历史版本(一般而言),就叫做快照读。就是直接进行select*的。(这个我们后面重点讨论)
读写并发时,假如写是修改,那么如果修改后的数据和读到的数据不一样的话,本质原因是二者看到的数据是不同版本的,前面的可重复读里面每查的时候看到的结果都是相同的,其实就是查的时候一直查的是历史版本,而修改的时候只能修改最新版本,所以二者不会访问同一个位置,就不需要加锁,这样就不会出现竞争锁的情况,所以并发读写不会出现问题。
这样通过不同的版本就实现了数据层面上的隔离性,而这本质上是在版本上做隔离,一条记录的版本可能有很多,具体看到哪一个版本完全是由隔离级别来决定的。
那如何实现隔离级别呢?
通过read view来实现。
Read View
读视图(read view),前面讲事务的时候说了事务就可以看作一个类,其实这里你也可以把这个东西想象成一个类,里面有很多字段,不同的事务通过这里的读视图就可以读到不同版本的记录。事务和读视图的关系就像PCB和进程地址空间的关系一样,二者用指针关联起来。
【注】只有在一个已经启动的事务首次进行快照读的时候,mysql才会为这个事务创建一个读视图。
读视图中有几个字段很重要,我这里挑出来说说(下面的这个类不用细看,先过一眼):
cpp
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;
// 省略...
};
重要字段解释
还记得前面说的每个事务有其ID不,这里就要重点说说这个东西。
再强调一遍:
只有在一个已经启动的事务首次进行快照读(直接进行select,不加 lock in share mode)的时候,mysql才会为这个事务创建一个读视图。
读视图只会在创建的时候初始化,后面值不会改变。
先来说说其中的m_ids和m_creator_trx_id字段,当一个事务创建了一个读视图对象的时候,可能会有其他事务也在执行中,此时每个事务都有其事务ID,此时m_creator_trx_id就是当前视图对应事务的事务ID;m_ids是一个集合,其中保存了除当前事务外,其他正在执行的事务的事务ID。
所以:
-
m_ids; ⇒ 一张列表,用来维护Read View生成时刻,系统正活跃的事务ID
-
m_up_limit_id; ⇒ 记录m_ids列表中事务ID最小的ID(没有写错)
-
m_low_limit_id; ⇒ ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1(也没有写错)
-
m_creator_trx_id; ⇒ 创建该ReadView的事务ID
m_ids中保存的事务ID不一定是连续的,事务也是有长事务和短事务的,长事务就是sql比较多且执行起来比较慢的,短事务比如说一个只有一条sql的事务,可能多个事务执行的时刻都很接近,但是可能先来的事务最后执行完(长事务),也可能后来的事务先执行完(短事务),所以说多个事务执行的时候中间的事务也有可能先执行完。
所以当一个事务创建读视图的时候可能有的先到来的事务还没有执行完,但是后来的事务已经执行完了,如图:
图中绿色的事务都是到某事务创建视图的时候还没有执行完毕的事务,蓝色的是中途就执行完了的事务,此时读视图中的m_ids保存的事务ID就会不连续。
前面讲undo log的时候说了每一条保存的历史记录都保存了其被执行的事务的事务ID,不同快照(版本)的记录可能事务ID不同,因为不同记录可能是由不同的事务执行的,比如说更新,所有的事务都可以对某一条记录进行修改。可能说有的版本记录中保存的事务ID对应的事务任然还在执行其他的sql,此时这些正在执行中的事务对应版本记录就不应该被看到。
那么如何区分这些事务呢?
m_ids中保存的是正在执行的事务,m_up_limit_id中保存的是m_ids中的最小事务的ID,如果一个事务的ID小于m_up_limit_id,就可以说这个事务已经执行完毕了。那么当undo log中的不同记录对应事务ID小于这个m_up_limit_id,就可以证明这条记录已经是一个历史版本的执行记录了,那么这些事务ID的记录可以被看到。
还有一种表示历史版本记录的,就是刚刚讲的中间就执行完毕的事务,这些事务的事务ID不会被保存在m_ids中但是依旧比m_up_limit_id大,那么如果undo log中的历史版本记录中保存的事务ID是这些中间执行完毕的事务的事务ID,那么也就表明这些历史版本的记录也是可以被看到的。
还有一个字段m_low_limit_id表示的是还未被分配的事务ID的下一个ID,也可以理解为m_ids中的最大事务ID+1(不过我觉得这种说法不准确,个人认为也可能出现最后的事务也执行完毕的情况,那么这个最后的事务也不会保存在m_ids中,而且事务ID是递增的,不会重复,所以未被分配的事务ID的下一个ID比较准确一点),如果一个事务的ID大于等于m_low_limit_id,那么就表明在当前视图创建时,事务ID大于m_low_limit_id的事务还没有被执行,那么这些事务对应的记录也不应该被看到。
流程演示
假设当前有条记录(创建事务先不管,给成null):
name | age | DB_TRX_ID(创建该记录的事务ID) | DB_ROW_ID(隐式主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 28 | null | 1 | null |
事务操作:
事务1 [id=1] | 事务2 [id=2] | 事务3 [id=3] | 事务4 [id=4] |
---|---|---|---|
事务开始 | 事务开始 | 事务开始 | 事务开始 |
... | ... | ... | 修改且已提交 |
进行中 | 快照读 | 进行中 | |
... | ... | ... |
-
假设事务4:修改name(张三) 变成name(李四)
-
当事务2 对某行数据执行了 快照读 ,数据库为该行数据生成一个 Read View 读视图
cpp
//事务2的 Read View
m_ids; // 1,3
up_limit_id; // 1
low_limit_id; // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id // 2
此时版本链是:
只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务:
我们的事务2在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟up_limit_id,low_limit_id和活跃事务ID列表(trx_list) 进行比较,判断当前事务2能看到该记录的版本。
cpp
//事务2的 Read View
m_ids; // 1,3
up_limit_id; // 1
low_limit_id; // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id // 2
//事务4提交的记录对应的事务ID
DB_TRX_ID=4
//比较步骤
DB_TRX_ID(4)< up_limit_id(1) ? 不小于,下一步
DB_TRX_ID(4)>= low_limit_id(5) ? 不大于,下一步
m_ids.contains(DB_TRX_ID) ? 不包含,说明,事务4不在当前的活跃事务中。
//结论
故,事务4的更改,应该看到。
所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本
示例及源码
再来一个示例,下面这张图就包含了上面的所有逻辑:
对应源码:
RR 与 RC的本质区别
其实二者就是提交了之后要不要被看到的区别。
这里用一下前面博客中创建的一张表:
表是空的,为了测试,插入一条记录:
下面来验证一下读视图的形成时间,也就是只有启动的事务在进行读快照的时候才会形成。
我现在会话的隔离级别为RR 的,搞两个会话:
左边查找并更新,右边查找。
两种情况:
- 左边更新前,右边直接进行快照读,左边提交之后,右边再次进行快照读,然后再进行当前读。
- 左边提交前,右边不读,左边提交后,右边进行快照读和当前读。
- 情况一:左边更新前,右边直接进行快照读,左边提交之后,右边再次进行快照读,然后再进行当前读。
结果如下:
可以看到左侧提交前和提交后右侧读快照的查找结果都是相同的,原理刚刚也是讲了的,左侧事务(假如说是事务A)和右侧事务(假如说是事务B)并发执行,右侧事务只有在进行读快照的时候才会形成读视图,那么B读的时候(也就是上图中的第二步)A正在执行中,此时A的ID就会进入B的读视图中的m_ids中,那么后续再进行快照读都会导致读取的结果看不到A所造成的影响,就算commit了也没有用。因为B的读视图只有在初始化的时候会赋值,后续不会修改B的读视图中的相关字段。
整个流程就是这样的:
事务A操作 | 事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 开启事务 | 开启事务 | begin |
select * from user | 快照读(无影响) | 查询快照读查询 | select * from user |
update user set age=18 where id=1; | 更新age=18 | - | - |
commit | 提交事务 | - | - |
select 快照读 ,没有读到age=18 | select * from user | ||
select lock in share mode当前读 , 读到age=18 | select * from user lock in share mode |
- 情况二:左边提交前,右边不读,左边提交后,右边进行快照读和当前读。
此时会话隔离级别还是RR的。
先让左侧提交:
右侧再进行快照读:
还是以A和B来表示这两个会话。
此时B在进行快照读,那么就会形成对应的读视图,而此时A事务已经提交了,那么B形成的读视图中就不会有A的事务ID,也就是说B在后续进行快照读的时候都会认为A这个事务已经结束了,那么就会读取到A修改后的结果,所以说现在快照读和当前读的结果都是相同的:
整个流程就是这样:
事务A操作 | 事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 开启事务 | 开启事务 | begin |
select * from user | 快照读,查到age=18 | - | - |
update user set age=28 where id=1; | 更新 age=28 | - | - |
commit | 提交事务 | - | - |
select 快照读 age=28 | select * from user | ||
select lock in share mode当前读 age=28 | select * from user lock in share mode |
故read view形成的时机不同,会影响事务的可见性,看的数据是老的还是新的不重要,在RR隔离级别下保证读到的记录是一直的才最重要。
我前面这些演示都是在RR级别下进行的,不是所有的隔离级别都是这样。
来说说RR和RC的区别。
-
正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同。
-
在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来。
-
此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;
-
即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见。
-
而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因。
-
总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。
-
正是RC每次快照读,都会形成Read View,所以,RC才会有不可重复读问题。
推荐阅读
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
https://www.jianshu.com/p/398d788e1083
https://tech.meituan.com/2014/08/20/innodb-lock.html
https://www.cnblogs.com/aspirant/p/9177978.html
到此结束。。。