MySQL之死锁问题

引言

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

一、MySQL中的死锁现象

还记得咱们在《MySQL锁机制》这篇文章中,描述事务、连接、线程三者关系的那段话嘛?

image.png

所谓的并发事务,本质上就是MySQL内部多条工作线程并行执行的情况,也正由于MySQL是多线程应用,所以需要具备完善的锁机制来避免线程不安全问题的问题产生,但熟悉多线程编程的小伙伴应该都清楚一点,对于多线程与锁而言,存在一个100%会出现的偶发问题,即死锁问题

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

对于死锁的定义,这里就不展开叙述了,因为在之前《并发编程-死锁、活锁、锁饥饿》中曾详细描述过,如下:

image.png

一句话来概述死锁:死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源竞争而造成相互等待、相互僵持的现象,一般当程序中出现死锁问题后,若无外力介入,则不会解除“僵持”状态,它们之间会一直相互等待下去,直到天荒地老、海枯石烂~

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

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

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

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执行情况是什么样的呢?如下:

image.png

如上图所示,一步步的跟着标出的序号去看,最终会发现:当死锁问题出现时,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内部会生成一张这样的等待图:

image.png

也就是T1持有着「竹子」这条数据的锁,正在等待获取「熊猫」这条数据的锁,而T2事务持有「熊猫」这条数据的锁,正在等待获取「竹子」这条数据的锁,最终T1、T2两个事务之间就出现了等待闭环,因此当MySQL发现了这种等待闭环时,就会强制介入,回滚结束其中一个事务,强制打破该闭环,从而解除死锁问题。

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

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

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

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

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

image.png

此时当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的锁是基于啥实现的呢?想要搞清楚这点,得先弄明白锁的内存结构,先看图:

image.png

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三部分,如下:

image.png

  • 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,锁粒度、锁类型、锁状态是什么情况呢?如下:

image.png

从上图中可得知,目前这组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://juejin.cn/post/7143614079532269598
来源:稀土掘金

鲸之声为您拼命加载中...