[MySQL] 事务的隔离性与 MVCC

MySQL 事务与 MVCC

1. 事务的隔离性

在没有任何隔离的情况下,"仅读事务" 与 "读写事务" 的并发执行可能会出现下面的问题:

​ v2 = 2:

​ 如果你认为事务 B 还没有提交,它的修改就不能算,这个结果就是错误的。如果你认为,按照时间线来说,这个值是符合预期的,这个结果就没问题。这种情况叫 "脏读",也就是我可以读到未提交事务中进行的修改,所谓的 "读未提交"。

​ v1 != v2:

​ 在一个事务中你读到了一个字段的不同值。这种情况叫 "不可重复读",因为不保证你在事务中每次读取数据都是一致的。

​ 除此之外还有一种情况,就是如果我们的 WHERE 条件是一个范围查询,那么其他事务可能会同时进行一些插入操作,这样会导致返回的行数不一致。这种情况叫 "幻读",其实你也可以将它理解为不可重复读。

下面我们来看看 InnoDB 是如何解决这些问题的:

​ 如果想解决 "脏读" 问题,就要保证:只要事务没提交,它的改变就不对其他事务可见。InnoDB 通过在每个 SQL 语句开始执行的时候创建一个 "视图" 来保证这点,这个视图保证,这条 SQL 语句只能看见已提交事务的修改。此时事务的隔离级别为 "读提交"。

​ 如果想解决 "不可重复读" 和 "幻读" 问题,就要保证:从事务开启到提交这个过程中,它不会看到任何数据的改变。InnoDB 通过在每个事务开始执行的时候(事务的第一条 SQL 语句)创建一个视图来保证这点,也就是这个事务中全部的 SQL 语句共用一个视图。此时事务的隔离级别为 "可重复读"。

2. 隔离性实现原理

​ 首先先来说一下刚刚反复提到的 "视图" 到底是一个什么东西,在 InnoDB 源码中它其实是一个这样的 C++ 数据结构:

c++ 复制代码
class ReadView {
  /* ... */
private:
  trx_id_t m_low_limit_id;      /* 下一个待分配的事务 ID */

  trx_id_t m_up_limit_id;       /* 最小的未提交事务 ID */

  trx_id_t m_creator_trx_id;    /* 当前事务自己的 ID */

  trx_id_t m_low_limit_no;      /* 下一个待分配的事务 no */

  ids_t m_ids;                  /* 创建 Read View 时的未提交事务 ID 列表 */

  m_closed;                     /* 标记 Read View 是否 close */
}

​ InnoDB 为了使 "仅读事务" 与 "读写事务" 并发执行,会为数据库中的每条记录维护多个版本,这些版本实际上就是对该记录的每一次修改,它们构成了一个版本链。InnoDB 会为聚簇索引添加两个隐藏列用于维护这个版本链,分别是:

trx_id:对该记录改动的 SQL 所属的事务 ID。

roll_pointer:使用这个指针将一条记录的众多版本串成一个时间上的有序链表,实际上就是指向 Undo Log 的指针。

​ InnoDB 中当前数据页上存储的是最新版本的数据,旧版本的数据通过 Undo Log 构建。因此版本链直观来看是类似下面的数据结构:

​ 但注意,图上所示是 Undo Log 的逻辑结构,不是物理结构。实际上 Undo Log 的真正硬盘存储是以事务为维度的,每个事务都会被分配自己的 Undo Log 组。

​ 介绍了 "版本链" 这个概念之后,就可以明确 Read View 的作用了:判断版本链中的哪个版本对当前事务可见。当我们执行一条 SELECT 时,InnoDB 会顺序遍历对应记录版本链中的 trx_id,对每个 trx_id 做如下判断:

​ 如果 trx_id 小于 Read View 中的 m_up_limit_id,说明这个版本一定属于已提交事务,因此可见。

​ 如果 trx_idm_up_limit_idm_low_limit_id 之间,那就要看 trx_id 是否在 m_ids 中可以找到, 如果可以找到,说明这个版本属于未提交事务,因此不可见。如果找不到,说明其依然属于已提交事务,因此可见。并且,我们知道 m_creator_trx_id 是一定位于这个范围的,如果 trx_id 等于 m_creator_trx_id,说明这个版本是当前事务创建的,因此可见。

​ 如果 trx_id 大于等于 m_low_limit_id,说明这个版本所属事务的开启是晚于当前事务的,因此不可见。

​ 这个循环一直进行,直到找到第一个可见版本,呈现给当前事务。这就是所谓的 MVCC,也就是在 "读提交" 和 "可重复读" 这两种隔离级别的事务执行 SELECT 操作时,访问记录版本链的过程。它的存在就是为了使 "仅读事务" 和 "读写事务" 可以安全地并发执行而无需加锁控制。

3.purge

​ 现在还有一个问题,就是这个版本链越来越长,什么时候清理呢?我们先来想一下,Undo Log 有两个作用,一个是事务回滚,一个是 MVCC。从事务回滚的角度来说,当事务提交时,这个事务产生的全部 Undo Log 就都没用了,因为不存在回滚了,这个时候删除即可,但是因为 Undo Log 还要服务 MVCC,所以这个删除时机是不对的,正确的删除时机还要更复杂一些:

​ 对于 INSERT 语句,它产生的 Undo Log 只用于回滚,也就是 DELETE,但是不需要服务 MVCC。为什么呢?因为插入是一个从无到有的过程,它没有所谓旧版本这一说。其他事务要么能 SELECT 出这一行,要么 SELECT 不出,没有版本选择问题。

​ 对于 UPDATE 语句产生的 Undo Log,InnoDB 是这样处理的:

​ 我们知道 Undo Log 是以事务为单位进行组织的,每个事务产生的众多 Undo Log 页面链接在一起。事务提交时,这组 Update Undo Log 会被转移到其所在回滚段的 History 链表中,这个链表就是为了支持 MVCC 而设计的,它会保留直至不被任何 Read View 需要为止。那么什么时候一条 Undo Log 才不被任何 Read View 需要呢?我们需要从下面两点来考虑:

1. 如果一个事务已提交,那么它对数据的修改一定对其提交之后产生的 Read View 可见

2. 对整个系统现存最早生成的 Read View 可见的数据版本,一定对其他 Read View 都可见。

​ 因此,如果一组 Undo Log 对应的事务提交是早于 Oldest Read View 的生成的,那么这组 Undo Log 就肯定没用了,这就是 InnoDB 的 purge 逻辑。

​ 具体来说,InnoDB 是通过在每组 Undo Log 的 Header 中的 TRX_UNDO_TRX_NO 属性,与 Read View 中的 m_low_limit_no 属性来控制的。生成 Read View 的时候,m_low_limit_no 会被赋为当前系统中最大的 no + 1 的值。事务提交时,当前事务生成自己的 no,这个 no 是严格以事务提交为顺序的,提交越早的事务 no 被分配得越小,反之则越大,然后将这个值赋给 TRX_UNDO_TRX_NO

​ 可能听起来有点乱,但是只要记住这个 no 就是用来记录事务提交与 Read View 被创建之间的相对时间先后的就好,这与刚刚的第一点是呼应的。如果还是没理解可以看下面的图片:

​ 这提醒了我们,影响 Undo Log 清理的是最老的 Read View,所以一个可重复读的长事务会严重拖慢 Undo Log 的清理效率。因为长事务很可能在使用最老的 Read View,那么任何比这个 Read View 的 no 值大的 Undo Log 都不能被清除。

4. 总结

​ MVCC 是用于保护一般 SELECT 语句进行 "快照读" 的机制,根本目的就是使这种 "仅读事务" 能够在不对表进行锁控制的情况下安全地与其他事务并发执行。

​ 对于 "读写事务" 和 "读写事务" 的并发来说,MVCC 就无能为力了,还是要进行一定的锁控制。

相关推荐
步步为营DotNet1 小时前
深度解析DbContext ChangeTracker:实体状态管理与性能优化
数据库·oracle·性能优化
南棱笑笑生1 小时前
20251205在ubuntu20.04.6下的打包/解压缩tar.bz2压缩包的方法
数据库·postgresql
java_logo1 小时前
PGADMIN4 Docker 容器化部署指南
运维·数据库·docker·postgresql·容器·数据库系统
a3722107741 小时前
HikariCP配置 高并发下连接泄漏避免
java·数据库·oracle
缺点内向1 小时前
如何在C#中添加Excel文档属性?
开发语言·数据库·c#·.net·excel
生信大表哥1 小时前
生物信息分析:Singularity容器技术深度解析与实战指南
数据库·r语言·rstudio·生信入门·数信院生信服务器
千百元1 小时前
实时监控磁盘I/O性能
linux·运维·数据库
我的offer在哪里1 小时前
如何查看和修改 MySQL 底层文件(分「查看」「修改」维度,严格区分安全 / 危险操作)
mysql·安全·adb
卿雪1 小时前
缓存异常:缓存击穿、缓存穿透、缓存雪崩 及其解决方案
java·数据库·redis·python·mysql·缓存·golang