深入MySQL:InnoDB存储引擎-事务[#4]

626 阅读16分钟

「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战」。

前言

之前我们已经详细介绍了InnoDB存储引擎的基础架构原理,相信对其已经有了较深的了解,但是那些只是InnoDB的基础能力,我们在使用中往往更为关注可能就是它所支持的一些重要特性,那这里我们先来看看其中特性之一:事务。

首先事务这个特性并不是InnoDB引擎所特有的,还有其他存储引擎同样也支持,像NDB等;甚至你可以自己实现一个支持事务安全的存储引擎,但是在深入了解InnoDB中事务是如何实现之前,我们得先明白什么是事务?为什么需要事务?这是个很重要的问题,也非常符合之前提到的“场景->需求->解决”的方法论。

事务

有关什么是事务及其特性,我在之前的文章《Spring源码解析之事务篇[#6]》中已经详细介绍过了,这里我再重新简单赘述下。

事务指访问并可能更新数据库中各种数据项的一个程序执行单元(unit),它是恢复和并发控制的基本单位,通俗的说我们在业务逻辑处理代码中对数据库的同一组增删改查操作就是一个事务操作。

ACID特性

至于为什么需要事务,它的作用就是用来保证数据在操作时的一些特性,而这些特性归纳总结起来就是我们熟知的ACID的4个特性,分别是:

  • 原子性(Automicity):一个事务操作中不可分割的最小工作单位,要么都成功,要么都失败。
  • 一致性(Consistency):事务前后的数据必须保证完整一致性。
  • 隔离性(Isolation):各个事务之间的执行操作是相互隔离且互不影响的。
  • 持久性(Durability):一个事务一旦提交,对数据库中数据的改变是永久性的,不会被回滚。

虽然上面的特性你看完可能并没有太大感觉,甚至在其他文章中也已经习空见惯,但其实这几个特性的概念非常重要,它们恰恰代表着事务存在的本质。而我们要明白的是,这其中的三种特性:原子性、隔离性、持久性,本质上最终都是为了实现一致性。

这么一总结下来,是不是觉得事务很简单?事实也是这样,寻求答案的时候,尽量先将其简单化,所谓的复杂最开始都是由一个简单的点慢慢演化而来。那既然我们已经知道事务的本质及作用了,那接下来就来看看InnoDB中的事务是如何实现的,也就是这三个特性的实现原理。

实现原理

其实一些特性的实现原理在前文InnoDB的基础架构原理中也已经提到过了,这里再单独拎出来讲一下,同时与其进行互补和完善,相互对照起来相信对InnoDB的事务实现原理更较容易理解。

持久性

先来看看持久性的实现原理,我们首先要知道持久性的目的,通俗的说就是将数据尽可能有效的写进磁盘里面进行永久储存,那这里有两个重要的点,一个是写进磁盘,另一个是尽可能。“写进磁盘”相信比较容易理解,前文对InnoDB中磁盘写的原理介绍得也很详细,主要通过Change buffer缓冲区 + 后台线程异步刷盘的方式来实现;而“尽可能”就需要考虑到如何在各种异常情况下保证数据能写进去,也就是尽量不丢失数据或者崩溃后尽量能够恢复。

在InnoDB中为了这种“尽可能”,设计了两个部分,分别是:

  • redo log:重做日志,持久性的重要支持,它解决什么问题呢?回到写磁盘的实现原理,当Buffer pool中缓存的脏页数据还没有被后台线程异步写到磁盘上或写到一半时,数据库宕机,因为这时候数据还是缓存在内存中,那这种情况下数据岂不是会丢失,怎么能保证持久性呢?所以为了解决这个问题,InnoDB会把所有对页的修改操作先单独写入到redo log buffer,再通过三种不同的刷盘机制将其写到磁盘的redo log日志文件中进行记录,以便于数据库重启恢复时,可以从这个日志文件进行恢复操作,实现crash-safe能力。同时它这种先写日志再写磁盘的WAL(Write-Ahead Logging)预写日志机制,由于是顺序IO,并不会带来很大的IO开销。
  • double write:双写缓冲区机制,为什么需要它?再看写磁盘的过程,在后台线程定时异步将数据页写入到磁盘中时,会有一个隐患,就是操作系统的文件管理的数据页大小为4K,而InnoDB的磁盘存储的结构页大小为16K,这意味着每个InnoDB页的写满需要操作系统进行4次写入操作,如果刚写了4K就崩溃宕机了,那剩下的12K的数据就丢失了,产生部分写失效问题,这种情况下就无法保证“尽可能”的持久性了。所以通过双写缓冲区机制的实现来解决这个隐患,它的实现原理就是在写磁盘数据页之前,先写一个副本页,如果发生部分写失效问题,丢失的数据可以在InnoDB崩溃恢复期间从这个副本页来还原重做。 和redo log相似,它由两部分组成:一个是内存中的double write buffer,另一个就是系统表空间中的double write;数据脏页会先复制到double write buffer,然后再将其写入到系统表空间的物理磁盘上,而且同样都是顺序写入。

原子性

接着来看原子性,它的目的是保证在一个事物中的操作,要么都成功,要么都失败,通俗的说就是在一个事务执行过程中由于某些原因失败了,或者主动执行rollback语句,那么数据将回滚到事务执行之前的样子。

那我们思考下,如果要将数据恢复到事务之前,该如何实现?

通常我们应该能想到一种实现方式:记录操作过程和结果,将事务开始之后的一些操作行为,像updateinsertdelete记录下来,回滚时顺序还原。

没错,InnoDB中就是利用undo log来完成这个实现方式的。我们先回顾下undo log的设计结构,首先存放一个事务的最小单位是undo log slot,也就是撤销日志,它们的集合为撤销日志段,撤销日志段位于一个特殊段:回滚段(undo segment)中,而回滚段则存放在系统表空间、独立的undo表空间或临时表空间(用于临时表的事务回滚)内,如下图:image-20220223122147980

其中InnoDB最多支持128个回滚段,其中32个分配给临时表空间,剩下的96个则分配给常规表中数据的事务,而每个回滚段中默认支持1024个undo log slot,所以理论上InnoDB默认支持的并发事务数就等于128*1024;然而这并不是最大的并发事务数,其实每个回滚段中的slot数量取决于InnoDB页面的大小,官网中已经说明:

InnoDB 页面大小回滚段中的撤消槽数(InnoDB 页面大小 / 16)
4096 (4KB)256
8192 (8KB)512
16384 (16KB)1024
32768 (32KB)2048
65536 (64KB)4096

当事务中执行updateinsertdelete语句去修改数据页时,则会产生相应的undo log,后续随着后台线程异步刷到磁盘中去,不同于的redo log的物理日志,undo log属于逻辑日志,也就是操作的记录和回滚并非直接落盘到数据库物理磁盘上,而是可能在内存中进行逻辑修改。

了解完物理设计结构,我们再来看看undo log的逻辑设计格式,我们知道单纯的查询并不会带来原子性问题,因为不涉及数据页的改变,所以undo log分为两种格式:

  • insert undo log:指在执行insert语句时产生的 undo log,格式如下:image-20220223143210789

    因为对于insert操作的数据,仅对当前的事务可见,并不会影响到其他事务的操作,所以在事务提交后会将其产生的undo log直接删除。

  • update undo log:指在执行updatedelete语句时产生的 undo log,格式如下:image-20220223143315446

    上图中type_cmpl的类型值有下面三种:

    1. TRX_UNDO_UPD_EXIST_REC:修改一个未被标记删除的记录。
    2. TRX_UNDO_UPD_DEL_REC:修改一个已经被标记删除的记录列值,因为删除的记录如果未被purge线程处理前,又插入相同键值的记录,则可能重用该记录。
    3. TRX_UNDO_DEL_MARK_REC:标记删除操作,不修改任何列值。

    值得注意的时,delete操作并不是直接删除数据记录,而是修改记录的删除标记,之后由purge线程进行处理并清理,所以本质上还是属于update操作。

当然InnoDB中关于Undo log 模块的设计远不止如此,这里浅谈即止,不再进行深入。

隔离性

读一致性问题

我们先来思考一下,如果有多个事务并发操作数据库中的数据时,会发生什么情况?

结果应该都能推算出来,在事务A的增删改查操作未完成时,事务B对这个数据又进行了一番增删改查,导致事务A操作的数据在结束前后不一致了,就像下面的简单例子:

image-20220105094619872

很容易看到,事务A的数据被事务B的更新操作影响了,像这样就属于并发情况下产生的读一致性问题,上面的举例仅仅是其中的一种问题情况,而将这些可能导致的问题分类会有下面三种情况:

  • 脏读:在一个事务中执行insertupdatedelete操作,但是还未提交,另外一个事务读取了未提交的数据,一旦前面事务发生回滚,那么后面的事务就产生了脏读。
  • 不可重复读:在一个事务中发生的两次读操作中间,另一个事务对数据进行了updatedelete操作,导致两次读取数据结果不一致的问题。
  • 幻读:在一个事务执行selectinsertupdatedelete操作时,另外一个事务进行了insert操作,这时前面的事务会无法操作新增的数据,从而产生幻读问题。

显而易见,在这种并发情况下,不仅仅是MySQL,任何数据库都会产生上面的问题,如果要保证上面的情况不会发生,就必须让数据库对每组事务的操作进行相互隔离,也就是事务隔离特性的定义。不过不用担心的是,现实中像这种国际性的情况,一般都会专门的组织联合很多相应专家来讨论和定义一个标准,而通过数据库提供支持的事务隔离级别来解决读一致性的问题的标准在SQL92标准(ISO组织在1992 年 7 月颁布的)中已经说明过了,包括上面的三种并发问题,以及针对这些问题的解决定义的下面四种数据库事务隔离级别:

  • 未提交读(Read-Uncommitted):未提交的事务操作数据允许被其他事务读取到,它能导致脏读、不可重复读、幻读的问题。
  • 已提交读(Read-Committed):已提交的事务操作数据才允许被其他事务读取到,它能导致不可重复读、幻读的问题。
  • 可重复读(Repeatable-Read):它在已提交读的基础上解决了不可重复读的问题,但是还是会产生幻读的问题。
  • 串行化(Serializable):所有事务必须串行化一个一个执行,不能并发执行,避免了脏读、不可重复读、幻读的问题,但是严重影响执行效率。

同时上面的隔离级别从上往下级别越高,事务所支撑的并发度越低。但是标准毕竟只是一个规范,实际中不同的数据库或存储引擎的事务隔离实现还是有一定的差别,并非全部按照标准来进行支持。

InnoDB的隔离级别

那我们来看看在MySQL的InnoDB中是怎么支持标准中的隔离级别的,相信很多人都已经看过或者知道,上面标准定义的四种隔离级别,在InnoDB都已经全部实现支持了,但是有区别的是在InnoDB中的可重复读RR的隔离级别实现时,通过一种临键锁解决了幻读的问题,所以它也是默认的事务隔离级别,既保证了数据的读一致性,又保证了事务一定的并发性能。

但是介绍这么多,终究还是停在隔离特性的层面上,我们还是不知道在InnoDB中是怎么实现隔离性的,接下来我们就去探究一下隔离性的实现原理。

实现方案

在了解InnoDB中隔离性实现原理之前,我们先设想一下,业务开发中,如果出现这种并发访问资源的情况,我们会去怎么解决?

相信很多人第一个时间想的就是加独占锁来控制,当前线程持有锁的情况下,其他线程需要等待当前线程执行完之后并获取释放的锁再进行资源的操作,来实现各个线程的隔离。在实际开发中,也确实是通过这样的方法来解决上面的情况,虽然牺牲了并发性能,但是能够保证数据一致性,而且这也是一种常见且很有效的解决方法。

那InnoDB中是不是也使用了这种方式来实现事务的隔离呢?

没错,但是这只是其中之一,下面来介绍下InnoDB中隔离性的两种实现方案。

LBCC

全称为Lock Based Concurrency Control,基于锁的并发控制,也很容易理解,事务开始前通过加锁来解决事务并发的问题。

而InnoDB中根据锁的粒度来分类的话,可以分为两种:

  • 表锁:给某张表进行加锁,加锁时其他事务都不能操作这张表中的数据,显然这样数据的竞争冲突概率会非常大,所以并发性能更低。

    其中表粒度锁有下面几种:

    1. 意向锁(Intention Locks):一种锁的状态标志,提升加表锁的效率。
    2. AUTO-INC锁(AUTO-INC Locks):AUTO_INCREMENT自动递增,也是使用数据库主键自增时用到的锁。
  • 行锁:给表中的某行记录加锁,加锁时其他事务则不能操作加锁的这行记录,这种加锁的实现方式是通过唯一索引来进行加锁,默认超过50秒则超时释放锁资源。

    其中行粒度锁有下面几种:

    1. 读写锁(Shared and Exclusive Locks):包含读锁(共享锁)、写锁(独占锁)。
    2. 记录锁(Record Locks):对某个索引记录加锁,范围属于精准匹配。
    3. 间隙锁(Gap Locks):在不同索引记录之间的间隙上的锁,范围属于左开右开。
    4. 插入意向锁(Insert Intention Locks):INSERT插入之前的操作设置的间隙锁,插入同一索引间隙的多个事务未插入间隙内的相同位置,则它们无需相互等待。
    5. 临键锁(Next-Key Locks):索引记录上的记录锁和索引记录之前的间隙上的间隙锁的组合,范围属于左开右闭,值得关注的是它解决了InnoDB中隔离级别为可重复读中的幻读问题。

对于上述介绍中的锁,这里并没有详细介绍及探究,仅为简单总结,感兴趣可以移步至官方文档

LBCC的方案虽然很直接有效,像隔离级别中串行化(Serializable)就是直接使用这种方式,但是缺点也很明显,意味着无法支持并发的读写操作,但是通常大多数实际场景下的业务对于数据库都是读多写少,仅仅为了保证隔离性而牺牲并发性能显然是得不偿失的。

MVCC

全称为Multi Version Concurrency Control,多版本的并发控制,它其实就是对于每个事务的前后数据进行快照,相当于给每个事务中涉及到的数据备份一次,并标记版本号来进行区分,这样事务中的每次读取只能查询当前版本号之前的数据,同时版本号是自动递增的。

其实在InnoDB的每行记录中都隐藏了两个字段:

  • DB_TRX_ID:最后一个事务的事务ID,在执行insertupdate操作时创建。
  • DB_ROLL_PTR:回滚指针,也就是执行delete操作时记录的事务ID。

我们知道LBCC通过唯一索引加锁来实现,那MVCC方案如何实现呢?

到这里可能你已经能猜到了,其实就是通过我们上面说的undo log来实现的,而每个数据的备份就是一个undo log slot,还记得两种设计结构中的保存的TRX_ID事务ID吗,它就是版本号。

这种方案不仅仅在MySQL数据库中使用,在其他数据库,像Oracle、PostgreSQL等中都得以实现。但是仅仅使用MVCC并不能完全解决不同隔离级别下并发读一致性的问题,像可重复读(Repeatable-Read),还是会产生幻读的问题,这时候在InnoDB中的,就采用MVCC和LBCC(临键锁)两种方案的协同使用,在损失了一点点的并发性能情况下来解决了这个问题,这也是它是InnoDB中默认隔离级别的原因。

参考文档:

MySQL官方文档

MySQL技术内幕-InnoDB存储引擎书籍

数据库内核月报 - 2015 / 04

文章中如有不妥之处,请批评指正,非常感谢。


把一件事做到极致就是天分!