MySQL学习笔记之MVCC的实现
MVCC 概述
MVCC 是什么?
Multi-Version Concurrency Control(MVCC)即多版本并发控制,是一种并发控制方法,一般用于实现对数据库的并发访问。特点:不依赖锁机制(无锁非阻塞式并发)却能避免同一数据在并发事务之间的竞争,以较低开销的方式提高数据库的并发性能。
MVCC 的使用在 MySQL 数据库哪些地方有体现呢? InnoDB 是如何实现 MVCC 的呢?
接下来将通过这篇文章进行解答。
1. 事务隔离性问题
在介绍 MVCC 之前,先了解一些关于 数据库事务 的前置知识。
事务的隔离性(Isolation)
事务具有 原子性、一致性、隔离性、持久性 四个特性,这些特性数据库是根据不同的技术实现的,这篇文章主要讲解的是 隔离性。
隔离性(Isolation):数据库允许多个并发事务同时对数据进行读写和修改的能力。当多个用户并发访问数据库时,每个用户开启的事务不能被其他事务所做的操作干扰,实现多个并发事务之间相互隔离。隔离性可以防止多个并发事务执行时由于交叉执行导致数据不一致的问题。
MySQL数据库的事务隔离性可以通过 MVCC 或者 加锁 的方式来实现。
脏读、不可重复读、幻读
并发事务场景下,读-写冲突(事务之间相互影响)导致出现 脏读、不可重复读、幻读 的事务隔离性问题。
事务隔离性问题 概述如下:
- 脏读:一个事务读取了另一个事务未提交的数据,这些数据随时可能会被回滚,导致事务可能读到脏数据(过期数据)。
- 不可重复读:在一个事务中多次执行同一查询语句,由于其他事务的修改操作,导致当前事务的前后两次查询返回的结果(记录)不一致。
- 幻读:在一个事务中多次执行同一查询语句,由于其他事务的插入或删除操作,导致当前事务的前后两次查询返回的结果集(记录数量)不一致。
数据库如何避免 脏读、不可重复读、幻读 现象的呢? 接着往下看。
2. 事务的隔离级别
SQL 标准提出 四种隔离级别 来避免脏读、不可重复读、幻读现象。
四种隔离级别如下(隔离级别从低到高):
- READ-UNCOMMITTED(读未提交):允许读取并发事务尚未提交的数据。
- READ-COMMITTED(读提交)):允许读取并发事务已经提交的数据。
- REPEATABLE-READ(可重复读):在同一事务中,多次执行同一查询语句返回的结果一致。
- SERIALIZABLE(可串行化):所有的事务串行执行。
四种隔离级别出现脏读、不可重复读、幻读的情况(✅表示可能出现 ❌表示不可能出现):
隔离级别越高,能避免事务隔离性问题越强,但是对应的并发性能效率也就越低。
MySQL 的 InnoDB 引擎默认隔离级为 可重复读。
关于这四种隔离级别数据库是如何实现的?
读未提交:可以读取未提交的数据(允许脏读),所以允许事务直接读取(不需要加锁) 最新数据即可。
读提交:只允许读取已提交的数据,需要避免脏读,所以在每次查询时,获取通过最新的版本快照来判断哪些是已提交的数据从而避免脏读。(这里是基于 MVCC 实现的,具体的后续文章会讲解)
可重复读:同一事务多次读取同样记录,需要返回结果一致。所以在事务第一次读取数据时,获取当前的版本快照,通过快照的方式保证事务执行期间与事务启动时看到的数据一致,即使其他事务修改了数据也不会影响当前事务,从而实现可重复读。(这里也是基于 MVCC 实现的,具体后续文章会讲解)
可串行化:需要完全服从ACID的特性,所以需要事务之间严格串行执行,通过加读写锁的方式实现串行执行即可。
3. 当前读与快照读
前面说到 MySQL 的 InnoDB 引擎默认隔离级别为 可重复读,那么是不是就无法避免 幻读 这种现象呢?
答案是能避免,虽然默认隔离级别为可重复读,但是通过 快照读 和 当前读 可以很大程度上的避免出现 幻读 现象(仍然存在一些情况会导致幻读)。
当前读(锁定读):
- 通过
select ... lock in share mode
、select ... for update
语句可以对读取的记录进行加锁,其中select ... lock in share mode
加的是S
锁(共享锁),select ... for update
加的是X
锁(独占锁)。 - 当前读 读取的是 记录的最新版本,为了防止幻读,在读取时要保证其他并发事务不能插入或删除当前记录,所以会对读取的记录进行加锁(
next-key lock
)操作,加锁后当其他事务插入或删除记录时就会被阻塞,从而避免幻读现象。
快照读(一致性非锁定读):
- 通过普通的
select
语句使用快照读,在读取记录时不会加锁,即不会阻塞其他事务。快照读是基于 MVCC(多版本并发控制) 实现的。快照读是为了提高并发性能,避免了加锁操作,降低了开销,代价是读到的记录可能是之前的历史版本而不是最新版本。 - 在可重复读的隔离级别下,快照读通过快照数据的方式可以保证 事务执行中看到的数据一直与事务启动时看到的数据一致,这样即使其他事务插入或删除记录,也不会影响当前事务的记录数量,从而避免了 幻读 现象。
所以 MySQL 默认的事务隔离级别可以通过 当前读(加next-key lock
锁的方式) 和 快照读(基于 MVCC 的方式) 很大程度上的避免出现幻读现象。
4. MVCC 在 MySQL 的体现
通过上面关于事务隔离性问题和隔离级别以及快照读等知识,应该已经能体现出 MVCC 在 MySQL 的使用场景了。接下来总结一下 MVCC 具体在 MySQL 哪些地方体现了。
- 在实现 MySQL 的读提交隔离级别中,会使用 Read View + 记录的版本链 以对比的方式来确保避免读取到未提交的数据即避免脏读现象。
- 在实现 MySQL 的可重复读隔离级别中,同样使用 Read View + 记录的版本链 的方式来确保 事务执行过程中看到的数据与启动事务时看到的数据一致,从而避免不可重复读现象。
- 可重复读隔离级别中的幻读是靠快照读(基于 MVCC 实现的)的方式来避免幻读现象。
所以可以看出 MVCC 可以解决事务隔离性问题,相比于基于悲观锁方式实现的当前读,MVCC 可以做到更低开销的方式解决隔离性问题,提高数据库的并发性能。
关于 InnoDB 对 MVCC 的实现(Read View + 记录的版本链),请接着往下看。
MVCC 的实现
MySQL 对 MVCC 的实现依靠 记录的隐藏字段、undo log 以及 Read View 来实现的。
1. 记录的版本链
记录的隐藏字段
聚簇索引的记录中除了包含记录的数据字段之外,还包含:
DB_ROW_ID
:隐含的自增ID,如果数据表中没有找到符合条件的主键,会自动以DB_ROW_ID
作为聚簇索引的索引键。DB_TRX_ID
:最后一次插入或修改(更新或删除)该记录的事务的事务ID。DB_ROLL_PTR
:回滚指针,指向该记录的undo log
(上一个版本),如果是第一个版本,则该位置为空。
这里我们只需关注 DB_TRX_ID
和 DB_ROLL_PTR
两个隐藏字段即可,其中的 DB_ROLL_PTR
回滚指针利用 undo log。
undo log
undo log 是 InnoDB 存储引擎生成的回滚日志,保证了事务的原子性,主要用于 事务回滚和 MVCC。
通过 undo log 结合 DB_ROLL_PTR
回滚指针 可以形成的 版本链,具体流程为:不同事务或相同事务对同一条记录进行修改时,数据库会先对该行加锁,并将旧数据会拷贝到 undo log 中,拷贝完毕后,新数据会替换旧数据同时会将隐藏字段中的事务ID修改为当前事务的ID,回滚指针指向拷贝到 undo log 的副本记录。通过这样的方式会形成一条版本链。
记录的版本链 如图:
2. Read View
Read View 是什么?
Read View 用于记录和维护系统某一时刻的当前活跃事务的ID,可以看成是一张快照,主要用来做可见性判断,通过结合记录的版本链,将 Read View 的字段作为条件判断当前事务对记录版本链上的哪个版本的记录可见。通过 Read View 与 记录的版本链 对比,来控制并发事务访问同一个记录时的行为,从而实现 MVCC。
Read View 里重要的四个字段
m_creator_trx_id
:创建该 Read View 的事务的事务 ID。m_ids
:创建该 Read View 时,当前数据库中活跃事务(启动但未提交)的事务 ID 列表。m_up_limit_id
:活跃事务 ID 列表m_ids
中最小的事务 ID。m_low_limit_id
:目前出现过的最大的事务 ID + 1,即下一个将被分配的事务 ID。
需要注意的是:m_low_limit_id
并不是活跃事务ID中最大的ID + 1,而是在创建该 Read View 时,数据库已经出现的最大事务ID + 1。(当每个事务启动时,都会被分配到一个事务ID,这个ID是递增的,也就是说最新创建的事务的事务ID是最大的)
可能存在一些事务 ID 较大的事务已经提交的情况(如图中的灰色部分)
数据可见性的比较流程
一个事务执行过程中,除了对自己修改的记录总是可见之外,想要判断对其他事务修改的记录是否可见,可以通过 该记录行的 DB_TRX_ID
与 Read View 的字段作为条件进行比较,判断是否这个版本的记录是否可见。
具体流程图如下:
具体流程文字描述:
- 如果记录行的事务ID
DB_TRX_ID
小于 Read View 的字段m_up_limit_id
,表示生成该版本的记录的事务在创建Read View前已经提交,所以该版本的记录对当前事务可见。 - 如果记录行的事务ID
DB_TRX_ID
大于等于 Read View 的字段m_low_limit_id
,表示生成该版本的记录的事务是创建Read View后才启动的,所以该版本的记录对当前事务不可见。 - 判断记录行的事务ID
DB_TRX_ID
是否在 Read View 的活跃事务ID列表m_ids
中:- 如果在活跃事务ID列表
m_ids
中,表示生成该版本记录的活跃事务依然未提交(活跃),所以该版本的记录对当前事务不可见。 - 如果不在活跃事务ID列表
m_ids
中,表示生成该版本记录的活跃事务已经提交,所以该版本的记录对当前事务可见。
- 如果在活跃事务ID列表
- 如果对当前版本的记录不可见,则根据当前记录的回滚指针
DB_ROLL_PTR
找到 undo log,找到版本链的最近一条旧数据,获取该记录的事务IDDB_TRX_ID
重新进行上述流程的判断,直到找到可见的版本记录为止。
通过 Read View 的字段 与 需要查询的记录的版本链 进行比较判断,当前事务对已经提交过的事务的版本记录可见,对未启动的事务的版本记录或正在活跃(启动未提交)的版本记录不可见。
Read View 的创建时机
在不同的事务隔离级别下,生成 Read View 的时机是不同的。
- 在 读提交(RC) 事务隔离级别下,每次执行
select
语句都会生成一个新的 Read View。- 这也是为什么读提交不能避免不可重复读现象的原因,因为如果在当前事务执行期间,多次读取同一个数据的过程中,有其他事务对数据进行修改并提交事务时,新的 Read View 对修改的数据是可见的,所以会导致前后两次读取的数据不一致。
- 在 可重复读(RR) 事务隔离级别下,每次启动事务后,第一次
select
数据前生成一个 Read View。- 这也是为什么可重复读可以避免不可重复读现象和幻读的原因,因为只会在事务第一次读取记录时生成一个 Read View,整个事务执行期间都是用这个 Read View,这样可以保证事务执行期间读取的数据始终与事务启动时读取的数据一致,并且记录数量也是一致的。
为什么不同创建时机能解决不同的事务隔离性问题在后面有详细讲解。
MVCC 如何实现 RR 事务隔离级别的?
通过例子来讲解 MVCC 如何实现 可重复读 事务隔离级别的。
当前有事务8与事务9两个事务,它们的事务执行过程如下:
这里的 begin;
并不是真正的启动事务,而是在执行 增删改查 的 SQL语句时,才算是真正启动事务。(可以通过 start transaction with consistent snapshot
命令立刻启动事务)
所以在 可重复读 事务隔离级别下,事务9 生成 Read View 的时机应该是在 T4 时刻。并且整个事务9执行期间,会一直使用该 Read View。
T4 时刻 查询记录返回结果为
name = lisi
因为 事务8 在 T2 时刻已经启动了,并且 T4 时刻未提交,所以 T4 时刻生成的 Read View 中包含活跃事务8。
T4 时刻生成的 Read View 以及 当前查询的记录的版本链 如下:
根据 T4 时刻生成的 Read View 和 记录的版本链 可以看到:记录的最新版本的事务ID DB_TRX_ID = 7
与 Read View 中的字段进行比较,因为 DB_TRX_ID < m_up_limit_id
所以事务9对当前最新版本的记录可见,也就是说 事务9 可以查询到 name = lisi
。
T6 时刻 查询记录返回结果为
name = lisi
由于 T5 时刻 事务8 修改了需要查询的数据,所以对应版本链发生变化。接下来看看 T6 时刻查询记录会不会发生变化。
T6 时刻生成的 Read View 以及 当前查询的记录的版本链 如下:
根据 T6 时刻生成的 Read View 和 记录的版本链 可以看到:记录的最新版本的事务ID DB_TRX_ID = 8
与 Read View 中的字段进行比较,因为 DB_TRX_ID 在 活跃事务ID列表 m_ids 中
所以事务9对当前最新版本的记录不可见。
接着会根据 回滚指针 DB_ROLL_PTR
找到最近的旧版本记录也就是DB_TRX_ID = 7
的记录,此时由于 DB_TRX_ID < m_up_limit_id
所以事务9对当前的旧版本记录可见,也就是说 事务9 可以查询到 name = lisi
。
T8 时刻 查询记录返回结果为
name = lisi
这里虽然 T7 时刻 事务8 已经提交事务了,但是因为事务隔离级别是 可重复读,也就是说虽然 事务8 提交了也就是 事务8 不活跃了,但是并不影响当前 事务9 的 Read View,因为 Read View 始终和第一次查询也就是 T4 时刻的 Read View 一致。所以 事务9 在 T8 时刻查询到的记录返回结果依旧是name = lisi
。
小结
通过上面的例子可以看到 T4、T6 以及 T8 时刻查询记录返回的结果始终是一致的,这也就符合了 可重复 事务隔离级别的要求:事务执行期间查询的数据始终与事务开始时查询的数据一致。
这是通过 Read View 与 记录的版本链 比较(也就是快照读(基于 MVCC 实现的))来保证避免 脏读、不可重复读现象,同时依靠这个机制还可以很大程度的避免 幻读 现象,但是仍然可能出现 幻读 现象。
可重复读事务隔离级别仍然存在幻读现象
举一个可能出现幻读的例子:
前提:当前表中并没有 id = 7
这条记录。
由于 id = 7
的记录并不存在,事务B 在 T3 时刻查询记录返回的结果为空。
接下来 事务A 执行插入id = 7
的记录语句并且提交事务,然后 事务B 使用 update
(注意此时的语句为当前读,可以获取最新的记录信息,也就是说可以看到 id = 7
这条记录)更新 id = 7
这条记录。
对于 可重复读 事务隔离级别来说,事务永远对自己修改的记录可见,所以在 T7 时刻再次查询记录时返回的结果就不会空了,这就说明前后两次的查询结果的记录数量不一致,也就是出现了 幻读 现象。
这里其实已经比较玄学了,事务B查不到记录但是却能修改记录,但是确实可能存在这样的情况,也就是仍然可能出现 幻读 现象。
MVCC 如何实现 RC 事务隔离级别的?
通过例子来讲解 MVCC 如何实现 读提交 事务隔离级别的。
当前有事务8与事务9两个事务,它们的事务执行过程如下:
和上面的例子相同,只需要关注 事务9 在 T4、T6、T8 时刻查询记录返回的结果。
注意点:因为事务隔离级别是 读提交,也就是说 Read View 生成的时机是每一次查询语句都会生成一个新的 Read View。
由于 T4、T6 时刻与上面 MVCC 如何实现 RR 事务隔离级别的?的例子完全相同,这里就不再重复描述了,简要概述一下。
在 T4 时刻 查询记录返回结果为
name = lisi
因为此时记录的版本链上最新记录依旧是 DB_TRX_ID = 7
,而当前最小活跃事务ID m_up_limit_id = 8
,所以 当前事务9 对 最新版本记录是可见的。所以查询记录返回结果为 name = lisi
。
在 T6 时刻 查询记录返回结果为
name = lisi
虽然此时记录的版本链上最新数据变为 DB_TRX_ID = 8
,但是由于 T6 时刻 事务8 并未提交事务(依旧是活跃事务),所以就算新的查询语句会重新生成 Read View,但是生成的 Read View 与 T4 时刻一致,所以 事务9 最后还是会找到 旧版本 DB_TRX_ID = 7
的记录,所以查询记录返回结果依旧为 name = lisi
。
在 T8 时刻 查询记录返回结果为
name = wangwu
由于 T8 时刻 事务8 已经提交事务,所以 T8 时刻生成的 Read View 不包含 事务8 的事务ID。
T8 时刻生成的 Read View 以及 当前查询的记录的版本链 如下:
根据 T8 时刻生成的 Read View 和 记录的版本链 可以看到:记录的最新版本的事务ID DB_TRX_ID = 8
与 Read View 中的字段 m_up_limit_id = 9
进行比较,因为 DB_TRX_ID < m_up_limit_id
所以事务9对当前最新版本的记录可见,也就是说 事务9 可以查询到 name = wangwu
。
小结
通过上面的例子可以看到在 事务8 未提交之前,查询的记录是一致的,在 事务8 提交事务之后,查询的记录与事务启动时查询的记录不一致。所以可以看出这里的 Read View 生成时机可以实现 读提交 事务隔离级别,即可以避免出现 脏读 现象。但是不可以避免 不可重复读 现象。
转载自:https://juejin.cn/post/7214665617311088700