事务特征(ACID)

事务是指是程序中一系列严密的逻辑操作,而且所有操作必须全部成功完成,否则在每个操作中所作的所有更改都会被撤消。事务满足四个特征:原子性、一致性、隔离性、持久性。
image.png
在InnoDB存储引擎中,ACID是这这样实现的:

  • 持久性(D)是通过 redo log (重做日志)来保证的;
  • 原子性(A)是通过 undo log(回滚日志) 来保证的;
  • 隔离性(I)是通过 MVCC(多版本并发控制) 或锁机制来保证的;
  • 一致性(C)则是通过持久性+原子性+隔离性来保证;

image.png

注意: 事务是由 MySQL 的引擎来实现的,常见的 InnoDB 引擎它是支持事务的。不过并不是所有的引擎都能支持事务,比如 MySQL 原生的 MyISAM 引擎就不支持事务。

原子性 - A

原子性即 Atomicity ,一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。

举例说明: 拿转账来说,假设用户A和用户B两者的钱加起来一共是20000,那么不管A和B之间如何转账,转几次账,事务结束后两个用户的钱相加起来应该还得是20000,这就是事务的一致性。

一致性 - C

一致性即 Consistency,事务的执行使数据库的数据从一个状态转换为另一个状态,但是对于整个数据的完整性保持稳定,数据库的完整性不会因为事务的执行而受到破坏。

举例说明: 比如表中有一个字段为姓名,它有唯一约束,也就是表中姓名不能重复,如果一个事务对姓名字段进行了修改,但是在事务提交后,表中的姓名变得非唯一性了,这就破坏了事务的一致性要求,这时数据库就要撤销该事务,返回初始化的状态。

隔离性 - I

隔离性即 Isolation ,数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。

举例说明: 如果A在转账1亿给B(T1),同时C又在转账3亿给A(T2),不管T1和T2谁先执行完毕,最终结果必须是A账户增加2亿,而不是3亿,B增加1亿,C减少3亿。

持久性 - D

持久性即 Durability,事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

原子性实现

原子性是指一个事务是一个不可分割的工作单位,其中的操作要么都做,要么都不做;如果事务中一个sql语句执行失败,则已执行的语句也必须回滚,数据库退回到事务前的状态。
image.png

undo log

undo log即回滚日志,MySQL的日志有很多种,如二进制日志、错误日志、查询日志、慢查询日志等,此外InnoDB存储引擎还提供了undo log用于保证事务的原子性。undo log是逻辑日志,只是将数据库逻辑地恢复到原来的样子,所有修改都被逻辑地取消了,可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。当执行事务回滚时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。而undo log也主要有两个作用:

  • 事务回滚,保证原子性
  • 多个行版本控制,实现MVVC技术的基础,保证隔离性

    实现原理

    image.png
    如上图所示,当事务A将balance字段由1000000修改为2000000后,如果后续事务执行期间,出现了sql执行错误或者手动回滚,那么只需要回退到原来的版本即可。

    持久性实现

    持久性是指事务一旦提交,它对数据库的改变就应该是永久性的,接下来的其他操作或故障不应该对其有任何影响。

    redo log

    redolog是InnoDB里用来记录事务提交的物理日志文件,记录的是数据页的物理修改,而不是某一行或某几行修改成怎样,主要用来恢复事务提交后的物理数据页。

    实现原理

    在每次进行写数据的时候都会发生IO,对于InnoDB来说每次修改一次数据都发生IO在性能上肯定是不被允许的,所以加入了事务日志缓存这个概念,即redolog_buffer(日志缓冲区),每次事务日志的写入并不会直接写入到文件中,而是会写入到缓冲区中,在一定事件的触发下,才会将缓冲区内的数据写入到日志文件中。也就是说,一个写操作在InnoDB引擎内部发生的事情其实是这样的:

    写操作 —>redoLog操作—>写入redolog buffer —>写入redolog file —>本地落盘

RedoLog重做日志主要是用来实现Mysql事务特性里的的持久性主要由俩部分组成:

  • redo Buffer 重做日志缓冲(内存)
  • redo File 重做日志文件(磁盘)

从上可以看出redoBuffer是存在内存里的这样也就意味着redoBuffer肯定是高性能的但是同时它也是易丢失的,在机器故障时会出现内存文件的丢失,而redoFile是存在磁盘里的说明在写磁盘的时候会产生IO代表着它在存储时性能较差但是数据则是持久化的。

innodb通过force log at commit机制实现事务的持久性,即在事务提交的时候,必须先将该事务的所有事务日志写入到磁盘上的redo log file和undo log file中进行持久化。 为了确保每次日志都能写入到事务日志文件中,在每次将log buffer中的日志写入日志文件的过程中都会调用一次操作系统的fsync操作(即fsync()系统调用)。调用fsync()的作用就是将buffer中的日志刷到磁盘上的log file中。
事务 - 图5

隔离性实现

脏读、不可重复读、幻读

MySQL 服务端是允许多个客户端连接的,这意味着 MySQL 会出现同时处理多个事务的情况。而并发处理多个事务就会出现脏读、不可重复读、幻读这三个问题,只有解决了这三个问题才算实现了隔离性。

脏读

如果一个事务读到了另一个未提交事务修改过的数据,就意味着发生了「脏读」现象,比如:
假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后再执行更新操作,如果此时事务 A 还没有提交事务,而此时正好事务 B 也从数据库中读取小林的余额数据,那么事务 B 读取到的余额数据是刚才事务 A 更新后的数据,即使没有提交事务。
image.png
因为事务 A 是还没提交事务的,也就是它随时可能发生回滚操作,如果在上面这种情况事务 A 发生了回滚,那么事务 B 刚才得到的数据就是过期的数据,这种现象就被称为脏读。

不可重复读

在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生了不可重复读现象,比如:
假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后继续执行代码逻辑处理,在这过程中如果事务 B 更新了这条数据并提交了事务,那么当事务 A 再次读取该数据时,就会发现前后两次读到的数据是不一致的情况,这种现象就被称为不可重复读。
image.png

幻读

在一个事务内多次查询某个符合查询条件的记录数量,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了幻读现象,比如:
假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库查询账户余额大于 100 万的记录,发现共有 5 条,然后事务 B 也按相同的搜索条件也是查询出了 5 条记录。
image.png
接下来,事务 A 插入了一条余额超过 100 万的账号,并提交了事务,此时数据库超过 100 万余额的账号个数就变为 6。然后事务 B 再次查询账户余额大于 100 万的记录,此时查询到的记录数量有 6 条,发现和前一次读到的记录数量不一样了,就感觉发生了幻觉一样,这种现象就被称为幻读。

注意: MySQL如何解决幻读问题:https://xiaolincoding.com/mysql/transaction/phantom.html

事务的隔离级别

隔离级别的定义

当多个事务并发执行时可能会遇到脏读、不可重复读、幻读的现象,这些现象会对事务的一致性产生不同程序的影响。

  • 脏读:读到其他事务未提交的数据;
  • 不可重复读:前后读取的数据不一致;
  • 幻读:前后读取的记录数量不一致。

这三个现象的严重性排序如下:
image.png
SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低,这四个隔离级别如下:

  • 读未提交(read uncommitted):指一个事务还没提交时,它做的变更就能被其他事务看到
  • 读已提交(read committed):指一个事务提交之后,它做的变更才能被其他事务看到
  • 可重复读(repeatable read):指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,这是 InnoDB 引擎的默认隔离级别
  • 串行化(serializable ):会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;

按隔离水平高低排序如下:
image.png
针对不同的隔离级别,并发事务时可能发生的现象也会不同:
image.png

  • 在「读未提交」隔离级别下,可能发生脏读、不可重复读和幻读现象;
  • 在「读提交」隔离级别下,可能发生不可重复读和幻读现象,但是不可能发生脏读现象;
  • 在「可重复读」隔离级别下,可能发生幻读现象,但是不可能脏读和不可重复读现象;
  • 在「串行化」隔离级别下,脏读、不可重复读和幻读现象都不可能会发生。

    注意: 要解决幻读现象不建议将隔离级别升级到串行化,因为这样会导致数据库在并发事务时性能很差。InnoDB 引擎的默认隔离级别虽然是可重复读,但是它通过next-key lock 锁(行锁和间隙锁的组合)来锁住记录之间的“间隙”和记录本身,防止其他事务在这个记录之间插入新的记录,这样就避免了幻读现象。

举例说明这四种隔离级别:
image.png
在不同隔离级别下,事务 A 执行过程中查询到的余额可能会不同:

  • 在「读未提交」隔离级别下,事务 B 修改余额后,虽然没有提交事务,但是此时的余额已经可以被事务 A 看见了,于是事务 A 中余额 V1 查询的值是 200 万,余额 V2、V3 自然也是 200 万了;
  • 在「读提交」隔离级别下,事务 B 修改余额后,因为没有提交事务,所以事务 A 中余额 V1 的值还是 100 万,等事务 B 提交完后,最新的余额数据才能被事务 A 看见,因此额 V2、V3 都是 200 万;
  • 在「可重复读」隔离级别下,事务 A 只能看见启动事务时的数据,所以余额 V1、余额 V2 的值都是 100 万,当事务 A 提交事务后,就能看见最新的余额数据了,所以余额 V3 的值是 200 万;
  • 在「串行化」隔离级别下,事务 B 在执行将余额 100 万修改为 200 万时,由于此前事务 A 执行了读操作,这样就发生了读写冲突,于是就会被锁住,直到事务 A 提交后,事务 B 才可以继续执行,所以从 A 的角度看,余额 V1、V2 的值是 100 万,余额 V3 的值是 200万。

    隔离级别的实现

  • 对于读未提交隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了

  • 对于串行化隔离级别的事务来说,通过加读写锁的方式来避免并行访问,后续锁相关知识介绍
  • 对于读已提交和可重复读隔离级别的事务来说,它们是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同。可以把 Read View 理解成某一时刻的数据快照,就像相机拍照那样,定格某一时刻的风景。读已提交隔离级别是在每个语句执行前都会重新生成一个 Read View;而可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View。

    注意: 执行开始事务命令,并不意味着启动了事务,在 MySQL 有两种开启事务的命令,分别是:

    • 第一种:begin/start transaction 命令;
    • 第二种:start transaction with consistent snapshot 命令;

    这两种开启事务的命令,事务的启动时机是不同的:

    • 执行了 begin/start transaction 命令后,并不代表事务启动了,只有在执行这个命令后,执行了增删查改操作的 SQL 语句,才是事务真正启动的时机;
    • 执行了 start transaction with consistent snapshot 命令,就会马上启动事务。

Read View

image.png
Read View就是数据库某一时刻的数据快照,这个数据快照是通过四个字段确定的:

  • m_ids :指的是在创建 Read View 时,当前数据库中活跃事务的事务 id 列表,是一个列表,“活跃事务”指的就是,启动了但还没提交的事务
  • min_trx_id :指的是在创建 Read View 时,当前数据库中活跃事务中事务 id 最小的事务,也就是 m_ids 的最小值
  • max_trx_id :这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1
  • creator_trx_id :指的是创建该 Read View 的事务的事务 id,即当前Read View是给哪一个事务用的

此外,InnoDB的行记录会有两个隐藏的字段:
image.png

  • trx_id:当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里
  • roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版本记录,于是就可以通过它找到修改前的记录

在创建 Read View 后,可以将记录中的 trx_id 划分这三种情况:
image.png
一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:

  • 如果记录的 trx_id 值小于 Read View 中的 min_trx_id 值,表示这个版本的记录是在创建 Read View 前已经提交的事务生成的,所以该版本的记录对当前事务可见。
  • 如果记录的 trx_id 值大于等于 Read View 中的 max_trx_id 值,表示这个版本的记录是在创建 Read View 后才启动的事务生成的,所以该版本的记录对当前事务不可见。
  • 如果记录的 trx_id 值在 Read View 的 min_trx_id 和 max_trx_id 之间,需要判断 trx_id 是否在 m_ids 列表中:
    • 如果记录的 trx_id 在 m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见。
    • 如果记录的 trx_id 不在 m_ids列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见。

这种通过版本链来控制并发事务访问同一个记录时的行为就是 MVCC(多版本并发控制)。

MVCC

MVCC是读已提交和可重复读这两种事务隔离级别实现的技术支持:

  • 可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View
  • 读已提交隔离级别是每次执行SQL前生成一个Read View,SQL执行过程都使用这个Read View

MVCC实现可重复读:
假设事务 A (事务 id 为51)启动后,紧接着事务 B (事务 id 为52)也启动了,那这两个事务创建的 Read View 如下:
image.png
事务 A 和 事务 B 的 Read View 具体内容如下:

  • 在事务 A 的 Read View 中,它的事务 id 是 51,由于它是第一个启动的事务,所以此时活跃事务的事务 id 列表就只有 51,活跃事务的事务 id 列表中最小的事务 id 是事务 A 本身,下一个事务 id 则是 52。
  • 在事务 B 的 Read View 中,它的事务 id 是 52,由于事务 A 是活跃的,所以此时活跃事务的事务 id 列表是 51 和 52,活跃的事务 id 中最小的事务 id 是事务 A,下一个事务 id 应该是 53。

接着,在可重复读隔离级别下,事务 A 和事务 B 按顺序执行了以下操作:

  • 事务 B 读取小林的账户余额记录,读到余额是 100 万;
  • 事务 A 将小林的账户余额记录修改成 200 万,并没有提交事务;
  • 事务 B 读取小林的账户余额记录,读到余额还是 100 万;
  • 事务 A 提交事务;
  • 事务 B 读取小林的账户余额记录,读到余额依然还是 100 万;

下面是为什么会出现这样的结果的解释:
事务 B 第一次读小林的账户余额记录,在找到记录后,它会先看这条记录的 trx_id,此时发现 trx_id 为 50,比事务 B 的 Read View 中的 min_trx_id 值(51)还小,这意味着修改这条记录的事务早就在事务 B 启动前提交过了,所以该版本的记录对事务 B 可见的,也就是事务 B 可以获取到这条记录。接着,事务 A 通过 update 语句将这条记录修改了(还未提交事务),将小林的余额改成 200 万,这时 MySQL 会记录相应的 undo log,并以链表的方式串联起来,形成版本链,如下图:
image.png
在上图的「记录的字段」看到,由于事务 A 修改了该记录,以前的记录就变成旧版本记录了,于是最新记录和旧版本记录通过链表的方式串起来,而且最新记录的 trx_id 是事务 A 的事务 id(trx_id = 51)。然后事务 B 第二次去读取该记录,发现这条记录的 trx_id 值为 51,在事务 B 的 Read View 的 min_trx_id 和 max_trx_id 之间,则需要判断 trx_id 值是否在 m_ids 范围内,判断的结果是在的,那么说明这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录。而是沿着 undo log 链条往下找旧版本的记录,直到找到 trx_id 「小于」事务 B 的 Read View 中的 min_trx_id 值的第一条记录,所以事务 B 能读取到的是 trx_id 为 50 的记录,也就是小林余额是 100 万的这条记录。最后,当事物 A 提交事务后,由于隔离级别时「可重复读」,所以事务 B 再次读区记录时,还是基于启动事务时创建的 Read View 来判断当前版本的记录是否可见。所以,即使事物 A 将小林余额修改为 200 万并提交了事务, 事务 B 第三次读取记录时,读到的记录都是小林余额是 100 万的这条记录。就是通过这样的方式实现了,「可重复读」隔离级别下在事务期间读到的记录都是事务启动前的记录。

InnoDB存储引擎使用的默认隔离级别就是可重复读,但是可重复读还存在一个幻读问题,但是InnoDB没有升级为串行化隔离级别,其解决方案参考: https://xiaolincoding.com/mysql/transaction/phantom.html

MVCC实现读已提交:
读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View。也意味着,事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。

假设事务 A (事务 id 为51)启动后,紧接着事务 B (事务 id 为52)也启动了,接着按顺序执行了以下操作:

  • 事务 B 读取数据(创建 Read View),小林的账户余额为 100 万;
  • 事务 A 修改数据(还没提交事务),将小林的账户余额从 100 万修改成了 200 万;
  • 事务 B 读取数据(创建 Read View),小林的账户余额为 100 万;
  • 事务 A 提交事务;
  • 事务 B 读取数据(创建 Read View),小林的账户余额为 200 万;

事务 B 每次读取数据时创建的 Read View。前两次 事务 B 读取数据时创建的 Read View 如下图:
image.png
事务 B 第二次读数据时,事务 B 在找到小林这条记录时,会看这条记录的 trx_id 是 51,在事务 B 的 Read View 的 min_trx_id 和 max_trx_id 之间,接下来需要判断 trx_id 值是否在 m_ids 范围内,判断的结果是在的,那么说明这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录。而是,沿着 undo log 链条往下找旧版本的记录,直到找到 trx_id 「小于」事务 B 的 Read View 中的 min_trx_id 值的第一条记录,所以事务 B 能读取到的是 trx_id 为 50 的记录,也就是小林余额是 100 万的这条记录。

在事务 A 提交后,由于隔离级别是「读已提交」,所以事务 B 在每次读数据的时候,会重新创建 Read View,此时事务 B 第三次读取数据时创建的 Read View 如下:
image.png
事务 B 在找到小林这条记录时,会发现这条记录的 trx_id 是 51,比事务 B 的 Read View 中的 min_trx_id 值(52)还小,这意味着修改这条记录的事务早就在创建 Read View 前提交过了,所以该版本的记录对事务 B 是可见的。

注意: 正是因为在读提交隔离级别下,事务每次读数据时都重新创建 Read View,那么在事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务

一致性实现

一致性就是数据库完整无误的从一个状态转变为另外一个状态。而如何完整无误的从一个状态转变为另一个状态,就是通过前面的通过回滚,以及恢复,和在并发环境下的隔离做到的。例如:
张三从银行卡转400到理财账户

start transaction; //开启事务 select balance from bank where name=“zhangsan”; // 生成 重做日志 balance=600 _update bank set balance = balance - 400; // 生成 重做日志 amount=400 _update finance set amount = amount + 400; commit //提交事务

  • 假如执行完 update bank set balance = balance - 400 之后发生异常了,银行卡的钱也不能平白无故的减少,而是回滚到最初状态。
  • 又或者事务提交之后,缓冲池还没同步到磁盘的时候宕机了,这也是不能接受的,应该在重启的时候恢复并持久化。
  • 假如有并发事务请求的时候也应该做好事务之间的可见性问题,避免造成脏读,不可重复读,幻读等。在涉及并发的情况下往往在性能和一致性之间做平衡,做一定的取舍,所以隔离性也是对一致性的一种破坏。

也就是说,数据库状态完整无误的转化到另一种状态,需要同时满足异常回滚、宕机恢复、并发正确这三个基础条件,也即对应前面的原子性、持久性、隔离性,所以一致性其实是原子性、持久性、隔离性三者共同实现的。