详解MySQL数据库事务的四大特性ACID

前言

要想熟练使用 MySQL 数据库就一定离不开事务,那么什么是事务呢?

事务(Transaction):是并发控制的基本单位。所谓的事务,它是一个操作序列,这些操作要么都执行,要么都不执行,它是一个不可分割的工作单位。

数据库事务,可以简单的理解为就是一组原子性的SQL执行语句,如果这些操作都能够成功执行,那么就执行这组操作;如果其中任意一条语句不论因为何种原因操作失败,那么所有的语句都不会被执行。即要么都执行,要么都不执行。事务的作用就是保持数据的最终一致性

举个最经典的银行转账例子。父亲给还未毕业的儿子转1000元生活费,会有如下一系列操作:

  1. 查询父亲账户余额是否大于1000元
  2. 从父亲账户余额减去1000元
  3. 在儿子账户余额加上1000元

如果这些步骤不放在事务里去做,那么当第三步执行失败了,很不幸父亲的账户的1000块扣除成功了,但是没有出现在儿子的账户里。这种情况显然是不被允许的。只有将这三个步骤放在同一个事务中,其中任何一步失败,都会回滚(ROLLBACK)已经执行完成的操作。也就是说,如果第三步失败了,会去回滚第二步,父亲“消失”的钱就又回来了。

那么是什么让事务变的如此“神奇”和可靠呢?

就是大家耳熟能详的 ACID 四大特性了:原子性(Atomicity),一致性(Consistency),隔离性(Isolation),持久性(Durability)。

 一、概念

1. 原子性 - Atomicity

原子性是指整个数据库事务是不可分割的工作单位。只有事务中所有的数据库操作都执行成功了,才算这个事务成功。如果事务中任何一个语句执行失败,那么就算已经执行成功的语句也会被撤销,数据库的状态应该退回到执行事务前的状态。

2. 一致性 - Consistency

一致性是指事务将数据库从一种状态转变为下一种一致的状态。在事务开始之前和事务结束之后,数据的完整性约束没有被破坏。

就拿上面举的例子来说,父亲账户减少1000元并且儿子账户增加1000元,钱有增有减且数值相同,这才是正确的状态,也就是一致性。如果父亲转出1000元,但是儿子并没有收到,交易前后相比总金额少了1000元,破坏了前后一致的状态,也就没有达成一致性。

3. 隔离性 - Isolation

隔离性是指一个事务产生的影响在该事务提交前对其他事务都不可见。这样保证了当多个用户并发操作数据库时,数据库开启的多个事务之间不会相互干扰。

4. 持久性 - Durability

事务一旦提交,其结果就是永久性的。即使发生宕机等故障,数据库也能将数据恢复。

还是父亲转账的例子,当父亲转完钱,看到了“操作成功!”的提示,那么数据就应该尘埃落定,即使此时数据库出现问题,也必须要保证父亲账户减少1000元并且儿子账户增加1000元这种状态,否则就会造成父亲看到操作成功的提示,但是数据库因为故障而没有完成转账操作的致命错误。

二、原理和实现

对于使用InnoDB存储引擎的MySQL数据库来说,逻辑备份日志(bin log)、重做日志(redo log)、回滚日志(undo log)、锁技术+MVCC 就是实现 ACID 的技术基础。

1. 原子性 - undo log

事务一般是以“BEGIN TRANSCACTION”开始,以“COMMIT”或者“ROLLBACK”结束。

  • COMMIT:提交事务中所有的操作并持久化到数据库。
  • ROLLBACK:将事务中所有已完成的更新操作全部撤销,回到事务刚开始时的状态。

MySQL中默认采用的是自动提交(autocommit)模式,可以通过下面命令查看。

show variables like 'autocommit';

在该模式下,如果没有用“BEGIN TRANSCACTION”显式地开始一个事务,那么每个 SQL 语句都会被当作一个独立的事务执行提交操作。

当然这个也是可以被修改的,通过下面方式可以关闭自动提交。

set autocommit = 0;

这个 autocommit 参数是连接(connection)级别的,也就是说每个连接中该参数的值都是独立的,修改当前连接的 autocommit 并不会影响到其它的连接。

在关闭了自动提交的情况下,所有的 SQL 语句都在同一个事务中,直到执行到“COMMIT”或者“ROLLBACK”,该事务才会结束,并且紧接着开启一个新的事务。

提交(Commit)说完了,下面来讲讲回滚(Rollback)。

数据库是如何进行回滚的呢?这里就需要用到 undolog 了。

每条更新数据库的操作(INSERT / UPDATE / DELETE)都会生成一条 undo log,并在修改数据之前写入 undo log buffer,随后将会被写到磁盘,具体何时落盘,下面会详细讲。当事务需要回滚时,InnoDB 就可以根据 undo log 对事务中已执行的操作进行回滚。回滚的方式其实就是执行相对应的逆向操作:

  • INSERT: 插入一条记录时,将这条记录的主键记录下来,回滚时根据这个主键删除该记录。
  • DELETE:删除一条记录时,将这条记录的内容记录下来,回滚时重新插入到表中。
  • UPDATE:修改一条记录时,将被更新的列的旧值记录下来,回滚时将这些值更新回去。

实际上这些操作都是存储在页(page)上的,其类型为 FIL_PAGE_UNDO_LOG,简称 undo log 页。Undo log 又分为两大类:

  • TRX_UNDO_INSERT:记录插入(INSERT)操作
  • TRX_UNDO_UPDATE:记录更新(UPDATE)和删除(DELETE)操作

这两类又分别存储在不同的 undo log 页,因为 insert 类型的 undo log 在事务提交过后就不会再被使用,会被直接释放;而 update 类型的 undo log 即使在事务提交过后也不能被释放,因为后续还会被 MVCC 使用。对于生命周期不相同的数据,当然分开存放会更适合一些。

想更深入了解 undo log 如何存储的小伙伴可以参考这篇文章:

InnoDB之Undo log写入和恢复

2. 持久性 - redolog+binlog

MySQL 数据库的数据是存放在磁盘上的,读写数据的时候都要通过磁盘的 IO,然而 IO 是非常消耗性能的操作。如果每次更新操作都直接落实到磁盘,那么可想而知其性能由于频繁的 IO 操作会比较差。为了提升性能,InnoDB 使用了缓冲池(Buffer Pool),不用每次操作都去存取磁盘,而是优先读写 buffer pool。

  • 读操作:先去从缓冲池中读,如果没有再去磁盘读取并写入缓冲池。
  • 写操作:直接写入缓冲池,缓冲池会在满足一定条件后把数据同步到磁盘。

如果 buffer pool 中的数据页被修改,且该修改还未同步到磁盘,我们称这种数据为“脏页”。

那么 buffer pool 中的脏页什么时候会同步到磁盘呢?

  1. 数据库正常关闭之前,会先将脏页同步到磁盘。

  2. buffer pool 中的数据达到一定阈值时,数据库会自动将部分脏页同步到磁盘,以避免 buffer pool 内存不足。

  3. 手动执行 FLUSH 命令可以强制将 buffer pool 中的脏页同步到磁盘。

buffer pool 带来了性能的提升,但是也带来了新的问题。当缓存池中的数据还没来得及写入磁盘时,出现了宕机的情况,此时这些数据就会丢失。为了解决这个问题,InnoDB 采用了 WAL(Write Ahead Logging)机制,即在数据修改后都会记录相应的日志优先与被修改的数据到磁盘。在 InnoDB 中,这种日志被叫做重做日志。

2.1 重做日志(redo log)

重做日志(redo log)是 InnoDB 存储引擎独有的,属于物理日志,记录了哪一页做了什么样的修改,每条 redo log 记录由“表空间号+数据页号+偏移量+修改数据长度+具体修改数据”组成,它让数据库拥有了崩溃恢复能力。比如数据库实例宕机了,重启时 InnoDB 存储引擎会根据 redo log 来恢复数据,以保证数据的完整性。

InnoDB 处理数据是以页(page)为单位的,当你在修改某条数据时,会先去 buffer pool 中查找该数据,如果不存在就去磁盘加载该数据所在的页,可能是一页或者多页;如果存在,就直接从 buffer pool 中读取。随后,会把修改记录写到重做日志缓存(redo log buffer)里,再同步到磁盘。

和 buffer pool 中的数据一样,undo log buffer 中的数据也需要保证其不丢失,所以在写 undo log 时也会去记录对应的 redo log。

redo log buffer 中的数据一般都会在事务提交前写到磁盘,这里针对写入时机提供了几中策略,下面会讲到。redo log 成功写到磁盘后,就确保了被修改数据的一致性。此时就算发生了宕机或者掉电等故障,导致 buffer pool 中还未写入磁盘的数据丢失,也不会真正的丢失数据,因为重启数据库的时候会去根据磁盘中 redo log 里的记录去恢复 buffer pool 中丢失的数据。

这里可能会有同会问:如果事务还未提交,redo log buffer 中的数据还未写到磁盘时宕机了,那数据不就丢了吗?这里你可能忘记了,事务持久性是针对已提交的事务。

上图 Log Buffer 中包含 undo log buffer 和 redo log buffer,作为 undo log 和 redo log 在写入磁盘前的缓冲区。细心的小伙伴就会发现,既然 redo log 也是先写 buffer 的,那么也应该存在理论上数据丢失的可能性。是的,这就涉及到 redo log buffer 的刷盘策略了。

2.1.1 刷盘策略

InnoDB 存储引擎中 redo log buffer 的刷盘策略可以通过 innodb_flush_log_at_trx_commit 参数来设置,可选值:0,1,2。

  • 0:不论事务提不提交,单独会有一个线程每隔一秒会把 redo log buffer 里的数据写到操作系统缓冲区(page cache),然后调用 fsync() 将缓冲区里的数据写到磁盘。
    • 也就是说在数据落盘前一秒内的数据会保存在 redo log buffer,也就是内存上,如果此时发生宕机,可能会丢失这一秒钟的数据。
  • 1:每次事务提交时或者每隔一秒,都把 redo log buffer 里的数据写到 page cache,然后调用 fsync() 将缓冲区里的数据同步到磁盘。
    • 这种策略下,在高并发场景下会进行大量 IO 操作,如果底层硬件提供的 IOPS 性能比较差,那么数据库的并发很快就会由于硬件 IO 的限制而达到瓶颈。好处就是最大程度降低了数据丢失的可能性,保证了数据安全性。
  • 2:每次事务提交时或者每隔一秒,都把 redo log buffer 里的数据写到 page cache,但是并不会立即调用 fsync() 写到磁盘,让系统自己调度何时写到磁盘。
    • 此时,如果只是数据库进程挂了,由于操作系统没有问题,数据仍然在 page cache 等待落盘,那么就不会有数据丢失。所以只有当操作系统损坏或者掉电的情况下,操作系统缓冲区里的还未落盘的数据才会丢失,导致相应的事务数据丢失。这种策略在不增加IO压力的情况下,一定程度上降低了数据丢失的可能性。

默认情况下,innodb_flush_log_at_trx_commit = 1 ,也就是事务一旦被提交就进行刷盘操作,优先确保数据一致性,但是具体使用哪种策略还是需要根据自身业务来进行选择。

2.1.2 Checkpoint 机制

试想一下,如果 buffer pool 和磁盘中的 redo log file 足够大,是不是就不需要往磁盘刷脏页了。因为就算宕机,也可以根据 redo log 来进行恢复,不会丢失数据。确实可以,但是这是一个昂贵的想法。有2个必须的前提:

  1. 足够大的内存用来缓存数据库所有的数据
  2. 无限大的磁盘空间来应对不断增加的 redo log

很显然这并不现实,而且就算满足这两条,也会有个问题,当 redo log 很大很大时,发生宕机需要花费很长很长的时间来加载 redo log 去进行恢复,这是难以忍受的。

因此 checkpoint 机制就出现了,针对性地解决了这些问题:

  1. 缓冲池不够时,会根据LRU算法溢出最近最少使用的页,如果这些页是脏页,则强制执行 checkpoint 将脏页刷到磁盘。
  2. redo log 可用磁盘空间不足时(用于存储 redo log 的磁盘空间大小是固定的且能重复使用),会强制执行 checkpoint 将脏页刷到磁盘,此时这些脏页对应的 redo log 就不再被需要,可以被擦除或者覆盖。如果宕机,恢复后这些被使用的 redo log 也不再被需要。
  3. 缩短了每次恢复时间,宕机恢复时,不用加载所有 redo log,而是只用加载最近一次 checkpoint 之后的 redo log 用于恢复就可以了。
2.1.3 日志文件组

实际上,磁盘上存储的 redo log 日志文件不只一个,而是以一个日志文件组的形式出现的,每个的redo日志文件大小都是一样的,默认2个,最多100个。比如可以配置为一组4个文件,每个文件的大小是 1GB,则整个 redo log 日志文件组可以记录 4G 的内容。

它采用的是环形数组形式,从头开始写,写到末尾又回到头循环写。在这有2个重要的参数

  • write pos:当前写入的位置
  • checkpoint:当前要擦除的位置

每次刷盘 redo log 到日志文件组中时,redo log 会从 write pos 的位置开始写入文件,write pos 就会相应地后移。每次 checkpoint 操作后,一些 redo log 不再被需要,checkpoint 指向的位置也会相应地前移。

所以不难看出,write pos 和 checkpoint 中间的部分, 是可复用的区域,就是还可用来写入 redo log 的空间。当这个空间为0时,也就是 write pos 追上了 checkpoint,此时表示日志文件组已满,也就是上面提到的 redo log 可用磁盘空间不足,这时就会强制执行 checkpoint 操作,使得 checkpoint 推进一下,留出能够继续写 redo log 的空间。

2.2 二进制日志(bin log)

重做日志(redo log)是物理日志,记录的是“在某个数据页上做了什么修改”,属于 InnoDB 存储引擎所独有的。而二进制日志(bin log)是逻辑日志,记录的是语句的原始逻辑,类似于“给 ‘User=儿子’的这一行的 Balance 字段加上1000”,属于 MySQL Server 层。不管 MySQL 用的是什么存储引擎,只要发生了表数据更新,都会产生 bin log。

那么 bin log 是用来做什么的呢?

如果把数据库里的数据当成银行账户里的余额,那么 bin log 就相当于银行账户的流水。账户余额只是一个结果,至于这个结果怎么来的,那就必须看流水了。同样的再 MySQL 里我们就是通过 bin log 来归档、验证、恢复、同步数据。

2.2.1 记录格式

bin log 应该说是 MySQL 里最核心的日志,它记录了除了查询语句(select、show)之外的所有的 DDL 和 DML 语句,也就意味着我们基本上所有对数据库的操作变更都会记录到 bin log 里面。bin log 以事件形式记录,不仅记录了操作的语句,同时还记录了语句所执行的消耗的时间。bin log 有三种记录格式,分别是ROW、STATEMENT、MIXED。

  • ROW:基于变更的数据行进行记录,如果一个 update 语句修改一百行数据,那么这种模式下就会记录 100 行对应的记录日志。
  • STATEMENT:基于SQL语句级别的记录日志,相对于 ROW 模式,STATEMENT 模式下只会记录这个 update 的语句。所以此模式下会非常节省日志空间,也避免着大量的 IO 操作。
  • MIXED:混合模式,此模式是 ROW 模式和 STATEMENT 模式的混合体,一般的语句修改使用 statment 格式保存 bin log,如一些函数,statement 无法完成主从复制的操作,则采用 row 格式保存 bin log。

比如执行这样一条语句

update Bank set update_time = now() where id = 1;

如果用 STATEMENT 格式来记录,

同步数据时,会执行记录的 SQL 语句,但是会出现一个问题,这里的 now() 会获取当前系统的时间,而不是这条语句执行时的时间,这会导致与原数据库的数据不一致。如果存在这种情况,就必须指定格式为 ROW,此时记录的内容就不再是简单的 SQL 语句了,还包含操作的具体数据,具体内容如下。

ROW 格式记录的内容是不能直接看到其详细的信息的,需要通过 mysqlbinlog 工具来解析。可以看到 update_time = now() 变成了 update_time = 2023-07-12 15:03:34,记录的是当时具体的时间。其后的 @1 和 @2 分别指的是表中 id 字段和 update_time 字段的值。

使用 ROW 格式能够确保同步数据的一致性,通常情况下也都是使用这种格式,可以给数据库的恢复和同步带来足够的可靠性。但是这种格式有一个弊端,就是需要大量的空间来记录,比较影响占用空间,恢复和同步时也会消耗大量 IO 资源,影响执行效率。

所以就有了第三种格式 MIXED,这是一种折中的方式。MySQL 会判断这条 SQL 语句是否会引起数据不一致,如果会,就用 ROW,反之就用 STATEMENT 格式。这种有针对性的混合格式,在保证了数据一致性的前提下,尽可能的减少了记录所占用的空间和所需的 IO 操作。

2.2.1 写入机制

事务在执行过程中,会先把日志写到 bin log cache,事务提交的时候,再把 cache 中的 bin log 写入 page cache。此时,可以通过设置 sync_binlog 参数来决定什么时候调用 fsync() 来进行刷盘(就是将 page cache 中的数据写入磁盘)。

  • sync_binlog = 0:每次提交事务时,都只会写 bin log 到 page cache,不会立即调用 fsync(),而是交由系统来调度何时执行 fsync()。
  • sync_binlog = 1:每次提交事务时,把 bin log 写入 page cache 后,都会立即执行 fsync()。
  • sync_binlog = N (N>1):每次提交事务时,把 bin log 写入 page cache 后,不会立即执行 fsync(),而是在积累了 N 个事务后才执行 fsync()。

数据只要没真正写到磁盘,就会存在由于机器宕机出现丢失的风险。所以为了安全起见,通常是会设置 sync_binlog = 1,每次提交事务就会写到磁盘。当然这种方式就会比较频繁的调用 fsync(),过多占用系统资源。设置成 N 就是一种比较折中的方法,如果宕机,只会丢失最近 N 个事务的 bin log。具体如何设置还需根据自身项目进行评估甄选。

和 undo/redo log 直接写到 log buffer 不一样,bin log cache 并不是在 log buffer 中。由于一个事务的 bin log 不能被拆开,所以无论这个事务多大,也要确保一次性写入,所以系统会给每个线程单独分配一块内存作为 bin log cache。我们可以通过 binlog_cache_size 参数来控制单个线程 bin log cache 的大小,如果存储内容超过了这个值,就会把溢出的数据暂存到磁盘(Swap)。

2.3 两阶段提交

MySQL 在修改数据时,会先去 buffer pool 中去找相应的数据,如果找不到,则会去磁盘上加载,然后再在 buffer pool 中进行修改。与此同时,会记录 redo log 到 log buffer 中的 redo log cache,然后再写入 bin log 到线程独立开辟的内存块(bin log cache)。

如果有执行这样的 SQL 语句,

update Bank set Balance = Balance + 1000 where User = son;

下面来看看执行该语句的流程。

这里的两阶段提交是参考了分布式系统中的 2PC(Two-Phase Commit) 协议,其实就是给 redo log 赋予两种状态:Prepare 和 Commit。

为什么要用两阶段提交呢?

大家可以思考这样一个问题:“如果当 redo log 写入磁盘成功了,而 bin log 写入磁盘失败了,会出现什么情况呢?”。也就是说 redo log 写完并且写到了磁盘后,发生了 crash,导致 bin log 还没有来得及写入磁盘,然后进行 crash recovery,主库会用 redo log 进行恢复,而由于没有该事务的 bin log 导致从库无法同步这次操作,会出现主库“新”于从库,从而造成主从不一致。

两阶段提交就可以解决这样的问题,当 crash recovery 时:

  • 如果 redo log 处于 Prepare 状态,则拿着该 redo log 的 XID (XA Transaction ID) 去 bin log files 中查找有没有相对应的完整的 bin log,
    • 如果有,则继续提交事务
    • 如果没有,则根据 undo log 回滚事务
  • 如果 redo log 处于 Commit 状态,则说明事务已经正常提交

2.4 思考

大家不妨在脑海里思考一下以下这些问题,看看有没有搞懂 redo log 和 bin log。

  1. redo log 和 bin log 是否只需要其中一个就行了?
  2. 为什么要采用两阶段提交?

3. 隔离性 - 读写锁+MVCC

虽然事务能够保证数据的最终一致性,但是在并发的场景下会引起脏读、不可重复读、幻读等问题。

脏读

如果一个事务读到了另一个未提交事务中被修改过的数据,那么称之为脏读。

还是转账的例子

时间事务A:转入事务B:取款
1开始事务
2开始事务
3查询账户,余额200
4取款100,余额100
5查询账户,余额100
6失败,回滚事务,余额200
7转入1000,余额1100
8提交事务

上述过程中,第五行就是脏读,事务A读取到了事务B中修改过但是未提交的数据。原本账户余额应该是 200 + 1000 = 1200,但是由于脏读导致实际余额变成 1100,这显然是不被允许的。

不可重复读

如果同一个事务中,前后多次读取数据,得到的结果内容不一致,那么称之为不可重复读。

时间事务A:查余额事务B:取款
1开始事务
2开始事务
3查询,余额1200
4查询,余额1200
5取款200,余额1000
6提交事务
7查询,余额1000        
8提交事务

可以看出,在事务A中,前后两次查询中间没有任何操作,但所得余额不一致,原因是在此期间事务B进行了取款操作并成功提交,这就是不可重复读。对于事务A而言,未作任何改动,余额却变少了,显得有点不合逻辑。

幻读

如果同一事务中,前后多次读取数据,得到的结果条数不一致,那么称之为幻读。

假设账户表中,有10个账户的余额超过了1000。

时间事务A:查询事务B:新增
1开始事务
2开始事务
3查询余额大于1000的数据,返回10条结果
4新增一个账户,存入2000
5提交事务
6查询余额大于1000的数据,返回11条结果
7提交事务

在事务A前后两次查询中间,事务B新插入一行数据且余额为2000,导致第二次查询时返回结果条数比第一次查询多1,导致了不一致,这就是幻读。

幻读与不可重复读类似,区别就是幻读主要是返回结果行数不一致,而不可重复读主要是返回结果内容不一致。

这些问题的出现,严重影响了并发事务的一致性,如何解决呢?没错就是下面要提到的四大隔离级别。

3.1 四大隔离级别

隔离性其实就是为了解决上述问题而诞生的,一共有四种隔离级别:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)、串行化(Serializable)。

3.1.1 读未提交

在读未提交隔离级别下,所有的事务都能够读取到其它未提交事务的改动。

所以在该隔离级别下,读取数据时不会给数据加S锁(共享锁),在此期间其它事务就能对该数据进行修改(会加X锁,也就是排他锁),所以可能产生脏读不可重复读幻读的问题。

MySQL InnoDB 引擎默认情况下,修改数据的语句(update、delete、insert)都会给被涉及的数据加上排他锁,如果是根据主键/唯一索引去加锁,则只会锁具体的行,否则可能锁全表。

3.1.2 读已提交

在读已提交隔离级别下,所有的事务都只能读取到其它已提交事务的改动。

因为只能读到已提交的事务,读不到未提交事务的修改,所以该隔离级别能够防止脏读,但是还是可能出现不可重复读幻读

在 MySQL InnoDB 引擎中,读已提交级别是采用 MVCC 的方式来实现,一个事务读取数据时总是读这个数据最近一次被commit的版本。相比使用锁的实现,效率更高。

3.1.3 可重复读

在可重复读隔离级别下,一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的。具体来说,一个事务只能读到另一个已经提交的事务修改过的数据,但是第一次读过某条记录后,即使其他事务修改了该记录的值并且提交,该事务之后再读该条记录时,读到的仍是第一次读到的值,该值可能不是最新值。

这种隔离级下,解决了脏读不可重复读的问题,但是由于只限制了内容不可修改,并没有限制插入操作,所以还是会出现幻读的问题。

可重复度是 MySQL InnoDB 引擎中的默认隔离级别,也是采用 MVCC 的方式来实现,和读已提交不通的是,一个事务读取数据时总是读取该数据在当前事务开始之前最后一次被commit的版本。所以在 InnoDB 引擎中,能很大程度避免幻读的问题(并不是完全解决)。

3.1.4 串行化

在串行化隔离级别下,所有的事务严格按照顺序依次执行,也就没有了并发带来的冲突。也是事务最高的隔离级别。

所以该隔离级别很自然的能够避免脏读不可重复读幻读的问题。看上去美好,但是串行化最大程度的限制了事务的并行,使得在并发事务下,性能很差。

3.1.5 总结

在 SQL 标准中,不通隔离级别可能发生的并发问题入下表

【×】表示不可能发生,【√】表示可能发生

隔离级别脏读不可重复读幻读
读未提交
读已提交×
可重复读××
串行化×××

需要注意的是,在 MySQL InnoDB 下, 由于 MVCC 的原因,可重复读级别是可以很大程度避免幻读的。

3.2 MVCC

数据库通过加锁,可以实现数据的隔离性。串行化隔离级别就是通过加锁实现的,也正是由于锁的存在,导致其性能很差。因此,很多主流数据库(包括 MySQL)都引入了 MVCC,在不加锁的情况下能解决并发读写冲突,提高并发性能。

3.2.1 什么是MVCC

MVCC (Multiversion Concurrency Control) 多版本并发控制,是一种并发控制的方法,常用于数据库中处理并发带来的读写冲突,目的在于提高并发场景下的数据库的吞吐性能。在 MySQL 中被用于实现读已提交可重复读隔离级别。

数据库并发场景一般有三种:

  • 读-读:不存在并发冲突
  • 读-写:有线程安全问题,存在并发冲突,可能出现脏读、不可重复度和幻读
  • 写-写:有线程安全问题,存在并发冲突,可能出现更新丢失

MVCC 主要是用来解决“读-写”冲突,可以保证以下两点:

  • 读操作和写操作互不干扰,不会阻塞对方,保证了数据库的并发吞吐量。
  • 解决脏读、不可重复读和幻读,保证了“读-写”冲突下的隔离性

但是对于“写-写”冲突,也可以叫作脏写,MVCC 就无能为力,此时 InnoDB 会配合使用锁来解决该问题。一般有两种组合:

  • MVCC + 悲观锁
  • MVCC + 乐观锁

其中 MVCC 用来解决“读-写”冲突,悲观锁和乐观锁用于解决“写-写”冲突。

3.2.2 MVCC 原理

MVCC 是由 Undo Log 版本链ReadView 机制协同实现的,也可以叫作快照读

Undo Log 版本链

对于 InnoBD 引擎,表中每一行记录出了用户自己定义的字段外,还存在一些隐藏字段。

字段是否必须描述
db_row_id隐藏主键,如果没有指定主键,则会以该字段创建聚簇索引
db_trx_id上一次对该记录做过修改的事务ID
db_roll_ptr回滚指针,指向该记录的上一个版本,也就是 undo log 中最新的快照

每条记录被修改前都会存储一份快照到 undo log 中,这几个隐藏字段也在其中。所以,每个快照都会有一个 db_trx_id 来标记本条记录对应的事务 ID,以及一个 db_roll_ptr 来存储上一个快照的地址。这样一来,每条记录都同时存在一系列的版本,按时间顺序以链表的方式连接,这个就叫做快照链或者版本链,除去最新版本的其它部分也就是 undo log。

 有了 undo log 版本链,就有了读取每条记录不同版本的途径,接下来就是通过 ReadView 机制来选择该读取哪个版本的数据。

ReadView 机制

ReadView 就是事务在某一时刻的读视图,该视图只能看见自身创建之前已经提交的事务所做的变动,也就是说在 ReadView 生成之时,就已经确定了该视图下哪些版本的数据可以被读取到。它会记录如下信息:

  • trx_ids:系统当前未提交事务ID的列表
  • low_limit_id:未提交事务中最大的事务ID
  • up_limit_id:未提交事务中最小的事务ID
  • creator_trx_id:创建该视图的事务ID

每开启一个事务,我们都会从数据库中获得一个事务 ID,这个事务 ID 是自增长的,通过 ID 大小,我们就可以判断事务的时间顺序。

有了这些信息,我们就能够判断某条记录对该视图是否可见。

举个例子,假如在一个事务中生成了 ReadView,该事务ID为 6,假设 ReadView 记录的信息如下

字段
trx_ids2,3,5
low_limit_id5
up_limit_id2
creator_trx_id6

要判断一条记录是否可见,只需将该记录的 db_trx_id 与 ReadView 中记录的未提交事务ID进行比较,假设该记录的 db_trx_id = n

  • 若 n < up_limit_id,则说明在 ReadView 中所有未提交的事务创建之前,db_trx_id = n 的这个事务就已经提交了,所以这条记录对当前视图就是可见的。
  • 若 n > low_limit_id,则说明 db_trx_id = n 的这个事务是在 ReadView 中所有未提交的事务创建之后才提交的。也就是说,在当前视图开启之后,有别的事务修改了数据并作了提交。所以,这个记录对于当前视图来说就是不可见的。
  • 若 up_limit_id > n > low_limit_id,此时会去 trx_ids 列表中进行比较
    • 如果 n 在 trx_ids 列表中,则说明在当前视图开启时,db_trx_id = n 的事务处于未提交状态,在后面才进行提交,所以该记录是在视图创建之后才被 db_trx_id = n 的事务提交,对当前视图来说也就不可见
    • 如果 n 不在 trx_ids 列表中,则说明在在当前视图开启之时,db_trx_id = n 的事务就已经提交,所以对当前视图是可见

对于可见的记录,之间返回就好了,但是对于不可见的记录又该如何呢?

此时 undo log 版本链就有用武之地了。如果该记录在当前视图下不可见,那就就会找到该记录中回滚指针 db_roll_ptr 指向的上一版本的记录,并再次进行上述 ReadView 判断,如果可见就返回,否则继续去找上一版本比较。如果找不到可见版本,则返回空。

在 MySQL InnoDB 中,虽然读已提交可重复读隔离级都使用了快照读,不同的是

  • 在读已提交级别下,事务中每个 SELECT 语句执行时都生成 ReadView
  • 在可重复读级别下,事务开始后执行第一个 SELECT 语句时才会生成 ReadView

所以在读已提交下,事务中的可见数据时可变化的,而可重复读下,事务中的可见数据不会变直到事务提交,且在执行第一条 SELECT 时确定。这就是为什么读已提交级别能避免脏读,可重复读级别能避免脏读、可重复读和幻读(特定情况下不能避免)的原因。

在 MySQL InnoDB 中,读已提交可重复读隔离级下,SELECT 语句使用快照读,UPDATE/INSERT/DELETE 语句使用当前读。快照读是通过 MVCC 实现,当前读是通过加锁(X锁或者Next-key锁)实现。

4. 一致性

 一致性是事务追求的最终目标:前面提到的原子性、持久性和隔离性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障。实现一致性必须要满足以下条件

  • 事务层面,也就是上面讲的原子性,持久性和隔离性。
  • 数据库层面,例如不允许向整形列插入字符串类型值、插入值不能超过最大限制等等。
  • 应用层面,也就是应用程序自身逻辑的完备性,例如转账操作,只考虑了扣减金额,没有考虑另一边增加金额。

后话

兜兜转转,写写停停,已经不记得什么时候开始写的了,不过好在总算完成了。

可能是因为第一次写技术文章吧,虽然早早就定好主题,但是什么都想讲一讲,所以篇幅略长。

也算是边学边写,参考了许多大佬的文章,受益匪浅。

参考

  1. 数据库ACID四大特性到底为了啥,一文带你看通透__陈哈哈的博客-CSDN博客
  2. 听我讲完redo log、binlog原理,面试官老脸一红__陈哈哈的博客-CSDN博客
  3. Mysql之redo log与bin log详解_redolog和binlog_星夜孤帆的博客-CSDN博客
  4. https://www.cnblogs.com/kismetv/p/10331633.html
  5. 再有人问你什么是MVCC,就把这篇文章发给他!-这篇文章的意思
  6. MySQL 可重复读隔离级别,完全解决幻读了吗? | 小林coding
  7. MVCC 机制的原理及实现-CSDN博客
  8. MySQL事务隔离级别和MVCC - 掘金
  9. 数据库MVCC和隔离级别的关系是什么? - 知乎
  10. 9 张图总结一下 MySQL 架构-腾讯云开发者社区-腾讯云
  11. https://mp.weixin.qq.com/s/ZVsuqpaKTAMeA0SyqRbqyw
  12. 数据库事务隔离级别--读未提交,读已提交,重复读,序列化-CSDN博客
  13. MVCC到底是什么?这一篇博客就够啦_什么是mvcc-CSDN博客
  14. MySQL(八):读懂MVCC多版本并发控制
  15. 事务原子性、一致性、持久性的实现原理-阿里云开发者社区 (aliyun.com)

​​​​​

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值