事务有哪些特性?
事务隔离级别是怎么实现的?

事务有哪些特性?
事务是由 MySQL 的引擎来实现的,我们常⻅的 InnoDB 引擎它是支持事务的。
不过并不是所有的引擎都能支持事务,比如 MySQL 原生的 MyISAM 引擎就不支持事务,也正是
这样,所以大多数 MySQL 的引擎都是用 InnoDB 。
事务看起来感觉简单,但是要实现事务必须要遵守 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 元)。
隔离性(Isolation ):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可
以防止多个事 务并发执行时由于交 叉执行而导致数据的不一致,因为多个事 务同时使用相同的
数据时,不会相互干扰,每个事 务都有一个完整的数据空间,对其他并发事务是隔离的。也就
是说,消费者购买商品这个事 务,是不影响其他消费者购买的。
持久性(Durability ):事务处理结束后,对数据的修改就是永久的,即便系统故障也不 会丢
失。
InnoDB 引擎通过什么 技术来 保证事务的这四个特性的呢?
持久性是通过 redo log (重做日志)来保证的;
原子性是通过 undo log (回滚日志) 来保证的;
隔离性是通过 MVCC (多版本并发控制) 或锁机制来保证的;
一致性则是通过持久性+原子性+隔离性来保证;
这次将重点介绍事务的隔离性,这也是面试时最常问的知识的点。
为什么事 务要有隔离性,我们就要知道并发事务时会引发什么 问题。
并行事务会引发什么 问题?
MySQL 服务端是允许多个客⼾端连接的,这意味着 MySQL 会出现同时处理多个事 务的情况。
那么在同时处理多个事 务的时候,就可能出现脏读(dirty read )、不可重复读(non-repeatable read )、幻读(phantom read )的问题。
接下来,通过举例子给大家说明,这些问题是如何发生的。
脏读
如果一个事 务「读到」了另一个「未提交事 务修改过的数据」,就意味着发生了「脏读」现象。
举个 栗子。
假设有 A 和 B 这两个事 务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后再执
行更新操作,如果此时事务 A 还没有提交事 务,而此时正好事务 B 也从 数据库中读取小林的余额
数据,那么事 务 B 读取到的余额数据是刚才事务 A 更新后的数据,即使没有提交事 务。

因为事 务 A 是还没提交事 务的,也就是它随时可能发生回滚操作,如果在上面这种情况事务 A 发
生了回滚,那么事 务 B 刚才得到的数据就是过期的数据,这种现象就被称为脏读。
不可重复读
在一个事 务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生
了「不可重复读」现象。
举个 栗子。
假设有 A 和 B 这两个事 务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后继续
执行代码逻辑处理,在这过 程中如果事务 B 更新了这条数据,并提交了事 务,那么当事务 A 再次
读取该数据时,就会发现前后两次读到的数据是不一致的,这种现象就被称为不 可重复读。

幻读
在一个事 务内多次查询某个符合查询条件的「记录数量」,如果出现前后两次查询到的记录数量不
一样的情况,就意味着发生了「幻读」现象。
举个 栗子。
假设有 A 和 B 这两个事 务同时在处理,事务 A 先开始从数据库查询账⼾余额大于 100 万的记录,
发现共有 5 条,然后事务 B 也按相同的搜索条件也是查询出了 5 条记录。

接下来,事务 A 插入了一条余额超过 100 万的账号,并提交了事 务,此时数据库超过 100 万余额
的账号个数就变为 6。
然后事务 B 再次查询账⼾余额大于 100 万的记录,此时查询到的记录数量有 6 条,发现和前一次
读到的记录数量不一样了,就感觉发生了幻觉一样,这种现象就被称为幻读。
事务的隔离级别有哪些?
前面我们提到,当多个事 务并发执行时可能会遇到「脏读、不可重复读、幻读」的现象,这些现
象会对事务的一致性产生不同程序的影响。
脏读:读到其他事 务未提交的数据;
不可重复读:前后读取的数据不一致;
幻读:前后读取的记录数量不一致。
这三个 现象的严重性排序如下:

SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低,这四个隔离级
别如下:
读未提交(read uncommitted ),指一个事 务还没提交时,它做的变更就能被其他事 务看到;
读提交(read committed ),指一个事 务提交之 后,它做的变更才能被其他事 务看到;
可重复读(repeatable read ),指一个事 务执行过程中看到的数据,一直跟这个事 务启动时看到
的数据是一致的,MySQL InnoDB 引擎的默认隔离级别;
串行化(serializable );会对记录加上读写锁,在多个事 务对这条记录进行读写操作时,如果
发生了读写冲 突的时候,后访问的事务必须等前一个事 务执行完成,才能继续执行;
按隔离水平高低排序如下:

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

也就是说:
在「读未提交」隔离级别下,可能发生脏读、不可重复读和幻读现象;
在「读提交」隔离级别下,可能发生不可重复读和幻读现象,但是不可能发生脏读现象;
在「可重复读」隔离级别下,可能发生幻读现象,但是不可能脏 读和不可重复读现象;
在「串行化」隔离级别下,脏读、不可重复读和幻读现象都不可能会发生。
所以,要解 决脏读现象,就要升级到「读提交」以上的隔离级别;要解 决不可重复读现象,就要
升级到「可重复读」的隔离级别,要解 决幻读现象不建议将隔离级别升级到「串行化」。
不同的数据库厂商对 SQL 标准中规定的 4 种隔离级别的支持不一样,有的数据库只实现了其中几
种隔离级别,我们讨论 的 MySQL 虽然支持 4 种隔离级别,但是与SQL 标准中规定的各级隔离级
别允许发生的现象却有些出入。
MySQL 在「可重复读」隔离级别下,可以很大程度上避免幻读现象的发生(注意是很大程度避
免,并不是彻底避免),所以 MySQL 并不会使 用「串行化」隔离级别来避免幻读现象的发生,因
为使用「串行化」隔离级别会影响性能。
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 有两种开启事务的命令,分别
是:
第一种:begin/start transaction 命令;
第二种:start transaction with consistent snapshot 命令;
这两种开启事务的命令,事务的启动时机是不同的:
执行了 begin/start transaction 命令后,并不代表事务启动了。只有在执行这个命令后,执行了
第一条 select 语句,才是事务真正启动的时机;
执行了 start transaction with consistent snapshot 命令,就会⻢上启动事务。
接下来详细说下,Read View 在 MVCC 里如何工作的?
Read View 在 MVCC 里如何工作的?
我们需要了解两个 知识:
Read View 中四个字段作用;
聚簇索引记录中两个 跟事务有关的隐藏列;
那 Read View 到底是个什么东 西?

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 的字段,我们还需要了解聚簇索引记录中的两个 隐藏列。
假设在账⼾余额表插入一条余额为 100 万的记录,然后我把 这两个 隐藏列也画出来,该记 录
的整个示意图如下:

对于使用 InnoDB 存储引擎的数据库表,它的聚簇索引记录中都包含下面两个 隐藏列:
trx_id ,当一个事 务对某条 聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐
藏列里;
roll_pointer ,每次 对某条 聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志
中,然后这个隐藏列是个指针,指向每一个旧版本记录,于是就可以通过它找到修改前的记
录。
在创建 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_id 和 max_trx_id 之间,需要判断 trx_id 是否
在 m_ids 列表中:
如果记录的 trx_id 在 m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提
交事 务),所以该版本的记录对当前事务不可⻅。
如果记录的 trx_id 不在 m_ids 列表中,表示生成该版本记录的活跃事务已经被提交,所以
该版本的记录对当前事务可⻅。
这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫 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 按顺序执行了以下操作:
事务 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 ,并以链表的方式串联起来,形成版本链,如下图:

你可以在上图的「记录的字段」看到,由于事 务 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 万的这
条记录。
就是通过这 样的方式实现了,「可重复读」隔离级别下在事务期间读到的记录都是事务启动前 的记
录。
读提交是如何工作的?
读提交隔离级别是在每次 读取数据时,都会生成一个新的 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 如下图:

我们来分析下为什么事 务 B 第二次读数据时,读不到事务 A (还未提交事 务)修改的数据?
事务 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 就可以读到事务 A 修改的数据?
在事务 A 提交后,由于隔离级别是「读提交」,所以事务 B 在每次 读数据的时候,会重新创建
Read View ,此时事务 B 第三次读取数据时创建的 Read View 如下:

事务 B 在找到小林这条记录时,会发现这条记录的 trx_id 是 51 ,比事务 B 的 Read View 中的
min_trx_id 值(52 )还小,这意味着修改这条记录的事务早就在创建 Read View 前提交过了,所以该版本的记录对事务 B 是可⻅的。
正是因为在读提交隔离级别下,事务每次 读数据时都重 新创建 Read View ,那么在事务期间的多次
读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事 务修改了该
记录,并提交了事 务。
总结
事务是在 MySQL 引擎层实现的,我们常⻅的 InnoDB 引擎是支持事务的,事务的四大特性是原子
性、一致性、隔离性、持久性,我们这次主要讲的是隔离性。
当多个事 务并发执行的时候,会引发脏读、不可重复读、幻读这些问题,那为了 避免这些问题,
SQL 提出了四种隔离级别,分别 是读未提交、读已提交、可重复读、串行化,从左往右隔离级别
顺序递增,隔离级别越高,意味着性能越差,InnoDB 引擎的默认隔离级别是可重复读。
要解 决脏读现象,就要将隔离级别升级到读已提交以上的隔离级别,要解 决不可重复读现象,就
要将隔离级别升级到可重复读以上的隔离级别。
而对于幻读现象,不建议将隔离级别升级为串 行化,因为这会导致数据库并 发时性能很差。
MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不
是完全解决了,详⻅这篇文章 ),解决的方案有两种:
针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,
事务执行过程中看到的数据,一直跟这个事 务启动时看到的数据是一致的,即使中途有其他事
务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
针对当前读(select ... for update 等语句),是通过 next-key lock (记录锁+间隙 锁)方式解决
了幻读,因为当执行 select ... for update 语句的时候,会加上 next-key lock ,如果有 其他事 务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所
以就很好了避免幻读问题。
对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View 来实现的,它们的
区别在于创建 Read View 的时机不同:
「读提交」隔离级别是在每个 select 都会生成一个新的 Read View ,也意味着,事务期间的多
次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事 务修改
了该记 录,并提交了事 务。
「可重复读」隔离级别是启动事务时生成一个 Read View ,然后整个事 务期间都在用这个 Read View ,这样就保证了在事务期间读到的数据都是事务启动前 的记录。
这两个 隔离级别实现是通过「事务的 Read View 里的字段」和「记录中的两个 隐藏列」的比对,
来控制并发事务访问同一个记录时的行为,这就叫 MVCC (多版本并发控制)。
在可重复读隔离级别中,普通的 select 语句就是基于 MVCC 实现的快照读,也就是不会加锁的。
而 select .. for update 语句就不是快照读了,而是当前读了,也就是每次 读都是拿到最新版本的数据,但是它会对读到的记录加上 next-key lock 锁。
