(十)全解MySQL之死锁问题分析、事务隔离与锁机制的底层原理剖析[亲测有效]

(十)全解MySQL之死锁问题分析、事务隔离与锁机制的底层原理剖析[亲测有效]MySQL事务隔离与锁机制是一个老生常谈的话题,但似乎很少有资料去讲它们底层真正的实现,而本章则会深入底层真正揭开事务与锁机制的神秘面纱~

引言

本文为掘金社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

   经过《MySQL锁机制》、《MySQL-MVCC机制》两篇后,咱们已经大致了解MySQL中处理并发事务的手段,不过对于锁机制、MVCC机制都并未与之前说到的《MySQL事务机制》产生关联关系,同时对于MySQL锁机制的实现原理也未曾剖析,因此本篇作为事务、锁、MVCC这三者的汇总篇,会在本章中补全之前空缺的一些细节,同时也会将锁、MVCC机制与事务机制之间的关系彻底理清楚。

一、MySQL中的死锁现象

   还记得咱们在《MySQL锁机制》这篇文章中,描述事务、连接、线程三者关系的那段话嘛?
事务与线程的关系
所谓的并发事务,本质上就是MySQL内部多条工作线程并行执行的情况,也正由于MySQL是多线程应用,所以需要具备完善的锁机制来避免线程不安全问题的问题产生,但熟悉多线程编程的小伙伴应该都清楚一点,对于多线程与锁而言,存在一个100%会出现的偶发问题,即死锁问题

1.1、死锁问题概述(Dead Lock)

   对于死锁的定义,这里就不展开叙述了,因为在之前《并发编程-死锁、活锁、锁饥饿》中曾详细描述过,如下:
死锁
一句话来概述死锁:死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源竞争而造成相互等待、相互僵持的现象,一般当程序中出现死锁问题后,若无外力介入,则不会解除“僵持”状态,它们之间会一直相互等待下去,直到天荒地老、海枯石烂~

当然,为了照顾一些不想看并发编程文章的小伙伴,这里也把之前的死锁栗子搬过来~

某一天竹子和熊猫在森林里捡到一把玩具弓箭,竹子和熊猫都想玩,原本说好一人玩一次的来,但是后面竹子耍赖,想再玩一次,所以就把弓一直拿在自己手上,而本应该轮到熊猫玩的,所以熊猫跑去捡起了竹子前面刚刚射出去的箭,然后跑回来之后便发生了如下状况:
熊猫道:竹子,快把你手里的弓给我,该轮到我玩了….
竹子说:不,你先把你手里的箭给我,我再玩一次就给你….
最终导致熊猫等着竹子的弓,竹子等着熊猫的箭,双方都不肯退步,结果陷入僵局场面…..

   比如上述这个栗子中,「竹子、熊猫」可以理解成两条线程,而「弓、箭」则可以理解成运行时所需的资源,由于双方各自占据对方所需的资源,因此就造就了死锁现象发生,此时想要解决这个问题,就必须第三者外力介入,把“违反约定”的竹子手中的弓拿过去给熊猫……,然后等熊猫玩了之后,再给竹子,恢复之前原有的“执行顺序”。

1.2、MySQL中的死锁现象

   而MySQLRedis、Nginx这类单线程工作的程序不同,它属于一种内部采用多线程工作的应用,因而不可避免的就会产生死锁问题,比如举个例子:

SELECT * FROM `zz_account`;
+-----------+---------+
| user_name | balance |
+-----------+---------+
|    熊猫   | 6666666 |
|    竹子   | 8888888 |
+-----------+---------+

-- T1事务:竹子向熊猫转账
UPDATE `zz_account` SET balance = balance - 888 WHERE user_name = "竹子";
UPDATE `zz_account` SET balance = balance + 888 WHERE user_name = "熊猫";

-- T2事务:熊猫向竹子转账
UPDATE `zz_account` SET balance = balance - 666 WHERE user_name = "熊猫";
UPDATE `zz_account` SET balance = balance + 666 WHERE user_name = "竹子";

上面有一张很简单的账户表,因为只是为了演示效果,所以其中仅设计了用户名和余额两个字段,紧接着有T1、T2两个事务,T1中竹子向熊猫转账,而T2中则是熊猫向竹子转账,也就是一个相互转账的过程,此时来分析一下:

  • T1事务会先扣减竹子的账户余额,因此会修改数据,此时会默认加上排他锁。
  • T2事务也会先扣减熊猫的账户余额,因此同样会对熊猫这条数据加上排他锁。
  • T1减完了竹子的余额后,准备获取锁把熊猫的余额加888,但由于此时熊猫的锁被T2事务持有,T1会陷入阻塞等待。
  • T2减完熊猫的余额后,也准备获取锁把竹子的余额加666,但此时竹子的锁被T1持有。

此时就会出现问题,T1等待T2释放锁、T2等待T1释放锁,双方各自等待对方释放锁,一直如此僵持下去,最终就引发了死锁问题,那先来看看具体的SQL执行情况是什么样的呢?如下:
死锁演示
如上图所示,一步步的跟着标出的序号去看,最终会发现:当死锁问题出现时,MySQL会自动检测并介入,强制回滚结束一个“死锁的参与者(事务)”,从而打破死锁的僵局,让另一个事务能继续执行。

看到这里有小伙伴会问了,为啥MySQL能自动检测死锁呀?其实这跟死锁检测机制有关,后续再细说。

但是要牢记一点,如果你也想自己做上述实验,那么千万不要忘了在创建了表后,基于user_name创建一个主键索引:

ALTER TABLE `zz_account` ADD PRIMARY KEY p_index(user_name);

如果你不为user_name字段加上主键索引,那是无法模拟出死锁问题的,这是为什么呢?还记得之前在《MySQL锁机制-记录锁》中聊到的一点嘛?在InnoDB中,如果一条SQL语句能命中索引执行,那就会加行锁,但如果无法命中索引加的就是表锁。

在上述给出的案例中,因为表中没有显示指定主键,同时也不存在一个唯一非空的索引,因此InnoDB会隐式定义一个row_id来维护聚簇索引的结构,但因为update语句中无法使用这个隐藏列,所以是走全表方式执行,因此就将整个表数据锁起来了。

而这里的四条update语句都是基于zz_account账户表在操作,因此两个事务竞争的是同一个锁资源,所以自然无法复现死锁现象,也就是T1修改时,T2的第一条SQL也不能执行,会阻塞等待表锁的释放。

而当咱们显示的定义了主键索引后,InnoDB会基于该主键字段去构建聚簇索引,因此后续的update语句可以命中索引,执行时自然获取的也是行级别的排他锁。

1.3、MySQL中死锁如何解决呢?

在之前关于死锁的并发文章中聊到过,对于解决死锁问题可以从多个维度出发,比如预防死锁、避免死锁、解除死锁等,而当死锁问题出现后该如何解决呢?一般只有两种方案:

  • 锁超时机制:事务/线程在等待锁时,超出一定时间后自动放弃等待并返回。
  • 外力介入打破僵局:第三者介入,将死锁情况中的某个事务/线程强制结束,让其他事务继续执行。

1.3.1、MySQL的锁超时机制

InnoDB中其实提供了锁的超时机制,也就是一个事务在长时间内无法获取到锁时,就会主动放弃等待,抛出相关的错误码及信息,然后返回给客户端。但这里的时间限制到底是多久呢?可以通过如下命令查询:

show variables like 'innodb_lock_wait_timeout';
+--------------------------+-------+
| Variable_name            | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 50    |
+--------------------------+-------+

默认的锁超时时间是50s,也就是在50s内未获取到锁的事务,会自动结束并返回。那也就意味着当死锁情况出现时,这个死锁过程最多持续50s,然后其中就会有一个事务主动退出竞争,释放持有的锁资源,这似乎听起来蛮不错呀,但实际业务中,仅依赖超时机制去解除死锁是不够的,毕竟高并发情况下,50s时间太长了,会导致越来越多的事务阻塞。

那么咱们能不能把这个参数调小一点呢?比如调到1s,可以吗?当然可以,确实也能确保死锁发生后,在很短的时间内可以自动解除,但改掉了这个参数之后,也会影响正常事务等待锁的时间,也就是大部分未发生死锁,但需要等待锁资源的事务,在等待1s之后,就会立马报错并返回,这显然并不合理,毕竟容易误伤“友军”。

也正是由于依靠锁超时机制,略微有些不靠谱,因此InnoDB也专门针对于死锁问题,研发了一种检测算法,名为wait-for graph算法。

1.3.2、死锁检测算法 – wait-for graph

这种算法是专门用于检测死锁问题的,在该算法中会对于目前库中所有活跃的事务生成等待图,啥意思呢?以上述的死锁案例来看,在MySQL内部会生成一张这样的等待图:
等待图
也就是T1持有着「竹子」这条数据的锁,正在等待获取「熊猫」这条数据的锁,而T2事务持有「熊猫」这条数据的锁,正在等待获取「竹子」这条数据的锁,最终T1、T2两个事务之间就出现了等待闭环,因此当MySQL发现了这种等待闭环时,就会强制介入,回滚结束其中一个事务,强制打破该闭环,从而解除死锁问题。

但这个“等待图”只是为了方便理解画出来的,内部的实现其实存在些许差异,一起来聊一聊。

wait-for graph算法被启用后,会要求MySQL收集两个信息:

  • 锁的信息链表:目前持有每个锁的事务是谁。
  • 事务等待链表:阻塞的事务要等待的锁是谁。

每当一个事务需要阻塞等待某个锁时,就会触发一次wait-for graph算法,该算法会以当前事务作为起点,然后从「锁的信息链表」中找到对应中锁信息,再去根据锁的持有者(事务),在「事务等待链表」中进行查找,看看持有锁的事务是否在等待获取其他锁,如果是,则再去看看另一个持有锁的事务,是否在等待其他锁…..,经过一系列的判断后,再看看是否会出现闭环,出现的话则介入破坏。

上面这个算法的过程,听起来似乎有些晕乎乎的,但实际上并不难,套个例子来理解,好比目前库中有T1、T2、T3三个事务、有X1、X2、X3三个锁,事务与锁的关系如下:
事务与锁

此时当T3事务需要阻塞等待获取X1锁时,就会触发一次wait-for graph算法,流程如下:

  • ①先根据T3要获取的X1锁,在「锁的信息链表」中找到X1锁的持有者T1
  • ②再在「事务等待链表」中查找,看看T1是否在等待获取其他锁,此时会得知T1等待X2
  • ③再去「锁的信息链表」中找到X2锁的持有者T2,再看看T2是否在阻塞等待获取其他锁。
  • ④再在「事务等待链表」中查找T2,发现T2正在等待获取X3锁,再找X3锁的持有者。

经过上述一系列算法过程后,最终会发现X3锁的持有者为T3,而本次算法又正是T3事务触发的,此时又回到了T3事务,也就代表着产生了“闭环”,因此也可以证明这里出现了死锁现象,所以MySQL会强制回滚其中的一个事务,来抵达解除死锁的目的。

但出现死锁问题时,MySQL会选择哪个事务回滚呢?之前分析过,当一个事务在执行SQL更改数据时,都会记录在Undo-log日志中,Undo量越小的事务,代表它对数据的更改越少,同时回滚的代价最低,因此会选择Undo量最小的事务回滚(如若两个事务的Undo量相同,会选择回滚触发死锁的事务)。

同时,可以通过innodb_deadlock_detect=on|off这个参数,来控制是否开启死锁检测机制。

死锁检测机制在MySQL后续的高版本中是默认开启的,但实际上死锁检测的开销不小,上面三个并发事务阻塞时,会对「事务等待链表、锁的信息链表」共计检索六次,那当阻塞的并发事务越来越多时,检测的效率也会呈线性增长。

1.3.3、如何避免死锁产生?

因为死锁的检测过程较为耗时,所以尽量不要等死锁出现后再去解除,而是尽量调整业务避免死锁的产生,一般来说可以从如下方面考虑:

  • 合理的设计索引结构,使业务SQL在执行时能通过索引定位到具体的几行数据,减小锁的粒度。
  • 业务允许的情况下,也可以将隔离级别调低,因为级别越低,锁的限制会越小。
  • 调整业务SQL的逻辑顺序,较大、耗时较长的事务尽量放在特定时间去执行(如凌晨对账…)。
  • 尽可能的拆分业务的粒度,一个业务组成的大事务,尽量拆成多个小事务,缩短一个事务持有锁的时间。
  • 如果没有强制性要求,就尽量不要手动在事务中获取排他锁,否则会造成一些不必要的锁出现,增大产生死锁的几率。
  • ……..

其实简单来说,也就是在业务允许的情况下,尽量缩短一个事务持有锁的时间、减小锁的粒度以及锁的数量。

同时也要记住:当MySQL运行过程中产生了死锁问题,那这个死锁问题以后绝对会再次出现,当死锁被MySQL自己解除后,一定要记住去排除业务SQL的执行逻辑,找到产生死锁的业务,然后调整业务SQL的执行顺序,这样才能从根源上避免死锁产生。

二、锁机制的底层实现原理

   对于MySQL的锁机制究竟是如何实现的呢?对于这点其实很少有资料去讲到,一般都是停留在锁机制的表层阐述,比如锁粒度、锁类型的划分,但既然咱们讲了锁机制,那也就顺便聊一下它的底层实现。

2.1、锁的内存结构

Java中,Synchronized锁是基于Monitor实现的,而ReetrantLock又是基于AQS实现的,那MySQL的锁是基于啥实现的呢?想要搞清楚这点,得先弄明白锁的内存结构,先看图:
锁结构
InnoDB引擎中,每个锁对象在内存中的结构如上,其中记录的信息也比较多,先全部理清楚后再聊聊锁的实现。

2.1.1、锁的事务信息

其中记录着当前的锁结构是由哪个事务生成的,记录的是指针,指向一个具体的事务。

2.1.2、索引的信息

这个是行锁的特有信息,对于行锁来说,需要记录一下加锁的行数据属于哪个索引、哪个节点,记录的也是指针。

2.1.3、锁粒度信息

这个略微有些复杂,对于不同粒度的锁,其中存储的信息也并不同,如果是表锁,其中就记录了一下是对哪张表加的锁,以及表的一些其他信息。

但如果锁粒度是行锁,其中记录的信息更多,有三个较为重要的:

  • Space ID:加锁的行数据,所在的表空间ID
  • Page Number:加锁的行数据,所在的页号。
  • n_bits:使用的比特位,对于一页数据中,加了多少个锁(后续结合讲)。

2.1.4、锁类型信息

对于锁结构的类型,在内部实现了复用,采用一个32bittype_mode来表示,这个32bit的值可以拆为lock_mode、lock_type、rec_lock_type三部分,如下:
锁类型

  • lock_mode:表示锁的模式,使用低四位。
    • 0000/0:表示当前锁结构是共享意向锁,即IS锁。
    • 0001/1:表示当前锁结构是排他意向锁,即IX锁。
    • 0010/2:表示当前锁结构是共享锁,即S锁。
    • 0011/3:表示当前锁结构是排他锁,即X锁。
    • 0100/4:表示当前锁结构是自增锁,即AUTO-INC锁。
  • lock_type:表示锁的类型,使用低位中的5~8位。
    • LOCK_TABLE:当第5个比特位是1时,表示目前是表级锁。
    • LOCK_REC:当第6个比特位是1时,表示目前是行级锁。
  • rec_lock_type:表示行锁的具体类型,使用其余位。
    • LOCK_ORDINARY:当高23位全零时,表示目前是临键锁。
    • LOCK_GAP:当第10位是1时,表示目前是间隙锁。
    • LOCK_REC_NOT_GAP:当第11位是1时,表示目前是记录锁。
    • LOCK_INSERT_INTENTION:当第12位是1时,表示目前是插入意向锁。
    • .....:内部还有一些其他的锁类型,会使用其他位。
  • is_waiting:表示目前锁处于等待状态还是持有状态,使用低位中的第9位。
    • 0:表示is_waiting=false,即当前锁无需阻塞等待,是持有状态。
    • 1:表示is_waiting=true,即当前锁需要阻塞,是等待状态。

OK~,上面分析了这一堆之后,看起来难免有些晕乎乎的,上个例子来理解一下:

00000000000000000000000100100011

比如上面给出的这组bit,锁粒度、锁类型、锁状态是什么情况呢?如下:
锁信息
从上图中可得知,目前这组bit代表一个阻塞等待的行级排他临键锁结构。

2.1.5、其他信息

这个所谓的其他信息,也就是指一些用于辅助锁机制的信息,比如之前死锁检测机制中的「事务等待链表、锁的信息链表」,每一个事务和锁的持有、等待关系,都会在这里存储,将所有的事务、锁连接起来,就形成了上述的两个链表。

2.1.6、锁的比特位

与其说是锁的比特位,不如说是数据的比特位,好比举个例子:

SELECT * FROM `zz_student`;
+------------+--------+------+--------+
| student_id | name   | sex  | height |
+------------+--------+------+--------+
|          1 | 竹子   || 185cm  |
|          2 | 熊猫   || 170cm  |
|          3 | 子竹   || 182cm  |
|          4 | 棕熊   || 187cm  |
|          5 | 黑豹   || 177cm  |
|          6 | 脑斧   || 178cm  |
|          7 | 兔纸   || 165cm  |
+------------+--------+------+--------+

学生表中有七条数据,此时就会形成一个比特数组:000000000,等等,似乎不对!明明只有七条数据,为啥会有9个比特位呢?因为行锁中,间隙锁可以锁定无穷小、无穷大这两个间隙,因此这组比特中,首位和末位即表示无穷小、无穷大两个间隙。

好比此时T1事务,对ID=2、3、6这三条数据加锁了,此时这个比特数组就会变为001100100,表示T1事务同时锁定了三条数据。而之前聊到的n_bits,它就会记录一下在这组比特中,多少条记录被上锁了。

2.2、InnoDB的锁实现

上面已经分析了MySQL的锁对象结构,接着来设想一个问题:

如果一个事务同时需要对表中的1000条数据加锁,会生成1000个锁结构吗?

如果这里是SQL Server数据库,那绝对会生成1000个锁结构,因为它的行锁是加在行记录上的,但MySQL锁机制并不相同,因为MySQL是基于事务实现的锁,啥意思呢?来看看:

  • ①目前对表中不同行记录加锁的事务是同一个。
  • ②需要加锁的记录在同一个页面中。
  • ③目前事务加锁的类型都是相同的。
  • ④锁的等待状态也是相同的。

当上述四点条件被满足时,符合条件的行记录会被放入到同一个锁结构中,好比以上面的问题为例:

假设加锁的1000条数据分布在3个页面中,同时表中没有其他事务在操作,加的都是同一类型的锁。

此时依据上述的前提条件,那在内存中仅会生成三个锁结构,能够很大程度上减少锁结构的数量。总之情况再复杂,也不会像SQL Server般生成1000个锁对象,那样开销太大了!

2.3、MySQL获取锁的过程

当一个事务需要获取某个行锁时,首先会看一下内存中是否存在这条数据的锁结构,如果存在则生成一个锁结构,将其is_waiting对应的比特位改为1,表示目前事务在阻塞等待获取该锁,当其他事务释放锁后,会唤醒当前阻塞的事务,然后会将其is_waiting改为0,接着执行SQL

实际上会发现这个过程并不复杂,唯一有些难理解的点就在于:事务获取锁时,是如何在内存中,判断是否已经存在相同记录的锁结构呢?还记得锁结构中会记录的一个信息嘛?也就是「锁粒度信息」,如果是表锁,会记录表信息,如果是行锁,会记录表空间、页号等信息。在事务获取锁时,就是去看内存中,已存在的锁结构的这个信息,来判断是否存在其他事务获取了锁。

拿表锁来说,当事务要获取一张表的锁时,就会根据表名看一下其他锁结构,有没有获取当前这张表的锁,如果已经获取,看一下已经存在的表锁和目前要加的表锁,是否会存在冲突,冲突的话is_waiting=1,反之is_waiting=0,而行锁也是差不多的过程。

释放锁的过程也比较简单,这个工作一般是由MySQL自己完成的,当事务结束后会自动释放,释放的时候会去看一下,内存中是否有锁结构,正在等待获取目前释放的锁,如果有则唤醒对应的线程/事务。

其实看下来之后大家会发现,MySQL的锁机制实现,与常规的锁实现有些不一样,一般的锁机制都是基于持有标识+等待队列实现的,而MySQL则是略微有些不一样。

三、事务隔离机制的底层实现

   对于事务隔离机制的底层实现,其实在前面的章节中简单聊到过,对于并发事务造成的各类问题,在不同的隔离级别实际上,是通过不同粒度、类型的锁以及MVCC机制来解决的,也就是调整了并发事务的执行顺序,从而避免了这些问题产生,具体是如何做的呢?先来看看DBMS中对各隔离级别的要求。

  • RU/读未提交级别:要求该隔离级别下解决脏写问题。
  • RC/读已提交级别:要求该隔离级别下解决脏读问题。
  • RR/可重复读级别:要求该隔离级别下解决不可重复读问题。
  • Serializable/序列化级别:要求在该隔离级别下解决幻读问题。

虽然DBMS中要求在序列化级别再解决幻读问题,但在MySQL中,RR级别中就已经解决了幻读问题,因此MySQL中可以将RR级别视为最高级别,而Serializable级别几乎用不到,因为序列化级别中解决的问题,在RR级别中基本上已经解决了,再将MySQL调到Serializable级别反而会降低性能。

当然,RR级别下有些极端的情况,依旧会出现幻读问题,但线上100%不会出现,这点后续聊,先来看看各大隔离级别在MySQL中是如何实现的。

3.1、RU(Read Uncommitted)读未提交级别的实现

对于RU级别而言,从它名字上就可以看出来,该隔离级别下,一个事务可以读到其他事务未提交的数据,但同时要求解决脏写(更新覆盖)问题,那思考一下该怎么满足这个需求呢?先来看看不加锁的情况:

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 |
+---------+-----------+----------+----------+---------------------+

-- ----------- 请按照标出的序号阅读代码!!! --------------

-- ①开启一个事务T1
begin;

-- ③修改 ID=1 的姓名为 竹子
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;

-- ⑥提交T1
commit;
-- ②开启另一个事务T2
begin;

-- ④这里可以读取到T1中还未提交的 竹子 记录
SELECT * FROM `zz_users` WHERE user_id = 1;

-- ⑤T2中再次修改姓名为 黑熊
UPDATE `zz_users` SET user_name = "黑熊" WHERE user_id = 1;

-- ⑦提交T2
commit;

假设上述两个事务并发执行时,都不加锁,T2自然可以读取到T1修改后但还未提交的数据,但当T2再次修改ID=1的数据后,两个事务一起提交,此时就会出现T2覆盖T1的问题,这也就是脏写问题,而这个问题是不允许存在的,所以需要解决,咋解决呢?

写操作加排他锁,读操作不加锁!

还是上述的例子,当写操作加上排他锁后,T1在修改数据时,当T2再次尝试修改相同的数据,也要获取排他锁,因此T1、T2两个事务的写操作会相互排斥,T2就需要阻塞等待。但因为读操作不会加锁,因此当T2尝试读取这条数据时,自然可以读到数据。

来分析一下,因为写-写会排斥,但写-读不会排斥,因此也满足了RU级别的要求,即可以读到未提交的数据,但是不允许出现脏写问题。

最终经过这一系列的讲解后,能够得知MySQL-RU级别的实现原理,即写操作加排他锁,读操作不加锁!

3.2、RC(Read Committed)读已提交级别的实现

理解了RU级别的实现后,再来看看RCRC级别要求解决脏读问题,也就是一个事务中,不允许读另一个事务还未提交的数据,咋实现呢?

写操作加排他锁,读操作加共享锁!

这样一想,似乎好像没问题,还是以之前的例子来说,因为T1在修改数据,所以会对ID=1的数据加上排他锁,此时T2想要获取共享锁读数据时,T1的排他锁就会排斥T2,因此T2需要等到T1事务结束后才能读数据。

因为T2需要等待T1结束后才能读,既然T1都结束了,那也就代表着T1事务要么回滚了,T2读上一个事务提交的数据;要么T1提交了,T2T1提交的数据,总之T2读到的数据绝对是提交过的数据。

这种方式的确能解决脏读问题,但似乎也会将所有并发事务串行化,会导致MySQL整体性能下降,因此MySQL引入了一种技术,也就是上篇聊到的《MVCC机制》,在每次select查询数据时,都会生成一个ReadView快照,然后依据这个快照去选择一个可读的数据版本。

因此对于RC级别的底层实现,对于写操作会加排他锁,而读操作会使用MVCC机制。

但由于每次select时都会生成ReadView快照,此时就会出现下述问题:

-- ①T1事务中先读取一次 ID=1 的数据
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事务中修改 ID=1 的姓名为 竹子 并提交
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
commit;

-- ③T1事务中再读取一次 ID=1 的数据
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 |
+---------+-----------+----------+----------+---------------------+

此时观察这个案例,明明是在一个事务中查询同一条数据,结果两次查询的结果并不一致,这也是所谓的不可重复读的问题。

3.3、RR(Repeatable Read)可重复读级别的实现

RC级别中,虽然解决了脏读问题,但依旧存在不可重复读问题,而RR级别中,就是要确保一个事务中的多次读取结果一致,即解决不可重复读问题,咋解决呢?两种方案:

  • ①查询时,对目标数据加上临键锁,即读操作执行时,不允许其他事务改动数据。
  • MVCC机制的优化版:一个事务中只生成一次ReadView快照。

相较于第一种方案,第二种方案显然性能会更好,因为第一种方案不允许读-写、写-读事务共存,而第二种方案则支持读写事务并行执行,咋做到的呢?其实也比较简单:

写操作加排他锁,对读操作依旧采用MVCC机制,但RR级别中,一个事务中只有首次select会生成ReadView快照。

-- ①T1事务中先读取一次 ID=1 的数据
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事务中修改 ID=1 的姓名为 竹子 并提交
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
commit;

-- ③T1事务中再读取一次 ID=1 的数据
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级别中,会对于T1事务的每次SELECT都生成快照,因此当T1第二次查询时,生成的快照中就能看到T2修改后提交的数据。但在RR级别中,只有首次SELECT会生成快照,当第二次SELECT操作出现时,依旧会基于第一次生成的快照查询,所以就能确保同一个事务中,每次看到的数据都是相同的。

也正是由于RR级别中,一个事务仅有首次select会生成快照,所以不仅仅解决了不可重复读问题,还解决了幻读问题,举个例子:

-- 先查询一次用户表,看看整张表的数据
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>=4 的用户信息
SELECT * FROM `zz_users` WHERE user_id >= 4;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       4 | 猫熊      || 8888     | 2022-09-27 17:22:59 |
|       9 | 黑竹      || 9999     | 2022-09-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+
-- ②T1事务中,再将所有 ID>=4 的用户密码重置为 1111
UPDATE `zz_users` SET password = "1111" WHERE user_id >= 4;

-- ③T2事务中,插入一条 ID=6 的用户数据
INSERT INTO `zz_users` VALUES(6,"棕熊","男","7777","2022-10-02 16:21:33");
-- ④提交事务T2
commit;

-- ⑤T1事务中,再次查询所有 ID>=4 的用户信息
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       4 | 猫熊      || 1111     | 2022-09-27 17:22:59 |
|       6 | 棕熊      || 7777     | 2022-10-02 16:21:33 |
|       9 | 黑竹      || 1111     | 2022-09-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+

此时会发现,明明T1中已经将所有ID>=4的用户密码重置为1111了,结果改完再次查询会发现,表中依旧存在一条ID>=4的数据:棕熊,而且密码未被重置,这似乎产生了幻觉一样。

如果是RC级别,因为每次select都会生成快照,因此会出现这个幻读问题,但RR级别中因为只有首次查询会生成ReadView快照,因此上述案例放在RR级别的MySQL中,T1看不到T2新增的数据,因此MySQL-RR级别也解决了幻读问题。

小争议:MVCC机制是否彻底解决了幻读问题呢?

先上定论,MVCC并没有彻底解决幻读问题,在一种奇葩的情况下依旧会出现问题,先来看例子:

-- 开启一个事务T1
begin;
-- 查询表中 ID>10 的数据
SELECT * FROM `zz_users` where user_id > 10;
Empty set (0.01 sec)

因为用户表中不存在ID>10的数据,所以T1查询时没有结果,再继续往下看。

-- 再开启一个事务T2
begin;
-- 向表中插入一条 ID=11 的数据
INSERT INTO `zz_users` VALUES(11,"墨竹","男","2222","2022-10-07 23:24:36");
-- 提交事务T2
commit;

此时T2事务插入一条ID=11的数据并提交,此时再回到T1事务中:

-- 在T1事务中,再次查询表中 ID>10 的数据
SELECT * FROM `zz_users` where user_id > 10;
Empty set (0.01 sec)

结果很明显,依旧未查询到ID>10的数据,因为这里是通过第一次生成的快照文件在读,所以读不到T2新增的“幻影数据”,似乎没问题对嘛?接着往下看:

-- 在T1事务中,对 ID=11 的数据进行修改
UPDATE `zz_users` SET `password` = "1111" where `user_id` = 11;

-- 在T1事务中,再次查询表中 ID>10 的数据
SELECT * FROM `zz_users` where user_id > 10;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|      11 | 墨竹      || 1111     | 2022-10-07 23:24:36 |
+---------+-----------+----------+----------+---------------------+

嗯?!??此时会发现,T1事务中又能查询到ID=11的这条幻影记录了,这是啥原因导致的呢?因为我们在T1中修改了ID=11的数据,在《MVCC机制原理剖析》中曾讲过MVCC通过快照检索数据的过程,这里T1根据原本的快照文件检索数据时,因为发现ID=11这条数据上的隐藏列trx_id是自己,因此就能看到这条幻影数据了。

实际上这个问题有点四不像,可以理解成幻读问题,也可以理解成是不可重复读问题,总之不管怎么说,就是MVCC机制存在些许问题!但这种情况线下一般不会发生,毕竟不同事务之间都是互不相知的,在一个事务中,不可能会去主动修改一条“不存在”的记录。

但如若你实在不放心,想要彻底杜绝任何风险的出现,那就直接将事务隔离级别调整到Serializable即可。

3.4、Serializable序列化级别的实现

前面已经将RU、RC、RR三个级别的实现原理弄懂了,最后再来看看最高的Serializable级别,在这个级别中,要求解决所有可能会因并发事务引发的问题,那怎么做呢?比较简单:

所有写操作加临键锁(具备互斥特性),所有读操作加共享锁。

由于所有写操作在执行时,都会获取临键锁,所以写-写、读-写、写-读这类并发场景都会互斥,而由于读操作加的是共享锁,因此在Serializable级别中,只有读-读场景可以并发执行。

四、事务与锁机制原理篇总结

   在本章中,实则更多的是对《MySQL事务篇》、《MySQL锁机制》、《MySQL-MVCC机制》的补充以及汇总,在本篇中补齐了MySQL死锁分析、锁实现原理、事务隔离机制原理等内容,也结合事务、锁、MVCC机制三者的知识点,彻底理清楚了MySQL不同隔离级别下的实现,最后做个简单的小总结:

  • RU级别:读操作不加锁,写操作加排他锁。
  • RC级别:读操作使用MVCC机制,每次SELECT生成快照,写操作加排他锁。
  • RR级别:读操作使用MVCC机制,首次SELECT生成快照,写操作加临键锁。
  • 序列化级别:读操作加共享锁,写操作加临键锁。
级别/场景 读-读 读-写/写-读 写-写
RU级别 并行执行 并行执行 串行执行
RC级别 并行执行 并行执行 串行执行
RR级别 并行执行 并行执行 串行执行
序列化级别 并行执行 串行执行 串行执行

到这里,MySQL事务机制、锁机制、MVCC机制、隔离机制就彻底剖析完毕啦~

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
转载请注明出处: https://daima100.com/11557.html

(0)
上一篇 2023-04-02
下一篇 2023-04-02

相关推荐

  • C#DirectoryInfo类用法详解「建议收藏」

    C#DirectoryInfo类用法详解「建议收藏」这篇文章主要介绍了C#DirectoryInfo类用法详解,本篇文章通过简要的案例,讲解了该项技术的了解与使用,以下就是详细内容,需要的朋友可以参考下

    2023-07-01
    159
  • 堆栈内存及代码执行步骤

    堆栈内存及代码执行步骤堆栈内存及代码执行步骤 JavaScript运行的环境: 浏览器 node.js webview WebApp(Hybride混合App) 浏览器能够运行JavaScript代码,是因为提供了代码的运

    2023-11-16
    134
  • 客官,这里有一份《MySQL 必知必会》读书笔记,请您笑纳!

    客官,这里有一份《MySQL 必知必会》读书笔记,请您笑纳!因为我最近的一些工作内容跟数据分析比较密切,所以需要对SQL使用得比较熟练,所以便阅读了《MySQL 必知必会》这本书,为了检验自己的阅读效果及帮助一些跟我一样需要学习MySQL相关的知识的朋友,所以每阅读一章,我就开始写一章的读书笔记,并在掘金的读书笔记版块发布沸点。一共三十…

    2023-04-02
    154
  • mysql 事务隔离级别解析和实战解析区别_innodb事务隔离级别

    mysql 事务隔离级别解析和实战解析区别_innodb事务隔离级别用户可以用SET TRANSACTION语句改变单个会话或者所有新进连接的隔离级别。语法如下: 当一个事务访问一个数据,并且进行了修改。另一个事务读到了被修改的数据,并且使用了这个数据。 在同一个事务内,多次读取同一个数据,此时事务还没有完成。另一个事务在前一个事务两次读取之间…

    2023-04-02
    173
  • 循环神经网络

    循环神经网络本文为笔者,不断加深对RNN的理解所作。不间断更新本文。 处理序列数据需要统计工具和新的深度神经网络架构(RNN) 训练数据的形式 我们可以如何去构建一个数据集呢? 其实这批数据仅仅是由 1,2,3,

    2023-11-17
    183
  • 排序算法总结(计算机排序算法有哪些)

    排序算法总结(计算机排序算法有哪些)

    2023-10-22
    121
  • 面试官看上你的表现_数据分析sql面试必会6题经典

    面试官看上你的表现_数据分析sql面试必会6题经典和其它数据库相比,MySQL有点与众不同,它的架构可以在多种不同场景中应用并发挥良好作用。主要体现在存储引擎的架构上,插件式的存储引擎架构将查询处理和其它的系统任务以及数据的存储提取相分离。这种架构可以根据业务的需求和实际需要选择合适的存储引擎。 连接层:最上层是一些客户端和连…

    2023-04-03
    141
  • HTML+CSS+JS实现 贪吃蛇游戏源码

    HTML+CSS+JS实现 贪吃蛇游戏源码meta name=”viewport” content=”width=device-width, initial-scale=1.var canvas = document.var ctx = canvas.self.self.self.self.ctx.ctx.fillRe…

    2023-11-14
    145

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注