记录些MySQL题集(8)

ACID原则、事务隔离级别及事务机制原理

一、事务的ACID原则

什么是事务呢?事务通常是由一个或一组SQL组成的,组成一个事务的SQL一般都是一个业务操作,例如聊到的下单:「扣库存数量、增加订单详情记录、插入物流信息」,这一组SQL就可以组成一个事务。

而数据库的事务一般也要求满足ACID原则,ACID是关系型数据库实现事务机制时必须要遵守的原则。

ACID主要涵盖四条原则,即:

  • • A/Atomicity:原子性

  • • C/Consistency:一致性

  • • I/Isolation:独立性/隔离性

  • • D/Durability:持久性

1.1、Atomicity原子性

原子性这个概念,而在MySQL中原子性的含义也大致相同,指组成一个事务的一组SQL要么全部执行成功,要么全部执行失败,事务中的一组SQL会被看成一个不可分割的整体,当成一个操作看待。

好比事务A①、②、③SQL组成,那这一个事务中的三条SQL必须全部执行成功,只要其中任意一条执行失败,例如执行时出现异常了,此时就会导致事务A中的所有操作全部失败。

1.2、Consistency一致性

不管事务发生的前后,MySQL中原本的数据变化都是一致的,也就是DB中的数据只允许从一个一致性状态变化为另一个一致性状态。简单解释一下就是:一个事务中的所有操作,要么一起改变数据库中的数据,要么都不改变,对于其他事务而言,数据的变化是一致的。

1.3、Isolation独立性/隔离性

简单理解原子性和一致性后,再来看看ACID中的隔离性,在有些地方也称之为独立性,意思就是指多个事务之间都是独立的,相当于每个事务都被装在一个箱子中,每个箱子之间都是隔开的,相互之间并不影响。

1.4、Durability持久性

相较于之前的原子性、一致性、隔离性来说,持久性是ACID原则中最容易理解的一条,持久性是指一个事务一旦被提交,它会保持永久性,所更改的数据都会被写入到磁盘做持久化处理,就算MySQL宕机也不会影响数据改变,因为宕机后也可以通过日志恢复数据。

二、MySQL的事务机制综述

ACID原则是数据库事务的四个特性,也可以理解为实现事务的基础理论。在MySQL默认情况下,一条SQL会被视为一个单独的事务,同时也无需咱们手动提交,因为默认是开启事务自动提交机制的,如若你想要将多条SQL组成一个事务执行,那需要显式的通过一些事务指令来实现。

2.1、手动管理事务

MySQL中,提供了一系列事务相关的命令,如下:

  • start transaction | begin | begin work:开启一个事务

  • commit:提交一个事务

  • rollback:回滚一个事务

事务是基于当前数据库连接而言的,而不是基于表,一个事务可以由操作不同表的多条SQL组成,这句话什么意思呢?

图片

上面画出了两个数据库连接,假设连接A中开启了一个事务,那后续过来的所有SQL都会被加入到一个事务中,也就是图中连接A,后面的SQL②、SQL③、SQL④、SQL⑤这四条都会被加入到一个事务中,只要在未曾收到commit/rollback命令之前,这个连接来的所有SQL都会加入到同一个事务中,因此对于这点要牢记,开启事务后一定要做提交或回滚处理。

不过在连接A中开启事务,是不会影响连接B的,这也是我说的:事务是基于当前数据库连接的,每个连接之间的事务是具备隔离性的。

这里插个小偏门知识:当你在Navicat、SQLyog这类可视化工具中,新建一个查询时,本质上它就是给你建立了一个数据库连接,每一个新查询都是一个新的连接。

-- 查看 自动提交事务 是否开启
SHOW VARIABLES LIKE 'autocommit';

-- 关闭或开启自动提交
SET autocommit = 0|1|ON|OFF;

上述的[0/ON]是相同的意思,表示开启自动提交,[1/OFF]则表示关闭自动提交。

2.2、事务回滚点

在上面简单阐述了事务的基本使用,但假设目前有一个事务,由很多条SQL组成,但是我想让其中一部分执行成功后,就算后续SQL执行失败也照样提交,这样可以做到吗?从前面的理论上来看,一个事务要么全部执行成功,要么全部执行失败,似乎做不到啊,但实际上是可以做到的,这里需要利用事务的回滚点机制。

在某些SQL执行成功后,但后续的操作有可能成功也有可能失败,但不管成功亦或失败,你都想让前面已经成功的操作生效时,此时就可在当前成功的位置设置一个回滚点。当后续操作执行失败时,就会回滚到该位置,而不是回滚整个事务中的所有操作,这个机制则称之为事务回滚点。

MySQL中提供了两个关于事务回滚点的命令:

  • savepoint point_name:添加一个事务回滚点

  • rollback to point_name:回滚到指定的事务回滚点

-- 先查询一次用户表
SELECT * FROM `zz_users`;
-- 开启事务
start transaction;
-- 修改 ID=4 的姓名为:黑熊
update `zz_users` set `user_name` = "黑熊" where `user_id` = 4;
-- 添加一个事务回滚点:update_name
savepoint update_name;
-- 删除 ID=1 的行数据
delete from `zz_users` where `user_id` = 1;
-- 回滚到 update_name 这个事务点
rollback to update_name;
-- 再次查询一次数据
SELECT * FROM `zz_users`;
-- 提交事务
COMMIT;

上述代码中开启了一个事务,事务中总共修改和删除两条SQL组成,然后在修改语句后面添加了一个事务回滚点update_name,在删除语句后回滚到了前面添加的回滚点。

但要注意:回滚到事务点后不代表着事务结束了,只是事务内发生了一次回滚,如果要结束当前这个事务,还依旧需要通过commit|rollback;命令处理。

其实借助事务回滚点,可以很好的实现失败重试,比如对事务中的每个SQL添加一个回滚点,当执行一条SQL时失败了,就回滚到上一条SQL的事务点,接着再次执行失败的SQL,反复执行到所有SQL成功为止,最后再提交整个事务。

2.3、MySQL事务的隔离机制

不同的数据库连接中,一个连接的事务并不会影响其他连接,当时也稍微的提过一嘴:这是基于事务隔离机制实现的,那接下来重点聊一聊MySQL的事务隔离机制。其实在MySQL中,事务隔离机制分为了四个级别:

  • Read uncommitted/RU:读未提交

  • Read committed/RC:读已提交

  • Repeatable read/RR:可重复读

  • Serializable:序列化/串行化

上述四个级别,越靠后并发控制度越高,也就是在多线程并发操作的情况下,出现问题的几率越小,但对应的也性能越差,MySQL的事务隔离级别,默认为第三级别:Repeatable read可重复读。

2.3.1、脏读、幻读、不可重复读问题
数据库的脏读问题

首先来看看脏读,脏读的意思是指一个事务读到了其他事务还未提交的数据,也就是当前事务读到的数据,由于还未提交,因此有可能会回滚,如下:

图片

在个案例中,事务A先扣减了库存,然后事务回滚时又加了回去,但连接②已经将扣减后的库存数量读回去操作了,这个过程就被称为数据库脏读问题。这个问题很严重,会导致整个业务系统出现问题,数据最终错乱。

数据库的不可重复读问题

再来看看不可重复读问题,不可重复读问题是指在一个事务中,多次读取同一数据,先后读取到的数据不一致,如下:

图片

在上述这个案例中,同一个事务中读取同一数据,结果却并不一致,也就说明了该数据存在不可重复读问题。
可重复读:在同一事务中,不管读取多少次,读到的数据都是相同的。

数据库的幻读问题

幻读:指同一个事务内多次查询返回的结果集不一样。比如同一个事务A,在第一次查询表的数据行数时,发现表中有n条行记录,但是第二次以同等条件查询时,却发现有n+1条记录,这就好像产生了幻觉。

这个说法实际上并不严谨,第一次读和第二次读同一数据,结果集并不相同,这其实属于一个不可重复读的问题,而并非幻读问题。那接下来举例说明一下什么叫做真正的幻读问题,先上图:

图片

发生幻读问题的原因是在于:另外一个事务在第一个事务要处理的目标数据范围之内新增了数据,然后先于第一个事务提交造成的问题。

数据库脏写问题

其实除开三个读的问题外,还有有一个叫做脏写的问题,也就是多个事务一起操作同一条数据,例如两个事务同时向表中添加一条ID=88的数据,此时就会造成数据覆盖,或者主键冲突的问题,这个问题也被称之为更新丢失问题。

2.3.2、事务的四大隔离级别

其实四个事务隔离级别,解决的实际问题就是这三个,因此一起来看看各级别分别解决了什么问题:

  • ①读未提交:处于该隔离级别的数据库,脏读、不可重复读、幻读问题都有可能发生。

  • ②读已提交:处于该隔离级别的数据库,解决了脏读问题,不可重复读、幻读问题依旧存在。

  • ③可重复读:处于该隔离级别的数据库,解决了脏读、不可重复读问题,幻读问题依旧存在。

  • ④序列化/串行化:处于该隔离级别的数据库,解决了脏读、不可重复读、幻读问题都不存在。

前面提到过,MySQL默认是处于第三级别的,可以通过如下命令查看目前数据库的隔离级别:

-- 查询方式①
SELECT @@tx_isolation;
-- 查询方式②
show variables like '%tx_isolation%';
+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| tx_isolation  | REPEATABLE-READ |
+---------------+-----------------+

其实数据库不同的事务隔离级别,是基于不同类型、不同粒度的锁实现的。事务是基于数据库连接的,数据库连接本身会有一条工作线程来维护,也就是说事务的执行本质上就是工作线程在执行,因此所谓的并发事务也就是指多条线程并发执行。

读未提交级别

这种隔离级别是基于「写互斥锁」实现的,当一个事务开始写某一个数据时,另外一个事务也来操作同一个数据,此时为了防止出现问题则需要先获取锁资源,只有获取到锁的事务,才允许对数据进行写操作,同时获取到锁的事务具备排他性/互斥性,也就是其他线程无法再操作这个数据。

但这个级别中,写同一数据时会互斥,但读操作却并不是互斥的,也就是当一个事务在写某个数据时,就算没有提交事务,其他事务来读取该数据时,也可以读到未提交的数据,因此就会导致脏读、不可重复读、幻读一系列问题出现。

但是由于在这个隔离级别中加了「写互斥锁」,因此不会存在多个事务同时操作同一数据的情况,因此这个级别中解决了前面说到的脏写问题。

读已提交级别

在这个隔离级别中,对于写操作同样会使用「写互斥锁」,也就是两个事务操作同一数据时,会出现排他性,而对于读操作则使用了一种名为MVCC多版本并发控制的技术处理,也就是有事务中的SQL需要读取当前事务正在操作的数据时,MVCC机制不会让另一个事务读取正在修改的数据,而是读取上一次提交的数据(也就是读原本的老数据)。

也就是在这个隔离级别中,基于同一条数据而言,对于写操作会具备排他性,对于读操作则只能读已提交事务的数据,不会读取正在操作但还未提交的事务数据。

事务A的主要工作是负责更新ID=1的这条数据,事务B中则是读取ID=1的这条数据。 此时当A正在更新数据但还未提交时,事务B开始读取数据,此时MVCC机制则会基于表数据的快照创建一个ReadView,然后读取原本表中上一次提交的老数据。然后等事务A提交之后,事务B再次读取数据,此时MVCC机制又会创建一个新的ReadView,然后读取到最新的已提交的数据,此时事务B中两次读到的数据并不一致,因此出现了不可重复读问题。

可重复读级别

在这个隔离级别中,主要就是解决上一个级别中遗留的不可重复读问题,但MySQL依旧是利用MVCC机制来解决这个问题的,只不过在这个级别的MVCC机制会稍微有些不同。在读已提交级别中,一个事务中每次查询数据时,都会创建一个新的ReadView,然后读取最近已提交的事务数据,因此就会造成不可重复读的问题。

而在可重复读级别中,则不会每次查询时都创建新的ReadView,而是在一个事务中,只有第一次执行查询会创建一个ReadView,在这个事务的生命周期内,所有的查询都会从这一个ReadView中读取数据,从而确保了一个事务中多次读取相同数据是一致的,也就是解决了不可重复读问题。

虽然在这个隔离级别中,解决了不可重复读问题,但依旧存在幻读问题,也就是事务A在对表中多行数据进行修改。

序列化/串行化级别

这个隔离级别是最高的级别,处于该隔离级别的MySQL绝不会产生任何问题,因为从它的名字上就可以得知:序列化意思是将所有的事务按序排队后串行化处理,也就是操作同一张表的事务只能一个一个执行,事务在执行前需要先获取表级别的锁资源,拿到锁资源的事务才能执行,其余事务则陷入阻塞,等待当前事务释放锁。

但这种隔离级别会导致数据库的性能直线下降,毕竟相当于一张表上只能允许单条线程执行了,虽然安全等级最高,可以解决脏写、脏读、不可重复读、幻读等一系列问题,但也是代价最高的,一般线上很少使用。

这种隔离级别解决问题的思想很简单,之前我们分析过,产生一系列问题的根本原因在于:多事务/多线程并发执行导致的,那在这个隔离级别中,直接将多线程化为了单线程,自然也就从根源上避免了问题产生。

其实在RR级别中也可以解决幻读问题,就是使用临键锁(间隙锁+行锁)这种方式来加锁。

2.3.3、事务隔离机制的命令

简单认识MySQL事务隔离机制后,接着来看看一些关于事务隔离机制的命令:

-- 方式①:查询当前数据库的隔离级别
SELECT @@tx_isolation;
-- 方式②:查询当前数据库的隔离级别
show variables like '%tx_isolation%';
-- 设置隔离级别为RU级别(当前连接生效)
set transaction isolation level read uncommitted;
-- 设置隔离级别为RC级别(全局生效)
set global transaction isolation level read committed;
-- 设置隔离级别为RR级别(当前连接生效)
-- 这里和上述的那条命令作用相同,是第二种设置的方式
set tx_isolation = 'repeatable-read';
-- 设置隔离级别为最高的serializable级别(全局生效)
set global.tx_isolation = 'serializable'; 

唯一要注意的在于:如果想要让设置的隔离级别在全局生效,一定要记得加上global关键字,否则生效范围是当前会话,也就是针对于当前数据库连接有效,在其他连接中依旧是原本的隔离级别。

三、MySQL的事务实现原理

**MySQL的事务机制是基于日志实现的**。为什么是基于日志实现的呢?

3.1、正常SQL的事务机制

**MySQL默认开启事务的自动提交,并且将一条SQL视为一个事务**。任意一条写SQL的执行都会记录三个日志:undo-log、redo-log、bin-log

  • undo-log:主要记录SQL的撤销日志,比如目前是insert语句,就记录一条delete日志。

  • redo-log:记录当前SQL归属事务的状态,以及记录修改内容和修改页的位置。

  • bin-log:记录每条SQL操作日志,只要是用于数据的主从复制与数据恢复/备份。

在写SQL执行记录的三个日志中,bin-log暂且不需要关心,这个跟事务机制没关系,重点是undo-log、redo-log这两个日志,其中最重要的是redo-log这个日志。

redo-log是一种WAL(Write-ahead logging)预写式日志,在数据发生更改之前会先记录日志,也就是在SQL执行前会先记录一条prepare状态的日志,然后再执行数据的写操作。

但要注意:MySQL是基于磁盘的,但磁盘的写入速度相较内存而言会较慢,因此MySQL-InnoDB引擎中不会直接将数据写入到磁盘文件中,而是会先写到BufferPool缓冲区中,当SQL被成功写入到缓冲区后,紧接着会将redo-log日志中相应的记录改为commit状态,然后再由MySQL刷盘机制去做具体的落盘操作。

因为默认情况下,一条SQL会被当成一个事务,数据写入到缓冲区后,就代表执行成功,因此会自动修改日志记录为commit状态,后续则会由MySQL的后台线程执行刷盘动作。

举个伪逻辑的例子,例如下述这条插入SQL的执行过程大致如下:

-- 先记录一条状态为 prepare 的日志
-- 然后执行SQL,在缓冲区中更改对应的数据
INSERT INTO `zz_users` VALUES(5,"黑竹","男","9999","2022-09-24 23:48:29");
-- 写入缓冲区成功后,将日志记录改为 commit状态
-- 返回 [Affected rows: 1],MySQL后台线程执行刷盘动作

3.2、多条SQL的事务机制

-- 开启事务
start transaction;
-- 修改 ID=4 的姓名为:黑熊(原本user_name = 1111)
update `zz_users` set `user_name` = "黑熊" where `user_id` = 4;
-- 删除 ID=1 的行数据
delete from `zz_users` where `user_id` = 1;
-- 提交事务
COMMIT;

①当MySQL执行时,碰到start transaction;的命令时,会将后续所有写操作全部先关闭自动提交机制,也就是后续的所有写操作,不管有没有成功都不会将日志记录修改为commit状态。

②先在redo-log中为第一条SQL语句,记录一条prepare状态的日志,然后再生成对应的撤销日志并记录到undo-log中,然后执行SQL,将要写入的数据先更新到缓冲区。

③再对第二条SQL语句做相同处理,如果有更多条SQL则逐条依次做相同处理..... ,这里简单的说一下撤销日志长啥样,大致如下:

-- 第一条修改SQL的撤销日志(将修改的姓名字段从 黑熊 改回 1111)
update `zz_users` set `user_name` = "1111" where `user_id` = 4;
-- 第二条删除SQL的撤销日志(将删除的行数据再次插入)
INSERT INTO `zz_users` VALUES(1,"熊猫","女","6666","2022-08-14 15:22:01");

④直到碰到了rollback、commit命令时,再对前面的所有写SQL做相应处理:

如果是commit提交事务的命令,则先将当前事务中,所有的SQLredo-log日志改为commit状态,然后由MySQL后台线程做刷盘,将缓冲区中的数据落入磁盘存储。

如果是rollback回滚事务的命令,则在undo-log日志中找到对应的撤销SQL执行,将缓冲区内更新过的数据全部还原,由于缓冲区的数据被还原了,因此后台线程在刷盘时,依旧不会改变磁盘文件中存储的数据。

3.3、事务的恢复机制

SQL执行时,数据还没被刷写到磁盘中,结果数据库宕机了,那数据是不是就丢了啊?毕竟本地磁盘中的数据,在MySQL重启后依旧存在,但缓冲区中还未被刷到磁盘的数据呢?因为缓冲区位于内存中,所以里面的数据重启是不会存在的撒?

对于这个问题呢实际上并不需要担心,因为前面聊到过redo-log是一种预写式日志,会先记录日志再去更新缓冲区中的数据,所以就算缓冲区的数据未被刷写到磁盘,在MySQL重启时,依旧可以通过redo-log日志重新恢复未落盘的数据,从而确保数据的持久化特性。

那如果在记录redo-log日志时,MySQL宕机咋整?

首先看看前面的那种情况:数据被更新到缓冲区但没刷盘,然后MySQL宕机了,MySQL会通过日志恢复数据。这里要注意的是:数据被更新到缓冲区代表着SQL执行成功了,此时客户端会收到MySQL返回的写入成功提示,只是没有落盘而言,所以MySQL重启后只需要再次落盘即可。

但如果在记录日志的时候MySQL宕机了,这代表着SQL都没执行成功,SQL没执行成功的话,MySQL也不会向客户端返回任何信息,因为MySQL一直没返回执行结果,因此会导致客户端连接超时,而一般客户端都会有超时补偿机制的,比如会超时后重试,如果MySQL做了热备/灾备,这个重试的时间足够MySQL重启完成了,因此用户的操作依旧不会丢失(对于超时补偿机制,在各大数据库连接池中是有实现的)。

MySQL也没做热备/灾备这类的方案呐?

MySQL挂了一直不重启,不仅仅当前的SQL会丢失,后续平台上所有的用户操作都会无响应,这属于系统崩溃级别的灾难了,因此只能靠完善系统架构来解决。

四、MySQL事务篇总结

再次结合undo-log、redo-log日志来看待ACID的四大特性:原子性、一致性、隔离性、持久性。

  • 原子性要求事务中所有操作要么全部成功,要么全部失败,这点是基于undo-log来实现的,因为在该日志中会生成相应的反SQL,执行失败时会利用该日志来回滚所有写入操作。

  • 持久性要求的是所有SQL写入的数据都必须能落入磁盘存储,确保数据不会丢失,这点则是基于redo-log实现的。

  • 隔离性的要求是一个事务不会受到另一个事务的影响,对于这点则是通过锁机制和MVCC机制实现的,只不过MySQL屏蔽了加锁和MVCC的细节。

  • 一致性要求数据库的整体数据变化,只能从一个一致性状态变为另一个一致性状态,其实前面的原子性、持久性、隔离性都是为了确保这点而存在的。

什么是排他锁、共享锁、意向锁

共享锁

共享锁,又被称为读锁,是由读取操作所创建的一种锁。在此期间,其他用户可以同时读取数据,但在数据上未释放所有共享锁之前,任何事务均无法对其进行修改(即获取数据的排他锁)。

一旦事务T对数据A加上共享锁,其他事务只能对A再加共享锁,而无法加排他锁。获得共享锁的事务仅可读取数据,不可修改数据。

SELECT ... LOCK IN SHARE MODE;

在查询语句后增加LOCK IN SHARE MODE,MySQL会对查询结果中的每一行都加上共享锁。当没有其他线程对查询结果集中的任何一行使用排他锁时,可以成功申请共享锁;否则会被阻塞。其他线程也可以读取已被共享锁保护的表,且这些线程读取的是同一版本的数据。

排他锁

排他锁又称为写锁。如果事务T对数据A加上排他锁,则其他事务无法对A加任何类型的锁。获得排他锁的事务既能读取数据,又能修改数据。

SELECT ... FOR UPDATE;

在查询语句后增加FOR UPDATE,MySQL会对查询结果中的每一行记录都加上排他锁。只有在没有其他线程对查询结果集中的任何一行使用排他锁时,才能成功申请排他锁;否则会被阻塞。

意向锁

在MySQL的InnoDB引擎中,支持多种锁级别,其中包括行级锁和表级锁。当多个事务需要访问同一共享资源时,如果每个事务都直接请求获取锁,可能会发生相互阻塞的情况,甚至可能导致死锁的产生。

假设事务A对表Table1中的某一行加上了行级锁,这使得该行记录只能被读取而不能被修改。同时,事务B试图对Table1增加表级锁。如果事务B成功获取了表级锁,那么它就可以修改表中的任意一行记录,这就导致了冲突。

为了解决这个问题,需要让事务B在尝试对Table1增加表级锁之前,先判断是否有其他事务已经对该表的某行加了行级锁。但是,事务B显然不能遍历整个表的数据逐条进行判断是否有加锁。

因此,MySQL引入了意向锁机制。意向锁是数据库管理系统中用于实现锁协议的一种机制,旨在处理不同锁粒度(如行锁和表锁)之间的并发性问题。这种机制帮助解决了不同锁粒度之间的并发问题,而对于相同锁粒度之间的并发问题,可以通过行级互斥锁来解决。

注意:

  1. 意向锁并非直接用于锁定资源,而是用于通知其他事务,以避免它们在资源上设置不兼容的锁。

  2. 意向锁并非由用户直接请求,而是由MySQL管理的。

当一个事务请求获取行级锁或表级锁时,MySQL会自动获取相应表的意向锁。这样,其他事务在请求获取表锁时,就可以首先基于这个意向锁来发现是否已经有其他事务加过锁,并根据该锁的类型(意向共享锁/意向排他锁)来判断自己是否可以获取锁。通过这种方式,可以在不阻塞其他事务的情况下,为当前事务锁定资源。意向锁有两种类型:意向共享锁和意向排他锁。

  1. 意向共享锁:表示事务打算在资源上设置共享锁(读锁)。通常用于表明事务计划读取资源,并希望在读取时不会有其他事务设置排他锁。

  2. 意向排他锁:表示事务打算在资源上设置排他锁(写锁)。这表示事务计划修改资源,并不希望有其他事务同时设置共享或排他锁。

意向锁是一种表级锁,在触发意向锁的事务提交或回滚后会被释放。

MySQL之MVCC机制

对于并发事务通常可以通过其提供的各类锁,去确保各场景下的线程安全问题,从而能够防止脏写、脏读、不可重复读及幻读这类问题出现。

虽然MySQL提供的锁机制确实能解决并发事务带来的一系列问题,但由于加锁后会让一部分事务串行化,而MySQL本身就是基于磁盘实现的,性能无法跟内存型数据库娉美,因此并发事务串行化会使其效率更低。

相较于加锁串行化执行,MVCC机制的出现,则以另一种形式解决了并发事务造成的问题。

一、并发事务的四种场景

 并发事务中又会分为四种情况,分别是读-读、写-写、读-写、写-读,这四种情况分别对应并发事务执行时的四种场景。

1.1、读-读场景

读-读场景即是指多个事务/线程在一起读取一个相同的数据,比如事务T1正在读取ID=88的行记录,事务T2也在读取这条记录,两个事务之间是并发执行的。

广为人知的一点:MySQL执行查询语句,绝对不会对引起数据的任何变化,因此对于这种情况而言,不需要做任何操作,因为不改变数据就不会引起任何并发问题。

1.2、写-写场景

写-写场景也比较简单,也就是指多个事务之间一起对同一数据进行写操作,比如事务T1ID=88的行记录做修改操作,事务T2则对这条数据做删除操作,事务T1提交事务后想查询看一下,结果连这条数据都不见了,这也是所谓的脏写问题,也被称为更新覆盖问题,对于这个问题在所有数据库、所有隔离级别中都是零容忍的存在,最低的隔离级别也要解决这个问题。

1.3、读-写、写-读场景

读-写、写-读实际上从宏观角度来看,可以理解成同一种类型的操作,但从微观角度而言则是两种不同的情况,读-写是指一个事务先开始读,然后另一个事务则过来执行写操作,写-读则相反,主要是读、写发生的前后顺序的区别。

并发事务中同时存在读、写两类操作时,这是最容易出问题的场景,脏读、不可重复读、幻读都出自于这种场景中,当有一个事务在做写操作时,读的事务中就有可能出现这一系列问题,因此数据库才会引入各种机制解决。

1.4、各场景下解决问题的方案

对于写-写、读-写、写-读这三类场景,都是利用加锁的方案确保线程安全,但上面说到过,加锁会导致部分事务串行化,因此效率会下降,而MVCC机制的诞生则解决了这个问题。

加锁的目的是什么?防止脏写、脏读、不可重复读及幻读这类问题出现。

对于脏写问题,这是写-写场景下会出现的,写-写场景必须要加锁才能保障安全,因此先将该场景排除在外。再想想:对于读-写并存的场景中,脏读、不可重复读及幻读问题都出自该场景中,但实际项目中,出现这些问题的几率本身就比较小,为了防止一些小概念事件,就将所有操纵同一数据的并发读写事务串行化,这似乎有些不讲道理。

因此MySQL就基于读-写并存的场景,推出了MVCC机制,在线程安全问题和加锁串行化之间做了一定取舍,让两者之间达到了很好的平衡,即防止了脏读、不可重复读及幻读问题的出现,又无需对并发读-写事务加锁处理。

二、MySQL-MVCC机制综述

MVCC机制的全称为Multi-Version Concurrency Control,即多版本并发控制技术,主要是为了提升数据库并发性能而设计的,其中采用更好的方式处理了读-写并发冲突,做到即使有读写冲突时,也可以不加锁解决,从而确保了任何时刻的读操作都是非阻塞的。

但与其说是MySQL-MVCC机制,还不如说是InnoDB-MVCC机制,因为在MySQL众多的开源存储引擎中,几乎只有InnoDB实现了MVCC机制,类似于MyISAM、Memory等引擎中都未曾实现。

2.1、MVCC技术在日常生活中的体现

假设发布了一篇关于《MySQL事务机制》的文章,发布后挺受欢迎的,因此有不少小伙伴在看,其中有一位小伙伴比较细心,文中存在两三个错别字,被这位小伙伴指出来了,因此我去修正错别字后重新发布。

问题来了,对于文章首次发布也好,重新发布也罢,绝对要等审核通过后才会正式发布的,那我修正文章后重新发布,文章又会进入「审核中」这个状态,此时对于其他正在看、准备看的小伙伴来说,文章是不是就不见了?毕竟文章还在审核,因此对这个业务需求又该如何实现呢?多版本。

对于首次发布后通过审核的文章,在后续重新发布审核时,用户可以看到更新前的文章,也就是看到老版本的文章,当更新后的文章审核通过后,再使用新版本的文章代替老版本的文章即可。

这样就能做到新老版本的兼容,也能够确保文章修正时,其他正在阅读的小伙伴不会受影响,而MySQL-MVCC机制的思想也大致相同。

2.2、MySQL-MVCC多版本并发控制

MySQL中的多版本并发控制,也和上面给出的例子类似,毕竟回想一下,脏读、不可重复读、幻读问题都是由于多个事务并发读写导致的,但这些问题都是基于最新版本的数据并发操作才会出现,那如果读、写的事务操作的不是同一个版本呢?比如写操作走新版本,读操作走老版本,这样是不是无论执行写操作的事务操作了什么,都不会影响读的事务?答案是Yes

不过要稍微记住,MySQL中仅在RC读已提交级别、RR可重复读级别才会使用MVCC机制。

因为如果是RU读未提交级别,既然都允许存在脏读问题、允许一个事务读取另一个事务未提交的数据,那自然可以直接读最新版本的数据,因此无需MVCC介入。

同时如若是Serializable串行化级别,因为会将所有的并发事务串行化处理,也就是不论事务是读操作,亦或是写操作,都会被排好队一个个执行,这都不存在所谓的多线程并发问题了,自然也无需MVCC介入。

因此要牢记:MVCC机制在MySQL中,仅有InnoDB引擎支持,而在该引擎中,MVCC机制只对RC、RR两个隔离级别下的事务生效。当然,RC、RR两个不同的隔离级别中,MVCC的实现也存在些许差异,对于这点后续详细讲解。

三、MySQL-MVCC机制实现原理剖析

MVCC机制主要通过隐藏字段、Undo-log日志、ReadView这三个东西实现的,因而也被称为“MVCC三剑客”。

3.1、InnoDB表的隐藏字段

通常而言,当你基于InnoDB引擎建立一张表后,MySQL除开会构建你显式声明的字段外,通常还会构建一些InnoDB引擎的隐藏字段,在InnoDB引擎中主要有DB_ROW_ID、DB_Deleted_Bit、DB_TRX_ID、DB_ROLL_PTR这四个隐藏字段。

3.1.1、隐藏主键 - ROW_ID(6Bytes)

对于InnoDB引擎的表而言,由于其表数据是按照聚簇索引的格式存储,因此通常都会选择主键作为聚簇索引列,然后基于主键字段构建索引树,但如若表中未定义主键,则会选择一个具备唯一非空属性的字段,作为聚簇索引的字段来构建树。

当两者都不存在时,InnoDB就会隐式定义一个顺序递增的列ROW_ID来作为聚簇索引列。

因此要牢记一点,如果你选择的引擎是InnoDB,就算你的表中未定义主键、索引,其实默认也会存在一个聚簇索引,只不过这个索引在上层无法使用,仅提供给InnoDB构建树结构存储表数据。

3.1.2、删除标识 - Deleted_Bit(1Bytes)

对于一条delete语句而言,当执行后并不会立马删除表的数据,而是将这条数据的Deleted_Bit删除标识改为1/true,后续的查询SQL检索数据时,如果检索到了这条数据,但看到隐藏字段Deleted_Bit=1时,就知道该数据已经被其他事务delete了,因此不会将这条数据纳入结果集。

设计Deleted_Bit这个隐藏字段的好处是什么呢?主要是能够有利于聚簇索引,比如当一个事务中删除一条数据后,后续又执行了回滚操作,假设此时是真正的删除了表数据,会发生什么情况呢?

  • ①删除表数据时,有可能会破坏索引树原本的结构,导致出现叶子节点合并的情况。

  • ②事务回滚时,又需重新插入这条数据,再次插入时又会破坏前面的结构,导致叶子节点分裂。

综上所述,如果执行delete语句就删除真实的表数据,由于事务回滚的问题,就很有可能导致聚簇索引树发生两次结构调整,这其中的开销可想而知,而且先删除,再回滚,最终树又变成了原状,那这两次树的结构调整还是无意义的。

所以,当执行delete语句时,只会改变将隐藏字段中的删除标识改为1/true,如果后续事务出现回滚动作,直接将其标识再改回0/false即可,这样就避免了索引树的结构调整。

但如若事务删除数据之后提交了事务呢?总不能让这条数据一直留在磁盘吧?毕竟如果所有的delete操作都这么干,就会导致磁盘爆满,显然这样是不妥的,因此删除标识为1/true的数据最终依旧会从磁盘中移除,啥时候移呢?

MySQL中存在purger线程的概念,为了防止“已删除”的数据占用过多的磁盘空间,purger线程会自动清理Deleted_Bit=1/true的行数据。

为了确保清理数据时不会影响MVCC的正常工作,purger线程自身也会维护一个ReadView,如果某条数据的Deleted_Bit=true,并且TRX_IDpurge线程的ReadView可见,那么这条数据一定是可以被安全清除的(即不会影响MVCC工作)。

3.1.3、最近更新的事务ID - TRX_ID(6Bytes)

TRX_ID全称为transaction_id,翻译过来也就是事务ID的意思,MySQL对于每一个创建的事务,都会为其分配一个事务ID,事务ID同样遵循顺序递增的特性,即后来的事务ID绝对会比之前的ID要大,比如:

此时事务T1准备修改表字段的值,MySQL会为其分配一个事务ID=1,当事务T2准备向表中插入一条数据时,又会为这个事务分配一个ID=2......

但有一个细节点需要记住:MySQL对于所有包含写入SQL的事务,会为其分配一个顺序递增的事务ID,但如果是一条select查询语句,则分配的事务ID=0

不过对于手动开启的事务,MySQL都会为其分配事务ID,就算这个手动开启的事务中仅有select操作。

表中的隐藏字段TRX_ID,记录的就是最近一次改动当前这条数据的事务ID,这个字段是实现MVCC机制的核心之一。

3.1.4、回滚指针 - ROLL_PTR(7Bytes)

ROLL_PTR全称为rollback_pointer,也就是回滚指针的意思,这个也是表中每条数据都会存在的一个隐藏字段,当一个事务对一条数据做了改动后,都会将旧版本的数据放到Undo-log日志中,而rollback_pointer就是一个地址指针,指向Undo-log日志中旧版本的数据,当需要回滚事务时,就可以通过这个隐藏列,来找到改动之前的旧版本数据,而MVCC机制也利用这点,实现了行数据的多版本。

3.2、InnoDB引擎的Undo-log日志

MySQL事务机制是基于Undo-log实现的,同时在刚刚在聊回滚指针时,聊到了Undo-log日志中会存储旧版本的数据,但要注意:Undo-log中并不仅仅只存储一条旧版本数据,其实在该日志中会有一个版本链。

SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊猫      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+

UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
UPDATE `zz_users` SET user_sex = "男" WHERE user_id = 1;

比如上述这段SQL隶属于trx_id=1T1事务,其中对同一条数据改动了两次,那Undo-log日志中只会存储一条旧版本数据吗?NO,答案是两条旧版本的数据,如下图:

图片

Undo版本链

从上图中可明显看出:不同的旧版本数据,会以roll_ptr回滚指针作为链接点,然后将所有的旧版本数据组成一个单向链表。但要注意一点:最新的旧版本数据,都会插入到链表头中,而不是追加到链表尾部。

细说一下执行上述update语句的详细过程:
①对ID=1这条要修改的行数据加上排他锁。
②将原本的旧数据拷贝到Undo-logrollback Segment区域。
③对表数据上的记录进行修改,修改完成后将隐藏字段中的trx_id改为当前事务ID
④将隐藏字段中的roll_ptr指向Undo-log中对应的旧数据,并在提交事务后释放锁。

为什么Undo-log日志要设计出版本链呢?两个好处:一方面可以实现事务点回滚,另一方面则可以实现MVCC机制。

与之前的删除标识类似,一条数据被delete后并提交了,最终会从磁盘移除,而Undo-log中记录的旧版本数据,同样会占用空间,因此在事务提交后也会移除,移除的工作同样由purger线程负责,purger线程内部也会维护一个ReadView,它会以此作为判断依据,来决定何时移除Undo记录。

3.3、MVCC核心 - ReadView

MVCC在前面聊到过,它翻译过来就是多版本并发控制的意思,对于这个名词中的多版本已经通过Undo-log日志实现了,但再思考一个问题:如果T2事务要查询一条行数据,此时这条行数据正在被T1事务写,那也就代表着这条数据可能存在多个旧版本数据,T2事务在查询时,应该读这条数据的哪个版本呢?此时就需要用到ReadView,用它来做多版本的并发控制,根据查询的时机来选择一个当前事务可见的旧版本数据读取。

那究竟什么是ReadView呢?就是一个事务在尝试读取一条数据时,MVCC基于当前MySQL的运行状态生成的快照,也被称之为读视图,即ReadView,在这个快照中记录着当前所有活跃事务的ID(活跃事务是指还在执行的事务,即未结束(提交/回滚)的事务)。

当一个事务启动后,首次执行select操作时,MVCC就会生成一个数据库当前的ReadView,通常而言,一个事务与一个ReadView属于一对一的关系(不同隔离级别下也会存在细微差异),ReadView一般包含四个核心内容:

  • creator_trx_id:代表创建当前这个ReadView的事务ID

  • trx_ids:表示在生成当前ReadView时,系统内活跃的事务ID列表。

  • up_limit_id:活跃的事务列表中,最小的事务ID

  • low_limit_id:表示在生成当前ReadView时,系统中要给下一个事务分配的ID值。

上面四个值很简单,值得一提的是low_limit_id,它并不是目前系统中活跃事务的最大ID,因为之前讲到过,MySQL的事务ID是按序递增的,因此当启动一个新的事务时,都会为其分配事务ID,而这个low_limit_id则是整个MySQL中,要为下一个事务分配的ID值。

下面上个ReadView的示意图,来好好理解一下它:

图片

假设目前数据库中共有T1~T5这五个事务,T1、T2、T4还在执行,T3已经回滚,T5已经提交,此时当有一条查询语句执行时,就会利用MVCC机制生成一个ReadView,由于前面讲过,单纯由一条select语句组成的事务并不会分配事务ID,因此默认为0,所以目前这个快照的信息如下:

{
    "creator_trx_id" : "0",
    "trx_ids" : "[1,2,4]",
    "up_limit_id" : "1",
    "low_limit_id" : "6"
}

3.4、MVCC机制实现原理

  • ①当一个事务尝试改动某条数据时,会将原本表中的旧数据放入Undo-log日志中。

  • ②当一个事务尝试查询某条数据时,MVCC会生成一个ReadView快照。

其中Undo-log主要实现数据的多版本,ReadView则主要实现多版本的并发控制,还是以之前的例子来举例说明:

-- 事务T1:trx_id=1
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
UPDATE `zz_users` SET user_sex = "男" WHERE user_id = 1;
-- 事务T2:trx_id=2
SELECT * FROM `zz_users` WHERE user_id = 1;

目前存在T1、T2两个并发事务,T1目前在修改ID=1的这条数据,而T2则准备查询这条数据,那么T2在执行时具体过程是怎么回事呢?

  • ①当事务中出现select语句时,会先根据MySQL的当前情况生成一个ReadView

  • ②判断行数据中的隐藏列trx_idReadView.creator_trx_id是否相同:

    • 相同:代表创建ReadView和修改行数据的事务是同一个,自然可以读取最新版数据。

    • 不相同:代表目前要查询的数据,是被其他事务修改过的,继续往下执行。

  • ③判断隐藏列trx_id是否小于ReadView.up_limit_id最小活跃事务ID

    • 小于:代表改动行数据的事务在创建快照前就已结束,可以读取最新版本的数据。

    • 不小于:则代表改动行数据的事务还在执行,因此需要继续往下判断。

  • ④判断隐藏列trx_id是否小于ReadView.low_limit_id这个值:

    • 大于或等于:代表改动行数据的事务是生成快照后才开启的,因此不能访问最新版数据。

    • 小于:表示改动行数据的事务IDup_limit_id、low_limit_id之间,需要进一步判断。

  • ⑤如果隐藏列trx_id小于low_limit_id,继续判断trx_id是否在trx_ids中:

    • 在:表示改动行数据的事务目前依旧在执行,不能访问最新版数据。

    • 不在:表示改动行数据的事务已经结束,可以访问最新版的数据。

说简单一点,就是首先会去获取表中行数据的隐藏列,然后经过上述一系列判断后,可以得知:目前查询数据的事务到底能不能访问最新版的数据。如果能,就直接拿到表中的数据并返回,反之,不能则去Undo-log日志中获取旧版本的数据返回。

注意:假设Undo-log日志中存在版本链怎么办?该获取哪个版本的旧数据呢?

如果Undo-log日志中的旧数据存在一个版本链时,此时会首先根据隐藏列roll_ptr找到链表头,然后依次遍历整个列表,从而检索到最合适的一条数据并返回。但在这个遍历过程中,是如何判断一个旧版本的数据是否合适的呢?条件如下:

  • 旧版本的数据,其隐藏列trx_id不能在ReadView.trx_ids活跃事务列表中。

因为如果旧版本的数据,其trx_id依旧在ReadView.trx_ids中,就代表着产生这条旧数据的事务还未提交,自然不能读取这个版本的数据,以前面给出的例子来说明:

图片

这是由事务T1生成的版本链,此时T2生成的ReadView如下:

{
    "creator_trx_id" : "0",
    "trx_ids" : "[1]",
    "up_limit_id" : "1",
    "low_limit_id" : "2"
}

结合这个ReadView信息,经过前面那一系列判断后,最终会得到:不能读取最新版数据,因此需要去Undo-log的版本链中读数据,首先根据roll_ptr找到第一条旧数据:

图片

第一条旧数据
此时发现其trx_id=1,位于ReadView.trx_ids中,因此不能读取这条旧数据,接着再根据这条旧数据的roll_ptr找到第二条旧版本数据:

图片

第二条旧数据
这时再看其trx_id=null,并不位于ReadView.trx_ids中,null表示这条数据在上次MySQL运行时就已插入了,因此这条旧版本的数据可以被T2事务读取,最终T2就会查询到这条数据并返回。

范围查询时,突然出现新增数据怎么办呢?

SELECT * FROM `zz_users`;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊猫      | 女       | 6666     | 2022-08-14 15:22:01 |
|       2 | 竹子      | 男       | 1234     | 2022-09-14 16:17:44 |
|       3 | 子竹      | 男       | 4321     | 2022-09-16 07:42:21 |
|       4 | 猫熊      | 女       | 8888     | 2022-09-27 17:22:59 |
|       9 | 黑竹      | 男       | 9999     | 2022-09-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+

-- T1事务:查询ID >= 3 的所有用户信息
select * from  `zz_users` where user_id >= 3;

-- T2事务:新增一条 ID = 6 的用户记录
INSERT INTO `zz_users` VALUES(6,"棕熊","男","7777","2022-10-02 16:21:33");

此时当T1事务查询数据时,突然蹦出来一条ID=6的数据,经过判断之后会发现新增这条数据的事务还在执行,所以要去查询旧版本数据,但此时由于是新增操作,因此roll_ptr=null,即表示没有旧版本数据,此时会不会读取最新版的数据呢?答案是NO,如果查询数据的事务不能读取最新版数据,同时又无法从版本链中找到旧数据,那就意味着这条数据对T1事务完全不可见,因此T1的查询结果中不会包含ID=6的这条新增记录。

附加说明:如果这个一个修改数据的事务正好快照生成结束后才开启的,并且多次修改了目前select操作要读取的目标数据行,因此在Undo版本链中会产生一系列旧数据,但根据前面的一系列判断,最终select事务会去版本链中找数据,此时后面这个修改事务的ID,恰巧不在快照到trx_ids列表中怎么办呢?
面对于这种情况,当MVCC发现旧版本的数据,其隐藏列的trx_id大于目前快照的最大事务ID时,MVCC会自动跳过该版本的数据,Why?因为MySQL在分配事务ID时,都是以递增的顺序分配,所以当旧版本上的trx_id大于快照的最大事务ID时,说明这条旧版本数据是在快照生成之后产生的,所以会跳过对应的旧版本数据不读取。

3.5、RC、RR不同级别下的MVCC机制

ReadView是一个事务中只生成一次,还是每次select时都会生成呢?

这个问题的答案跟事务的隔离机制有关,不同级别的隔离机制也并不同,如果此时MySQL的事务隔离机制处于RC读已提交级别,那此时来看一个例子:

-- 开启一个事务T1:主要是修改两次ID=1的行数据
begin;
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
UPDATE `zz_users` SET user_sex = "男" WHERE user_id = 1;

-- 再开启一个事务T2:主要是查询ID=1的行数据
SELECT * FROM `zz_users` WHERE user_id = 1;

-- 此时先提交事务T1
commit;

-- 再次在事务T2中查一次ID=1的行数据
SELECT * FROM `zz_users` WHERE user_id = 1;

再来看看上述这个案例,如果是处于RC级别的情况下,T2事务中的查询结果如下:

SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊猫      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+

SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 竹子      | 男       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+

为什么两次查询结果不一样呢?因为RC级别下,MVCC机制是会在每次select语句执行前,都会生成一个ReadView,由于T2事务中第二次查询数据时,T1已经提交了,所以第二次查询就能读到修改后的数据,这是啥问题?不可重复读问题。

接着再来看看RR可重复级别下的MVCC机制,SQL代码和上述一模一样,但查询结果如下:

SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊猫      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+

SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊猫      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+

这又是为啥?为啥明明在T2事务第二次查询前,T1已经提交了,T2依旧查询出的结果和第一次相同呢?这是因为在RR级别中,一个事务只会在首次执行select语句时生成快照,后续所有的select操作都会基于这个ReadView来判断,这样也就解决了RC级别中存在的不可重复问题。

实际上InnoDB引擎中,是可以在RC级别解决脏读、不可重复读、幻读这一系列问题的,但是为了将事务隔离级别设计的符合DBMS规范,因此在实现时刻意保留了这些问题,然后放在更高的隔离级别中解决。

四、MVCC机制篇总结

MVCC多版本并发控制,其中的多版本主要依赖Undo-log日志来实现,而并发控制则通过表的隐藏字段+ReadView快照来实现,通过Undo-log日志、隐藏字段、ReadView快照,实现了MVCC机制。

  • 9
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值