由于访问mysqld的设备不止一个,因此对于mysqld内部的数据,每一个设备都可以将其进行修改。而修改的过程是以多线程的方式并发控制的,这个时候,就大概率会产生一系列的线程安全问题。
比如:
因此,为了防止上述的错误,我们就需要将CURD进行一系列的限制,而这个限制我们将其称之为MySQL的事务管理。为了避免上述情况的发生,就需要指定规则,即:
CURD需要满足以下属性
事务的本质,是站在MySQL之上的,即使用者的角度。这个功能可能由多条SQL构成,在具体业务场景进行的需求转换成的多条SQL。
因此,什么是事务?
事务: 我们将一条或多条SQL构成的集合体,这个集合体所要完成一系列的任务,我们将这一系列的任务统称为MySQL事务。
事务就是一组DML语句组成,这些语句在逻辑上存在相关性,这一组DML语句要么全部成功,要么全部失败,是一个整体。MySQL提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。事务就是要做的或所做的事情,主要用于处理操作量大,复杂度高的数据。假设一种场景:你毕业了,学校的教务系统后台 MySQL 中,不在需要你的数据,要删除你的所有信息(一般不会:) ), 那么要删除你的基本信息(姓名,电话,籍贯等)的同时,也删除和你有关的其他信息,比如:你的各科成绩,你在校表现,甚至你在论坛发过的文章等。这样,就需要多条 MySQL 语句构成,那么所有这些操作合起来,就构成了一个事务。
正如我们上面所说,一个 MySQL 数据库,可不止你一个事务在运行,同一时刻,甚至有大量的请求被包装成事务,在向 MySQL 服务器发起事务处理请求。而每条事务至少一条 SQL ,最多很多 SQL ,这样如果大家都访问同样的表数据,在不加保护的情况,就绝对会出现问题。甚至,因为事务由多条 SQL 构成,那么,也会存在执行到一半出错或者不想再执行的情况,那么已经执行的怎么办呢?
一个完整的事务,绝对不是简单的 sql 集合,还需要满足如下四个属性:
上面四个属性,可以简称为 ACID 。
事务被 MySQL 编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题.可以想一下当我们使用事务时,要么提交,要么回滚,我们不会去考虑网络异常了,服务器宕机了,同时更改一个数据这些情况。因此事务本质上是为了应用层服务的.而不是伴随着数据库系统天生就有的.
共识:我们后面把 MySQL 中的一行信息,称为一行记录。
在 MySQL 中只有使用了 Innodb 数据库引擎的数据库或表才支持事务, MyISAM 不支持。
通过以下命令可以产科数据库支持的存储引擎:
表格显示:
show engines;
行显示:
show engines \G ;
其中,Support中的default代表默认引擎,Transactions表示是否支持事务。
事务的提交方式常见的有两种:
查看事务提交方式:
可见,事务的默认提交方式是自动提交的。
我们也可以通过set手动的去修改事物的提交方式,比如将自动提交关闭,那么就变成了手动提交:
若想重新设置回自动提交,只需将0变成1:
mysql的客户端在/usr/bin/mysql路径下;而mysqld,即服务端在/usr/sbin/mysqld路径下。
而我们使用的就是客户端。客户端有很多种,包括有图形化界面的和无图形化界面的,各种语言版本的。mysql也是一套网络服务,因此我们也可以用远程连接的方式进行操作。因此,一个mysqld,即一个服务端可以被多个客户端访问。
为了便于演示,我们将mysql的默认隔离级别设置成读未提交。具体操作后面专门会讲,现在以使用为主。
这样设置之后,我们需要重新登陆MySQL,设置的才能有效。
重新打开一个窗口,登陆mysql,检查隔离级别,发现他的隔离级别也是读未提交。
我们将如上两个客户端充当并发访问mysqld的客户端,用如上两个客户端来演示各种并发场景。此外,我们将隔离级别设置成最低,即只要一方操作,另一方就会马上反应。(若调成最高,则不能同时观察到)
下面,创建一张员工的工资表:
如果我们再用一个客户端连接mysqld,我们可以查看当前有多少人在连接mysql:
此时可以看到,Time所对应的时间可以推出正在访问的有两个客户端。下面就用这两个客户端进行操作。
证明事务的开始与回滚
首先,我们看一下提交方式,发现提交方式是自动提交的:
下面就是启动事务,启动事务之后,下面的所有操作都是同一个事务的操作。
start transaction; -- 开始一个事务begin也可以,推荐begin
我们将在左侧的事务插入数据,左侧的事务并未结束,但是我们可以在右侧的事务查询到左侧事务操作的结果:
此时我们再设置一个保存点s2,相当于标记,表示后续能够通过这个标记返回,方便我们后续定向回滚。
此时继续插入数据:
然后继续设置保存点s3,即插入一条数据,设置一个保存点:
左侧的所有操作让我们的mysql原子性的,持久化的,具有隔离性的一次保存在数据库里,这称之为一个事务。
但此时,我们不想去插入王五了,那么我们就可以根据设置的s3保存点向前回滚,将王五这条数据撤销。
rollback to s3;
通过另一个事务查看,可以发现王五的这条数据不存在了。同理,如果我们想回滚到s2,回滚到s1,都是可以的。如下,我们就回滚到s1,即没有数据的时候:
最终,如果我们不先想操作这个事务,即结束掉左侧的事务,就执行:
commit;
最终的结果依然是空的,因为事务提交时的结果就是没有数据。
如果不设置保存点,直接进行rollback,那就是将从事务开始到目前的所有操作全都回滚掉。
再commit结束掉,此时就属于正常的sql了。
再启动一次这两个事务,并且不回滚,直接commit,就会发现数据最终保存到了数据库中,即便之后再rollback,也不起作用。
因此,我们所提到的回滚操作,是在事务运行期间才可以进行回滚,事务一旦结束,就无法回滚。
上面我们所提到的都是事务的正常操作所得到的的结果。事务的产生实际上是为了应对那些非正常操作的情况,而我们的保存点就是在非正常操作的情况才会产生真正的作用,比如在操作时sql的客户端突然挂掉了,或者服务端被强制关闭了等等这样的情况,下面就来看看事务的异常操作。
首先,我们的提交方式仍然是自动提交的:
下面,我们来看看各种非正常操作中,事务是如何进行处理的。我们先将这两个事务启动:
非正常演示1 - 证明未commit,客户端崩溃,MySQL自动会回滚(隔离级别设置为读未提交)
在原有的基础上,插入新的数据:
然后,为了营造客户端崩溃的场景,左侧的事务中直接快捷键ctrl \
让客户端崩溃。
再次观察右侧,发现新的数据消失了,这实际上就是事务因异常情况从而自动回滚:
同样的,在commit之前若将客户端直接关闭,也会产生回滚。
非正常演示2 - 证明commit了,客户端崩溃,MySQL数据不会在受影响,已经持久化
只要在产生异常或者退出客户端之前就进行commit,数据不会丢失,因为已经插入到数据库中了。也就是说,commit之前可以随时回滚,commit之后不能回滚。
非正常演示3 - 对比试验。证明begin操作会自动更改提交方式,但不会受MySQL是否自动提交所影响
我们将自动提交关掉,并进行与演示1相同的操作:
启动事务,在原有的基础上,插入新的数据,并进行commit:
左侧快捷键ctrl + \
,使mysql客户端异常终止,观察右侧表中数据:
所以,mysql的提交方式无论是自动还是手动,并不会影响我们事务的手动提交。
非正常演示4 - 证明单条 SQL 与事务的关系: (此时MySQL的提交方式是OFF)
示例一:
首先,如果在事务中,我们begin之后,删除田七的数据,并进行commit,就将这个执行永久执行了。
示例二:
如果我们不在事务中了,也就是不执行begin,同样的也不执行commit,就是纯粹的SQL进行操作,删除id=2的数据,会发现和预料到的一样,右侧终端显示已经被删除:
但是当左侧直接将mysql关掉,这时候,右侧继续显示,发现删除的id=2的数据又回来了:
那如果仍然不启动事务,将自动提交打开,再删除一次id=1的数据,然后让左侧的崩溃,发现数据不会回来,删了就是删了,这也与我们的预期一样。
因此,通过两个示例的对比我们不难发现:
因此,我们可以再次验证一下,单SQL如果在自动提交关闭的情况下进行commit,会发生什么情况:
我们发现,同样永久修改了数据库。因此可以证明,每一条SQL语句都是一个事务。只不过以前存在自动提交,我们并不能发现。
最终结论:
事务操作注意事项
start transaction
或者 begin
。通过此演示,我们已经了解到了事务的原子性和持久性,那么隔离性、一致性下面就开始介绍:
数据库事务的隔离级别有以下四种:
说明一下:
有三种查看方式:
- 查看全局隔离级别
SELECT @@global.tx_isolation;
- 查看会话(当前)全局隔级别
SELECT @@session.tx_isolation;
- 与第二种方式一样
SELECT @@tx_isolation;
说明一下:
设置隔离级别
语法:
SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ
COMMITTED | REPEATABLE READ | SERIALIZABLE}
说明一下:
比如,我们想以会话(session)的方式将事务设置成读提交:
set session transaction isolation level read committed;
我们也可以验证一下,其他会话的隔离级别是否改变:
可见,session的方式修改事务的隔离级别只会改变当前会话的隔离级别,不会影响其他会话的隔离级别。
需要注意的是,一旦修改了global的隔离级别,当前会话必须重新登陆,隔离级别才会被修改。
对于读未提交,我们在 五.事务的操作 中实际上展示的就是读未提交,只不过没有说出来而已。
首先,我们先把隔离级别设置为读未提交,按照global方式进行修改后,需重新登陆:
此时我们打开另一个终端,作为客户端访问数据库。此时开始实验:
同时启动事务,表示两个事务并发在跑
当前并没有commit,因此这两个事务都存在执行前和执行中,接下来先让右侧的事务对数据先进行查看:
此时我们需要记住,在begin和commit之间的所有操作打成一个包,才叫做一个事务,所以目前为止,这两个事务并不完整。
由于事务具有原子性,在左侧的事务未结束即并未commit,原则上右侧的事务不能观察到左侧未结束的事务,但是我们设置了读未提交,结果就是在左侧事务的执行过程中,右侧的事务同时也能观察左侧事务的一举一动:
我们知道,事务要么不做,要么全做。而此时,我们左侧的事务执行了一半,右侧的另一个事务就能看到了,因此对于这个右侧的事务来说,此隔离级别就是读到了,但是并未提交,也就是读未提交。
那么我们rollback左侧的事务,将其全部回滚之后,右侧也就能看到之前的修改操作全部没了:
此时这个事务也算结束了。
因此,在事务中,一个事务没有结束并且正在进行CURD操作,另一个事务也能立刻查看这个事务的数据,我们将这种情况称之为读未提交。
这就好比在多线程中,对同一份资源做修改,另一个线程立马能看到,一定是因为没有加锁。所以对于读未提交来说,这个隔离级别几乎没有加锁,虽然效率高,但是存在很多问题。
读未提交隔离级别的问题:
读未提交会产生脏读。一个事务在执行中,读到另一个执行中事务的更新(或其他操作)但是未commit的数据,这种现象叫做脏读(dirty read)。这种现象很不合理,因为作为原子性的操作,必须要等到一个事务结束,另一个事务才能查看。
首先,我们要把隔离级别改成读提交。
当我们其中的一个客户端修改或增加mysql的数据时,实际上已经写进mysql了,但只对自己本身有效,其他的客户端并不会生效。比如下面的更改,左侧的客户端能够看到自己已修改的数据,但由于未commit,右侧的mysql并不能得到一样的显示:
只有提交之后,才会显示:
那么什么是读提交呢?
一个事务做增删查改操作,另一个事务在此期间不能观察到现象,只有提交之后才能得到对应的结果,我们将这种隔离级别称之为读提交。
不可重复读
那么,我们可以发现,两侧的数据显示是不同的,这其实是合理的,就比如不同世纪的人经历的事情并不一样。那么,仅仅对于右侧的现象,我们并不知道左侧什么时候提交的,但会发现右侧的查询会有不一样的结果,我们将这种现象叫做不可重复读。
不可重复读: 即在事务的高并发情况下,任何一个事务都极有可能在修改时的同时导致另一个事务看不到,当这个事务提交了,其他事务才能看到,就会导致同样的调用select查看数据在1秒前和1秒后的现象是不一样的。
不可重复读真的算是问题吗?
不可重复读就是一个问题,其是在隔离级别为读提交的场景下的。事务是具有原子性的,如果两个事务并行的在跑,其中一个事务提交之后,另一个事务还未提交就会看到,这就导致了另一个事务受到了这个事务的影响,这就与事务的原子性相冲突。因为只有在当开始下一个事务时受到之前事务的影响,这种情况才不与原子性相违背。
举个例子:
对于员工工资来说,一旦提高了该员工的工资,而恰好此时在根据不同的薪资情况给员工发奖金。这两个情况一旦并行,就极有可能导致某个员工在涨薪前后,查看薪资范围时查看了该员工两次,从而发给该员工两次奖金,这种情况一定是不合理的,所以不可重复读一定是存在问题的。因此,读提交隔离级别仍然不严谨。
什么是可重复读?
可重复读隔离级别就是为了处理上面的不可重复读而存在的。一个事务与另一个事务并行执行,其中一个事务提交,也不会影响另一个事务的运行(CURD),我们将这种隔离级别称之为可重复读。
下面就演示一下,首先将默认隔离级别改成可重复读,并重启生效:
开始事务,并查看原始信息:
当我们修改数据并且提交,我们仍发现另一个事务的数据没有被影响:
当我们将右侧事务也提交,我们继续查看表信息:
发现当事务结束后,就可以查看到最新的数据信息了。即可重复读意味着并发的事务在运行期间不会受到影响,事务结束,数据就会更新。这也是mysql的默认隔离级别。
幻读情况:专门针对insert
select多次查看,发现终端A在对应事务中insert的数据,在终端B的事务周期中,也没有什么影响,也符合可重复的特点。但是,一般的数据库在可重复读情况的时候,无法屏蔽其他事务insert的数据(为什么?因为隔离性实现是对数据加锁完成的,而insert待插入的数据因为并不存在,那么一般加锁无法屏蔽这类问题),会造成虽然大部分内容是可重复读的,但是insert的数据在可重复读情况被读取出来,导致多次查找时,会多查找出来新的记录,就如同产生了幻觉。这种现象,叫做幻读(phantom read)。很明显,我使用的MySQL5.7在RR级别的时候,是解决了幻读问题的(解决的方式是用Next-Key锁(GAP+行锁)解决的),insert并没有产生幻读。
经过上三种隔离方式,我们已经发现隔离就是为了在高并发时互相不影响,但是上三种或多或少都存在着彼此影响的情况。而串行化从根本上解决了这个问题,因为同一时间串行化只能运行一个事务在运行,这也就导致了串行化的效率非常低下,几乎不会被采用。
什么是串行化?
串行化是事务的最高隔离级别,多个事务同时进行读操作时加的是共享锁,因此可以并发执行读操作,但一旦需要进行写操作,就会进行串行化,效率很低,几乎不会使用。
下面就演示一下,首先将默认隔离级别改成串行化,并重启生效:
观察此时的数据信息:
在两个终端各自启动一个事务,如果这两个事务都对表进行的是读操作,那么这两个事务可以并发执行,不会被阻塞。如下:
但如果这两个事务中有一个事务要对表进行写操作,那么这个事务就会立即被阻塞。如下:
直到访问这张表的其他事务都提交后,这个被阻塞的事务才会被唤醒,然后才能对表进行修改操作。如下:
对MySQL中的隔离级别总结如下:
隔离级别 | 脏读 | 不可重复读 | 幻读 | 加锁读 |
---|---|---|---|---|
读未提交(read uncommitted) | √ | √ | √ | 不加锁 |
读已提交(read committed) | X | √ | √ | 不加锁 |
可重复读(repeatable read) | X | X | X | 不加锁 |
可串行化(serializable) | X | X | X | 加锁 |
√:会发生该问题
X:不会发生该问题
说明一下:
事务执行的结果,必须使数据库从一个一致性状态,变到另一个一致性状态,当数据库只包含事务成功提交的结果时,数据库就处于一致性状态。
也就是说,一致性实际是数据库最终要达到的效果,一致性不仅需要原子性、持久性和隔离性来保证,还需要上层用户编写出正确的业务逻辑。
对于事务的隔离级别,通过之前的演示,我们已经理解了各种情况下的隔离级别。其中,作为首位和末尾的读未提交和串行化隔离级别,他们的原理无可厚非很好理解。但在中间的两个隔离级别:读提交(RC)和可重复读(RR),这两个的是如何做到的呢,原理是什么样的呢?
在RR级别的时候,多个事务的update,多个事务的insert,多个事务的delete,也是存在加锁现象的。所以对于并发性这种情况来说,一般更多是集中在读写并发上,写写在RR级别上一定要加锁,毕竟这个写一半那个又要写肯定是有问题的。
针对于隔离性来说,根据不同的场景有不同的并发策略。
读-读
:不存在任何问题,也不需要并发控制。读-写
:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读。写-写
:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失(后面补充)在我们的操作中,大多都是读写并发,因此读-读
和写-写
不在讨论范畴之中,下面就深入探讨一下读-写
的并发场景。
在之前的演示中,我们已经证实了在隔离时读的数据和写的数据可以不一样,其实这已经证明了读的数据和写的数据并不是同一份。因此,如果在读-写
并发下,想要实现很好的隔离性的话,我们要实现的核心技术之一:多版本并发控制(MVCC)。
什么是多版本并发控制?
多版本并发控制( MVCC )是一种用来解决 读-写
冲突 的无锁并发控制
1. 事务的开始有先有后,那么是如何控制先后顺序的呢?
MySQL会为事务分配单向增长的事务ID,事务ID与事务是一对一的关系,数字越小来的越早,执行优先级越高。因此每个事务都要有自己的事务ID,可以根据事务ID的大小来决定事务到来的先后顺序。
2. mysqld同一时期处理多个事务
要学习隔离级别,我们首先要了解MVCC,而要理解MVCC,还需要知道下面的三个前提知识。
理解 MVCC
需要知道三个前提知识:
undo
日志Read View
数据库表中的每条记录都会有如下3个隐藏字段:
DB_TRX_ID
:6字节,创建或最近一次修改该记录的事务ID。DB_ROW_ID
:6字节,隐含的自增ID(隐藏主键)。DB_ROLL_PTR
:7字节,回滚指针,指向这条记录的上一个版本。说明一下:
示例
创建一个学生表,表中包含学生的姓名和年龄。如下:
当向表中插入一条记录后,该记录不仅包含name和age字段,还包含三个隐藏字段。如下:
说明一下:
DB_TRX_ID
字段填的就是9。DB_ROW_ID
字段填的就是1。DB_ROLL_PTR
的值设置为null。MySQL的三大日志如下:
MySQL会为上述三大日志开辟对应的缓冲区,用于存储日志相关的信息,必要时会将缓冲区中的数据刷新到磁盘。
说明一下:
现在有一个事务ID为10的事务,要将刚才插入学生表中的记录的学生姓名改为“李四”:
DB_TRX_ID
改为10,回滚指针DB_ROLL_PTR
设置成undo log中副本数据的地址,从而指向该记录的上一个版本。修改后的示意图如下:
现在又有一个事务ID为11的事务,要将刚才学生表中的那条记录的学生年龄改为38:
DB_TRX_ID
改为11,回滚指针DB_ROLL_PTR
设置成刚才拷贝到undo log中的副本数据的地址,从而指向该记录的上一个版本。修改后的示意图如下:
此时我们就有了一个基于链表记录的历史版本链,而undo log中的一个个的历史版本就称为一个个的快照。
说明一下:
insert和delete的记录如何维护版本链?
也就是说,增加、删除和修改数据都是可以形成版本链的。
当前读 VS 快照读
事务在进行增删查改的时候,并不是都需要进行加锁保护:
而select查询时应该进行当前读还是快照读,则是由隔离级别决定的,在读未提交和串行化隔离级别下,进行的都是当前读,而在读提交和可重复读隔离级别下,既可能进行当前读也可能进行快照读。
undo log中的版本链何时才会被清除?
说明一下:
ReadView类的源码如下:
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是单向增长的,因此根据Read View中的m_up_limit_id和m_low_limit_id,可以将事务ID分为三个部分:
示意图如下:
源码策略如下:
bool changes_visible(trx_id_t id, const table_name_t& name) const
MY_ATTRIBUTE((warn_unused_result))
{
ut_ad(id > 0);
//1、事务id小于m_up_limit_id(已提交)或事务id为创建该Read View的事务的id,则可见
if (id < m_up_limit_id || id == m_creator_trx_id) {
return(true);
}
check_trx_id_sanity(id, name);
//2、事务id大于等于m_low_limit_id(生成Read View时还没有启动的事务),则不可见
if (id >= m_low_limit_id) {
return(false);
}
//3、事务id位于m_up_limit_id和m_low_limit_id之间,并且活跃事务id列表为空(即不在活跃列表中),则可见
else if (m_ids.empty()) {
return(true);
}
const ids_t::value_type* p = m_ids.data();
//4、事务id位于m_up_limit_id和m_low_limit_id之间,如果在活跃事务id列表中则不可见,如果不在则可见
return (!std::binary_search(p, p + m_ids.size(), id));
}
说明一下: 使用该函数时将版本的DB_TRX_ID传给参数id,该函数的作用就是根据Read View,判断当前事务能否看到这个版本。
现象演示一:
启动两个终端,将事务的隔离级别都改为可重复读。并查看此时user表中的数据:
在两个终端各自启动一个事务,在左终端中的事务操作之前,先让右终端中的事务查看一下表中的信息。如下:
左终端中的事务对表中的信息进行修改并提交,针对可重复读的隔离级别,右终端中的事务看不到修改后的数据,即便左侧commit,在右侧的事务只要未停止,那么右终端中的事务就看不到修改后的数据,因为这种读都被称之为快照读。如下:
在右终端中使用select ... lock in share mode
命令进行当前读,可以看到表中的数据确实是被修改了,只是右终端中的事务看不到而已。如下:
现象演示二:
我们将左右两侧事务begin以后,不让右侧进行快照读,仅仅只是让左侧的事务进行修改数据并提交,提交之后,右侧的事务在进行快照读与当前读,我们发现这两个结果是一样的,都是当前读的数据:
那么这种现象与可重复读的隔离性冲突吗,实际上一点也不冲突,因为左侧事务已经完成了,不可能会影响到右侧事务了。
说明一下:03
RR与RC的本质区别