事务

事务的概念及性质

引入

事务性质(ACID)

事务看起来感觉简单,但是要实现事务必须要遵守4个特性,分别如下:

  • 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样,就好比买一件商品,购买成功时,则给商家付了钱,商品到手;购买失败时,则商品在商家手中,消费者的钱也没花出去。
  • 一致性(Consistency):是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。比如,用户 A和用户 B在银行分别有 800 元和 600 元,总共 1400 元,用户 A给用户 B转账 200 元分为两个步骤,从 A的账户扣除 200 元和对 B的账户增加 200 元。一致性就是要求上述步骤操作后最后的结果是用户 A 还有 600 元,用户 B有 800 元,总共 1400 元,而不会出现用户 A 扣除了 200元,但用户B未增加的情况(该情况,用户A和B均为 600 元,总共 1200 元)。

(感觉可以在相当程度上理解为能量守恒定律,反正我就这么理解的😅)

  • 隔离性(lsolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交又执行而导致数据的不一致,因为多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。也就是说,消费者购买商品这个事务,是不影响其他消费者购买的。
  • 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?

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

并发事务产生的问题

一共三个问题:脏读、不可重复读、幻读。从问题的严重性上区分的话,是从左到右依次减少。

  • 脏读(Dirty read)

  如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了脏读,示意图如下:

​ 由于事务A触发回滚,实际上数据库里还是只有100w,但是事务B读取到的金额为未触发回滚时的200w。

  • 不可重复读(Non-Repeatable Read)

  在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生了「不可重复读」现象。

​ 这里事务A前后两次数据查询不相同的问题就是不可重复读现象。

  • 幻读(Phantom Read)

    在一个事务内多次查询某个符合查询条件的「记录数量」如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。

​ 事务A前后两次查询时5条变为6条。这就是幻读。

事务的隔离级别

为了解决这些问题,SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低,这四个隔离级别如下:

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

针对不同隔离级别,可能发生的问题也会有区分。显然,串行化的隔离级别是最高的,随后从右到左依次降低。

MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了),解决的方案有两种:

  • 针对**快照读(普通 select 语句)**,是通过 MVCC方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
  • 针对**当前读(select .. for update 等语句)**,是通过 next-key lock(记录锁+间隙锁)方式解决了么读,因为当执行 select… for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。

接下来,举个具体的例子来说明这四种隔离级别,有一张账户余额表,里面有一条账户余额为 100 万的记录。然后有两个并发的事务,事务A只负责查询余额,事务B则会将我的余额改成 200 万,下面是按照时间顺序执行两个事务的行为:

在不同隔离级别下,事务 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选择了可重复读的隔离级别,那是如何解决幻读问题的呢?Read View到底是什么呢?可重复读是如何工作的呢?请看下一节:MVCC(Multi-Version Concurrency Control,多版本并发控制 )。

MVCC与Read View

Read View到底是个什么东西呢?

它由四个字段组成,分别是当前事务id,(创建时)最小事务id,(创建时)最大事务id+1,以及创建事务时其他启动了且还未提交的事务id。

介绍完Read View,还需要再介绍一下聚簇索引记录中的两个隐藏列。

假设在账户余额表插入一条小林余额为 100 万的记录,然后我把这两个隐藏列也画出来,该记录的整个示意图如下:

对于使用 InnoDB 存储引擎的数据库表,它的聚簇索引记录中都包含下面两个隐藏列:

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

这样下来,事务A对事务B可见性的判断就有了着落:

  • 如果记录的 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列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见。

应用1:可重复读场景

可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View。

举例:

事务A想要更改money为200w,事务B想要查询money字段。事务A先于事务B创建。

你可以在上图的「记录的字段」看到,由于事务 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 万的这条记录。

应用2:读提交场景

同样的题设,当事务 B在找到小林这条记录时,会发现这条记录的 trx_id 是 51,比事务 B的 Read View 中的 min_trx_id值(52)还小,这意味着修改这条记录的事务早就在创建 Read View 前提交过了,所以该版本的记录对事务 B 是可见的。

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

间隙锁

我们不妨再复习一下:

MySQL 里除了普通査询是快照读,其他都是当前读,比如 update、insert、delete,这些语句执行前都会查询最新版本的数据,然后再做进一步的操作。

这很好理解,假设你要 update 一个记录,另一个事务已经 delete 这条记录并且提交事务了,这样不是会产生冲突吗,所以 update 的时候肯定要知道最新的数据。

另外, select …for update这种查询语句也是当前读,每次执行的时候都是读取最新的数据。

Innodb 引擎为了解决「可重复读」隔离级别使用「当前读」而造成的幻读问题,就引出了间隙锁。

假设,表中有一个范围 id 为(3,5)间隙锁,那么其他事务就无法插入id=4这条记录了,这样就有效的防止幻读现象的发生。

再举个具体的例子:

事务A执行了这面这条锁定读语句后,就在对表中的记录加上 id 范围为 (2,+∞]的 next-key lock(next-key lock 是间隙锁+记录锁的组合)。

然后,事务 B在执行插入语句的时候,判断到插入的位置被事务A加了 next-key lock,于是事物 B会生成一个插入意向锁,同时进入等待状态,直到事务 A提交了事务。这就避免了由于事务 B插入新记录而导致事务 A 发生幻读的现象。

幻读问题:避无可避

然而即便如此,可重复读隔离+MVCC+间隙记录锁还是无法完全排除幻读的可能。最典型的场景是:

A快照读->B插入数据->A当前读

因此,要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select… for update 这类当前读的语句,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。

总结

  • 事务ACID特性
  • 事务并发问题
  • 隔离级别解决方案
  • MVCC机制
  • 幻读问题解决