MySQL:事务&MVCC详解

数据库事务(transaction)是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作逻辑单元。

事务的四大特性

  • 原子性(Atomicity):一个事务中的所有操作,要么都执行,要么都失败,不存在中间状态,一旦事务执行过程中发生了错误,事务会进行回滚,恢复到事务开始之前的状态,就像这个事务从来没有执行过一样。

  • 一致性(Consistency):数据库的完整性不会因为事务的执行而受到破坏,一个事务执行之前和执行之后都必须处于一致状态。比如表中有一个字段为姓名,它有唯一约束,也就是表中姓名不能重复,如果一个事务对姓名字段进行了修改,但是在事务提交后,表中的姓名变得非唯一性了,这就破坏了事务的一致性要求,这时数据库就要撤销该事务,返回初始化的状态。再比如用户A和用户B两者的钱加起来一共是1000,那么不管A和B之间如何转账、转几次账,事务结束后两个用户的钱相加起来应该还得是1000,这就是事务的一致性。

  • 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以保证多个事务在并发执行的过程中互相隔离,互不干扰,从而避免并发事务交叉执行时带来的数据不一致问题。

  • 持久性(Durability):事务结束后,对数据的修改是永久的,即便发生系统故障也不会对丢失。

InnoDB引擎如何保证事务的四大特性?

持久性通过redo log(重做日志)来保证的

原子性通过undo log(回滚日志)来保证的

隔离性通过mvcc(多版本并发控制)或锁机制来保证的

一致性通过持久性+原子性+隔离性来保证的

并发事务带来的问题

脏读

一个事务读到了另一个「未提交事务修改的数据」,如果该数据发生回滚,则读到的数据就是错误的数据,就意味着发生了「脏读」现象。

因为事务 A 是还没提交事务的,也就是它随时可能发生回滚操作,如果在上面这种情况事务 A 发生了回滚,那么事务 B 刚才得到的数据就是过期的数据,这种现象就被称为脏读。

不可重复读

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

事务B第一读取余额为100万,第二次读取余额变为200万,两次读同一条数据结果却不一致。

幻读

在一个事务中多次查询符合特定条件的记录数量,如果前后两次查询到的记录数量不同,就意味着发生了「幻读」。

事务B第一次查询大于100万的记录有5条,第二次查询同样条件结果却变成了6条,出现了幻读。

MySQL针对「脏读,不可重复读,幻读」等现象,采取了什么措施应对?

答:隔离级别

事务的隔离级别

SQL标准规定了四种隔离级别:

  • 读未提交(Read Uncommitted),一个事务可以读取到其他未提交事务变更过的数据;
  • 读已提交(Read Committed),一个事务只能读取到其他已经提交多的事务变更的数据,Oracle数据库默认采用这种隔离级别;
  • 不可重复读(Repeatable Read),一个事务执行过程中读取到的数据始终和事务开始时读到的数据一致,MySQL数据库默认采用这种隔离级别;
  • 串行化(Serializable),会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;

按「隔离水平」从高到低排序:

针对不同隔离级别,并发事务时可能发生的现象:

解决脏读现象,就要升级到「读提交」以上的隔离级别;要解决不可重复读现象,就要升级到「可重复读」的隔离级别。

不过,要解决幻读现象不建议将隔离级别升级到「串行化」,因为这样会导致数据库在并发事务时性能很差。

MySQL的默认隔离级别是「不可重复读」,但是它采用Next-Key Lock锁(行锁和间隙锁的组合),来锁住记录本身以及记录的“间隙”,防止其他事务在记录之间插入新的记录,从而可以有效避免幻读。

四种隔离级别是如何实现的?

  • 对于「读未提交」,因为可以读到为其他未提交事务修改的数据,所以直接读取最新数据;
  • 对于「串行化」,通过加读写锁的方式来避免并行访问;
  • 对于「读已提交」和「不可重复读」,它们都是通过Read View来实现的,它们的区别在于创建Read View的时机不同,Read View可以理解为一个快照数据,类似于相机拍的照片,记录的是某一时刻的风景。「读已提交」是在每个读语句执行前都会重新创建一个新的Read View;而「不可重复读」则是在事务开启前创建一个Read View,然后整个事务执行期间都使用这个Read View

Read View是MVCC工作的核心,下面来详细讲讲MVCC(多版本并发控制)。

MVCC(Multi-Version Concurrency Control)

MVCC最大的好处就是:读不加锁,读写不冲突。相比基于锁的并发控制,在读多写少的场景下,MVCC极大地提升了系统的并发性能。

在MVCC并发控制中,读操作可以分为两类:快照读(Snapshot Read)和当前读(Current Read)。快照读,读取的是记录的可见版本(可能是历史版本),不用加锁;当前读,读取的是记录的最新版本,并且当前读返回的数据都会上锁,保证其他事务不会并发修改该记录。

在MySQL InnoDB存储引擎中,哪些读操作是快照读?哪些操作又是当前读呢?

  • 快照读:简单的select操作,属于快照读,不加锁。

    • 例如:select * from table where ?;
  • 当前读:特殊的读操作、插入/更新/删除操作,属于当前读,需要加锁。

    • select * from table where ? lock in share mode;
    • select * from table where ? for update;
    • insert into table values (…);
    • update table set ? where ?;
    • delete from table where ?;

    以上语句都属于当前读,读取到记录的最新版本,并且会在读取到数据后,对数据进行加锁,以保证其他并发事务不会对当前数据进行修改。其中除了第一条语句,对读记录加S锁(共享锁)外,其他操作都是加X锁(排它锁)。

为什么插入/更新/删除操作,都归为当前读?

当Update SQL发给MySQL后,MySQL server会根据where条件,读取到第一条满足条件的记录,然后InnoDB引擎会将第一条记录返回并加锁。待MysSQL server受到第一条记录后,会再发起一个Update请求,更新这条记录。一条记录操作完成后,会再读取下一条记录,直到没有满足条件的记录为止。因此Update操作内部就包含了一个当前读。同理Delete操作也一样。Insert操作会稍微不同,简单来讲,就是Insert操作可能会触发Unique Key的冲突检查,也会进行一下当前读。

快照读中的记录快照就对应着前面提到的Read View,下面介绍MVCC的工作原理。

Read View在MVCC中是如何工作的?

首先我们需要了解两个知识点:

  • Read View中的四个字段,及其作用
  • 聚簇索引记录中两个跟事务相关的隐藏列
  1. Read View的四个重要字段

各个字段的含义:

  • creator_trx_id:是指创建该Read View的事务id
  • m_ids:创建Read View时,当前数据库中「活跃事务」的事务id列表,“活跃事务”是指启动了但还未提交的事务
  • min_trx_id:创建Read View时,「活跃事务」中最小的事务id,即m_idx中的最小值
  • max_trx_id:创建Read View时,当前数据库中应该给下一个事务的id值,不是m_idx中的最大值,是全局事务中最大的事务id+1
  1. 聚簇索引记录中两个跟事务相关的隐藏列

  • trx_id:当某个事务对该条记录改动时,会把该事务的事务id记录在trx_id中
  • roll_pointer:每次对某一条记录进行修改时,都会把这条记录的旧版本保存到undo log中,这个隐藏列是一个指针,指向了上一个版本的记录,于是通过它就能找到历史版本

在创建Read View时我们可以将记录中的trx_id划分为三种情况:

一个事务去读取数据时,除当前事务的修改记录总是可见的之外,还有一下情况:

  • 如果记录的trx_id小于Read View中的min_trx_id,表示这个版本的记录是在创建Read View之前就已经提交了的事务生成的,因此该版本的记录对当前事务是可见的。
  • 如果记录的trx_id大于等于Read View中的max_trx_id,表示这个版本的记录是在创建Read View之后才启动的事务生成的,因此该版本的记录对当前事务是不可见的。
  • 如果记录的trx_id介于Read View中的min_trx_idmax_trx_id之间,则需要判断trx_id是否在m_ids列表中:
    • 如果记录的trx_idm_ids中,则表明这个版本的记录是在创建Read View时仍处于活跃状态(启动但未提交)的事务生成的,因此该版本的记录对当前事务时不可见的。
    • 如果记录的trx_id不在m_ids中,则表明生成该版本记录的活跃事务在创建Read View时已经提交,因此该版本的记录对当前事务是可见的。

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

可重复读是如何工作的?

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

分析一个场景,假设事务A(事务id为51)启动后,紧接着启动了事务B(事务id为52),那么两个事务创建的Read View及初始记录如下:

事务 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按顺序执行了以下操作:

  1. 事务B读取小林的账户余额,显示余额为100万
  2. 事务A更新小林的账户余额为200万,但还未提交
  3. 事务B再读取小林的账户余额,显示100万
  4. 事务A提交事务
  5. 事务B再次读取到小林的账户余额为100万

分析过程:

事务B第一次读取账户余额时,记录的trx_id为50,小于Read View中的min_trx_id=51,因此记录对于事务B是可见的,因此读取到余额为100万;

事务A更新余额后,两个事务的Read View及记录的版本信息为

事务B第二次读取账户余额时,最新版本记录的trx_id为51,处于事务B的Read View中min_trx_id=51max_trx_id=53之间;因此还需判断trx_id是否在事务B的Read View中m_ids=[51, 52]列表中,发现在m_ids列表中,因此最新版本的记录对于事务B是不可见的;此时会根据当前版本记录的roll_pointer沿着undo log链条找历史版本,直到发现trx_id小于事务B的Read View中min_trx_id的第一条记录,所以事务B能读到trx_id=50的记录,也就是余额为100万的记录。

最后在事务A提交后,由于在「可重复读」隔离级别下,Read View只会在创建事务时生成,因此事务A和事务B的Read View和创建事务是的一样。事务B第三次读取余额时还是基于之前的Read View来判断版本记录是否可见,因此此时读到的数据和第二次读取的结果是一样的,余额也是100万。

就是通过这样的方式实现了,「可重复读」隔离级别下在事务期间读到的记录都是事务启动前的记录。

读已提交是怎么工作的?

读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View。

同样分析上面那个场景,只不过将隔离级别从原来的「可重复读」改为「读已提交」。操作顺序也同上

  1. 事务B读取小林的账户余额,显示余额为100万
  2. 事务A更新小林的账户余额为200万,但还未提交
  3. 事务B再读取小林的账户余额,显示100万
  4. 事务A提交事务
  5. 事务B再次读取到小林的账户余额为200万

我们来分析事务B第二次读取记录时,读取不到事务A未提交的修改?

在「读已提交」隔离级别下,事务B在第二次读取数据时会新创建Read View

由于事务A还未提交,因此事务B读数据时事务A扔出于活跃状态,因此事务A的修改无法被事务B读取到。

我们再来分析事务B第三次读数据时,为什么可以读取到事务A(事务已提交)的修改?

事务B第三次执行读数据操作时创建的Read View如下:

事务B第三次读数据时,事务A已经提交,此时的Read View中的min_trx_id为52,即当前活跃的事务只有事务B,最新版记录由事务A创建,因此其trx_id为51,小于事务B的Read View中的min_trx_id,因此最新版本的记录对于事务B是可见的,因此第三次事务B读取到的余额为200万。

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