MySQL(十四):事务隔离与 MVCC 原理
目录一、事务隔离问题回顾1. 为什么并发事务会产生问题二、并发事务中的三大问题1. 脏读Dirty Read2. 不可重复读Non-repeatable Read3. 幻读Phantom Read三、隔离级别与问题对照1. 隔离级别对照2. 对照表四、Undo Log 原理1. Undo Log 解决了什么问题2. DML 操作对应 Undo Log3. 事务回滚流程五、版本链机制1. 聚簇索引记录中的隐藏字段2. 版本链如何形成六、Read View1. 什么是 Read View2. Read View 的四大核心物理字段3. Read View 可见性判断原则七、MVCC 整体流程1. 快照读与当前读3. 快照读的 MVCC 工作流八、RC 与 RR 的区别1. Read View 生成时机对比2. 为什么 RC 会出现不可重复读3. 为什么 RR 能解决不可重复读4. RR 是否完全解决幻读总结一、事务隔离问题回顾在上一章中我们初步认识了事务并了解了数据库为了在安全与性能之间寻找平衡而设计的四种隔离级别。然而这只是理论的冰山一角在高并发的真实生产环境中成百上千个事务同时交织读写底层的物理冲突远比想象中复杂。本章我们将正式撕开数据库的底面从微观的并发异象开始一路下探到 InnoDB 存储引擎最核心的部分——MVCC 机制1. 为什么并发事务会产生问题从操作系统的物理视角来看数据库本质上是一个多线程共享的内存与磁盘混合资源池当我们执行SELECT或UPDATE操作时InnoDB引擎会依次完成以下步骤将所需数据页从磁盘加载到内存缓冲池进行加锁 / 非加锁处理修改数据最后通过异步机制刷新磁盘如果所有的业务请求都单线程运行数据自然绝对安全。但在追求极致吞吐量的现代后端架构中数万个线程同时对 Buffer Pool 中同一张表的同一行进行读写就必然会导致内存状态机的错乱。这种由于多个物理线程对共享数据页的并发读写缺乏时序规范而导致的逻辑灾难就是并发事务问题的根源隔离级别与并发性能为了解决无序的并发冲突SQL 标准定义了四种隔离级别读未提交、读已提交、可重复读和串行化这四种隔离级别的设计本质上是通过对底层锁资源和内存开销的精准调控在并发吞吐量与数据安全性之间寻求最佳平衡点追求吞吐量降低隔离级别可以减少锁机制带来的性能开销使读写操作能够更高效地并发执行从而显著提升系统的 TPS每秒事务处理量。然而这种优化是以牺牲数据一致性为代价可能导致各种异常读取现象频繁出现追求绝对安全提高隔离级别尤其是到了串行化级别数据库会通过强行加锁把所有的并发读写全部排成单线程。数据虽然绝对精准但系统的整体吞吐量会直接归零产生严重的线程阻塞二、并发事务中的三大问题当多个事务在缺乏隔离的环境中并发运行时数据库底层会出现三种经典的并发读取异象为了方便理解下文所有的场景均假设存在事务 A与事务 B两个物理线程在时间线上交错执行1. 脏读Dirty Read定义脏读是指事务 A 读取到了事务 B 已经修改、但尚未正式提交的数据以银行转账为例假设张三的初始余额为 1000 元时间线 (Timeline) | |-- T1: 事务 A 开启查询张三余额结果为 1000 元 | |-- T2: 事务 B 开启执行 UPDATE将张三余额扣减 200 元变为 800 元但未提交 | |-- T3: 事务 A 再次查询张三余额读取到了内存 Buffer Pool 中已被修改的 800 元 | |-- T4: 事务 B 突发业务异常执行了 ROLLBACK。张三的余额在磁盘和内存中被重置回 1000 元 v此时事务 A 拿着读取到的 800 元 数据去继续执行后续的财务报表输出或风控计算。然而这个 800 元 由于事务 B 的回滚在现实世界中从来没有真正存在过。事务 A 读取到的就是一段脏数据2. 不可重复读Non-repeatable Read定义不可重复读是指事务 A 在其生存周期内多次读取同一行记录但在前后两次读取的间隔中事务 B 对该行数据执行了修改并正式提交导致事务 A 前后两次读取到的具体数值完全不一致张三的初始余额为 1000 元时间线 (Timeline) | |-- T1: 事务 A 开启首次读取张三的余额结果为 1000 元 | |-- T2: 事务 B 开启将张三余额修改为 800 元并立刻执行了 COMMIT | |-- T3: 事务 A 在当前事务内再次发起对张三余额的读取指令 v此时由于事务 B 已经提交事务 A 读到了最新的 800 元。站在事务 A 的视角这笔业务还没结束前我明明没有改过张三的钱为什么前后两秒钟查出来的余额突然变了这打破了事务内数据应当保持静态快照的逻辑预期使得事务 A 无法做出前后一致的业务决策3. 幻读Phantom Read定义幻读是指事务 A 按照某一范围条件如 age 20批量检索数据首次读取时得到了 N 行记录。随后事务 B 在该范围内新插入或删除了几行记录并正式提交。当事务 A 再次以相同的条件检索时惊讶地发现记录行数变成了 N1 或 N-1 行仿佛产生了幻觉假设银行需要统计存款大于 50 万元的高净值客户总数初始状态下只有 3 个人时间线 (Timeline) | |-- T1: 事务 A 开启执行范围查询balance 500000; | 存储引擎返回结果3 人 | |-- T2: 事务 B 开启新开户了一个李四存款 60 万元 | 并且事务 B 立刻执行了 COMMIT 提交 | |-- T3: 事务 A 为了二次复核再次执行相同的范围查询 v此时事务 A 发现结果突然变成了 4人。多出来的李四就像幽灵Phantom一样凭空冒了出来误区辨析 很多时候容易把不可重复读和幻读混淆。在面试或技术方案评审中必须精准指出两者的物理区别不可重复读的焦点是UPDATE / DELETE。它指的是原本就存在的某一行特定记录其内部的字段值在前后读取时发生了改变幻读的焦点是INSERT。它指的是原本的单行记录并没有变而是由于并发插入导致整体数据集合的记录行数或范围空间发生了突变并发问题触发问题的核心 SQL 类型并发事务 B 的提交状态异常表现底层物理本质脏读UPDATE / INSERT / DELETE尚未提交读取到了注定会随着回滚而消亡的中间内存数据读写完全没有隔离读取了内存中的不可靠脏页不可重复读由 UPDATE 或 DELETE 触发已经提交同一行特定记录前后读取的字段内容发生了改变事务无法锁定/维持住已有数据行的快照稳态幻读由 INSERT 触发已经提交相同范围条件检索前后得到的记录条数/行数发生了改变事务仅能控制已知行无法锁住行与行之间的间隙三、隔离级别与问题对照针对脏读、不可重复读和幻读这三大问题数据库设计者并没有强制要求系统必须全盘封锁。相反SQL 标准通过划分四种不同的隔离级别明确了每一档级别能够防御哪些问题又对哪些异象做出了妥协1. 隔离级别对照读未提交在该级别下数据库底层的读操作几乎不加任何限制或锁控制底层行为一个事务在读取数据时直接去读取内存缓冲池中被其他并发事务修改后的最新字节页而完全不检查这个修改事务是否已经提交防御边界没有任何防御能力代价与问题在此级别下脏读、不可重复读、幻读三大问题全部会发生。它属于数据库一致性中的裸奔状态在实际工业级生产中绝不可轻易启用读已提交从该级别开始数据库开始构筑真正的一致性防线底层行为执行引擎通过机制确保任何读操作只能看到那些已经发出 COMMIT 命令、进入物理稳态的数据防御边界完美解决脏读问题。因为未提交的临时中间修改对其他事务是完全不可见的代价与问题由于它在事务内部的每次独立查询都会实时去读取最新的物理快照导致在并发 UPDATE 或 INSERT 发生并提交时当前事务前后查询的结果依然会发生突变。因此不可重复读和幻读问题在此级别下依然存在可重复读该级别是 MySQL InnoDB 存储引擎的默认事务隔离级别底层行为在此级别下事务启动并执行第一次查询时系统会为当前数据库状态存储一张内存快照。在接下来的整个事务生命周期内无论其他并发事务如何修改并提交数据当前事务的后续读取都只会定格在当初那张快照的画面中防御边界完美解决脏读、不可重复读问题针对幻读的特殊说明按照原生标准该级别原则上是无法阻止幻读的。但是MySQL 的 InnoDB 存储引擎通过 MVCC 与间隙锁已经基本上解决了幻读问题串行化Serializable是隔离级别的终极形态它代表着对并发性能的完全绝缘底层行为在此级别下MVCC 这种弱隔离机制会部分失效数据库会退回到最原始的基于锁的并发控制。所有的普通 SELECT 语句都会被底层隐式自动升级为加共享锁的 SELECT ... FOR SHARE 模式。读操作会阻塞写操作写操作也会阻塞读操作防御边界同时解决脏读、不可重复读、幻读代价与问题因为所有的读写碰撞都演变成了单线程排队阻塞系统的并发吞吐量会发生灾难性的跌落。通常仅用于极度敏感的金融账目总清算等无并发要求的特殊场景2. 对照表为了在工程设计时能够一目了然地评估风险SQL 标准将四种隔离级别与并发异象的防御映射关系整理成了如下的标准对照表隔离级别脏读不可重复读幻读物理加锁/吞吐量开销Read Uncommitted无法防御无法防御无法防御几乎无锁吞吐量极大Read Committed完美解决无法防御无法防御锁极少读写分离吞吐量优秀Repeatable Read完美解决完美解决标准 SQL 无法解决(InnoDB 基本解决)中等读借助快照写借助行/间隙锁Serializable完美解决完美解决完美解决极高读写全部排队吞吐量极低四、Undo Log 原理MVCC多版本并发控制犹如一座雄伟的建筑而 Undo Log回滚日志正是支撑这座建筑的地基。在 InnoDB 存储引擎中Undo Log 的精妙机制不仅保障了事务的原子性回滚能力更是 MVCC 多版本机制得以实现的关键基础1. Undo Log 解决了什么问题定义Undo Log 是一种逻辑日志。当事务对数据库进行修改时InnoDB 不仅会在内存缓冲池中改写字节还会在专门的 Undo 页面中将这次修改的逆向操作以结构化的形式记录下来解决的两个核心问题实现事务的原子性回滚当系统发生业务报错、用户主动调用 ROLLBACK 命令或者服务器发生断电需要崩溃恢复时InnoDB 会依据 Undo Log 的记录信息将数据逆向恢复构建 MVCC 版本链隔离性当一个事务在读已提交或可重复读级别下读取某行记录而该行正巧被其他事务修改时执行引擎不会去等待锁而是会顺着 Undo Log 逆向推导回退在内存中找出该记录在过去某个节点的快照从而实现不加锁的高并发快照读2. DML 操作对应 Undo Log为了追求极致的性能与空间压缩InnoDB 针对 INSERT、UPDATE 和 DELETE 三种不同的修改行为在底层设计了完全不同的 Undo Log 记录格式INSERT 对应的 Undo Log当一个事务往表中插入一行全新记录时对于 过去的世界 而言这行记录是完全不存在的记录内容InnoDB 的 TRX_UNDO_INSERT_REC 类型的日志非常精简它只需要记录该新插入记录的主键 ID生命周期极其短暂。因为该记录在事务开启前不存在没有任何其他并发事务可能通过 MVCC 跨时空访问到它。因此只要当前 INSERT 事务一旦提交该插入类型的 Undo Log 就可以立刻被系统直接销毁或回收UPDATE 对应的 Undo Log更新操作是 MVCC 的核心演进动力其对应的 TRX_UNDO_UPD_EXIST_REC 日志最为复杂记录内容它会详细记录当前行被修改前的旧值以及本次修改所涉及的列信息生命周期由于其他并发事务可能正在以 可重复读 的规格读取当前行的旧版本因此即便当前 UPDATE 事务已经提交了其对应的 Undo Log也绝对不能立刻删除。它必须一直存放在 Undo 页面中直到整个系统中所有可能用到该旧版本的事务全部终结后才由后台的Purge 线程进行统一的异步物理清理DELETE 对应的 Undo Log在 InnoDB 的 B 树物理结构中如果你对某行执行了 DELETE FROM 表 WHERE id 1执行引擎并不会瞬间将这行数据从页面的物理链表中删除。因为其他并发事务可能正在读取该页面底层机制第一阶段在事务执行删除时InnoDB 仅仅是将该行记录的头信息中的 delete_mask 标志位修改为 1逻辑上标记删除。此时该记录依然在 B 树的叶子节点中第二阶段当事务提交且没有任何其他并发事务再需要看到这行历史数据时后台的 Purge 线程才会真正将其从 B 树的行链表中物理移除Undo Log 的职责在第一阶段执行时系统会生成一条删除类型的 Undo Log。如果要回滚系统只需要将该行的 delete_mask 重新改回 0 即可INSERTUPDATEDELETE日志类型TRX_UNDO_INSERT_RECTRX_UNDO_UPD_EXIST_RECTRX_UNDO_DEL_MARK_REC记录内容仅记录主键 ID记录被修改列的历史旧值记录主键及逻辑删除状态回滚时行为执行逆向 DELETE将字段值重写为旧值将标志位置回 0事务提交后立刻释放/复用挂入版本链等待 Purge 线程挂入版本链等待 Purge 线程3. 事务回滚流程我们用前文的 t_balance 表张三的初始余额为 1000 元来模拟一次事务崩塌后的回滚轨迹当收到 ROLLBACK 指令后InnoDB 执行引擎的工作流如下反向检索引擎通过当前事务的控制块定位到该事务产生的最后一条 Undo Log 记录提取旧值解析该 Undo 记录发现主键为 1 的行其 balance 字段的历史旧值是 1000.00逆向重写引擎直接在内存和日志中对主键为 1 的记录执行一次反向的重写把 balance 从 400 强行改回 1000.00清理释放当所有逆向操作执行完毕数据状态完全回到了事务开启前的状态回滚成功并安全释放该事务所持有的行锁资源。对于外界而言这笔修改如同从未发生五、版本链机制在 InnoDB 存储引擎中每一行记录在任何时候都绝不仅仅呈现为一个单一的稳态而是由一系列历史版本相互勾连组合而成的。这种将数据的 过去 与 现在 串联起来的数据结构就是版本链Version Chain而版本链的构建完全依赖于底层数据行中的隐藏字段1. 聚簇索引记录中的隐藏字段当我们定义了一张表并插入数据时InnoDB 在将其存入 B 树的叶子节点时会自动在每一行记录的头部塞入2 到 3 个对用户不可见的隐藏列DB_TRX_ID6 字节事务 ID物理职责用来记录最近一次对当前行执行了 INSERT 或 UPDATE 操作的事务 ID运作逻辑如果事务 100 执行了一条更新语句修改了这行数据那么这行记录的 DB_TRX_ID 字段就会被立刻改写为 100。它是后续进行隔离性可见性判断的核心DB_ROLL_PTR7 字节回滚指针物理职责这是一个指向对应的 Undo Log 记录的物理指针运作逻辑每当当前行被修改时InnoDB 都会把修改前的旧快照塞进 Undo Page 里。然后当前行记录的 DB_ROLL_PTR 就会写入一个指针值指向刚刚生成的这条 Undo LogDB_ROW_ID6 字节行 ID物理职责当且仅当该表既没有显式定义主键也没有定义任何非空的唯一索引时InnoDB 会自动生成这个单调递增的隐藏主键2. 版本链如何形成为了理解版本链从无到有、逐步拉长的全过程我们用前文的 t_balance 表来进行一次推演阶段一初始插入状态事务 ID 50假设一个历史事务ID 为 50执行了插入语句数据如下当前 B 树叶子节点中的行记录account_id 1, user_name 张三, balance 1000.00DB_TRX_ID 50DB_ROLL_PTR NULL因为是全新插入没有更远的前世阶段二事务 60 介入修改余额为 800 元此时并发事务 60 启动执行UPDATE t_balance SET balance 800 WHERE account_id 1但尚未提交底层物理变化InnoDB 拷贝出当前行的旧值1000.00在 Undo 页面中生成一条 UPDATE 类型的 Undo Log假设其物理地址为 0x00AA引擎直接修改 B 树叶子节点中的当前行把 balance 改为 800当前行的隐藏列发生更替DB_TRX_ID 变为60DB_ROLL_PTR 写入刚才的 Undo 日志地址0x00AA阶段三事务 70 再次介入将余额修改为 600 元紧接着另一个并发事务 70 启动对同一行再次执行了UPDATE t_balance SET balance 600 WHERE account_id 1同样尚未提交底层动作InnoDB 再次拷贝出当前行的旧值此时行的旧值是 balance 800, 且其 roll_ptr 为 0x00AA在 Undo 页面中生成一条全新的 Undo Log假设其物理地址为 0x00BB。这条新日志的内部会完美保留它复制出来的旧指针 0x00AA引擎修改 B 树叶子节点中的当前行把 balance 改为 600当前行的隐藏列再次刷新DB_TRX_ID 变为70DB_ROLL_PTR 指向最新的 Undo 日志地址0x00BB经历了上述动作主键为 1 的张三这一行记录在 InnoDB 底层实际上已经演变成了一个单向链表结构从上图我们可以清晰地解构出版本链的几大核心物理特征链表头永远是最新放在 B 树索引页里的那行记录永远代表着数据在内存/磁盘中最实时的现状此时 balance 600被事务 70 占据链表身由 Undo Log 构成随着 DB_ROLL_PTR 指针向后回溯我们可以依次翻出balance 800事务60 以及最古老的 balance 1000事务50无需加锁的原因当一个只读事务在并发乱序环境下想要读取张三的余额时它根本不需要去抢占这行记录的锁。它只需要顺着 DB_ROLL_PTR 构成的链表一路向后直到找到一个它 有资格 看到的历史节点并在内存中把那个节点的数值提取出来即可六、Read View在上一节中我们见证了由隐藏字段和 Undo Log 构成的版本链。然而面对链表上一条记录的多个历史快照当前正在运行的事务究竟如何判断自己应该读取哪一个版本这就需要 InnoDB 存储引擎在 MVCC 中的Read View一致性视图1. 什么是 Read View定义Read View 是一个事务在执行快照读Snapshot Read时由 InnoDB 存储引擎在内存中创建的一个物理快照数据结构它并不复制真实的数据库行数据而是像一台相机在开启的那一瞬间给整个 MySQL 实例中所有当前正在活跃已启动但尚未提交的事务 ID拍一张快照。拿着这张快照当前事务在遍历版本链时就能精准判断出哪些版本是已经落盘哪些版本属于尚未提交2. Read View 的四大核心物理字段为了在底层进行精确切割一个刚生成的 Read View 内部必然包含以下四个核心物理变量m_ids一个核心列表记录了在生成 Read View 的那一瞬间整个 MySQL 系统中正处于活跃状态即已经启动但还没执行 COMMIT 提交的事务 ID 集合。这是判断可见性的核心参照物min_trx_id一个单调边界值代表生成 Read View 时当前系统中所有活跃事务中最小的那个事务 ID。通常情况下它就是 m_ids 列表中的最小值min_trx_id min(m_ids)max_trx_id很多人会想当然地认为它是活跃事务列表里的最大值这是错误的在物理定义上max_trx_id 代表生成 Read View 时系统即将分配给下一个全新事务的 ID 值。也就是说它是 未来边界creator_trx_id记录了创建当前这个 Read View 的事务自身的事务 ID。只有执行写操作DML的事务才会被分配一个真正的数字 ID如果是纯只读事务这个值通常默认为 03. Read View 可见性判断原则有了上述四个核心字段当当前事务去读取某行记录并拿到该记录当前版本的 DB_TRX_ID记为 trx_id时InnoDB 会在底层运行一套区间判断算法为了让这套算法变得直观易懂我们将 min_trx_id 和 max_trx_id 视作两条物理隔离带将整个数据世界划分为过去、现在、未来判断逻辑按照以下四条原则执行自产自销原则命中 creator_trx_id条件如果被读取版本的 trx_id creator_trx_id结论绝对可见。这说明这个历史版本就是当前事务自己亲手修改出来的自己看自己的改动天经地义过去世界原则小于 min_trx_id条件如果被读取版本的 trx_id min_trx_id结论绝对可见。这说明修改该版本的那个事务在当前 Read View 诞生之前就已经完成了提交。它已经属于尘埃落定的历史稳态数据可以安全读取未来世界原则大于等于 max_trx_id条件如果被读取版本的 trx_id max_trx_id结论绝对不可见。这说明修改该版本的事务是在当前 Read View 生成之后才刚刚开启的。对于当前的 Read View 而言它属于未来的未知世界因此必须直接拒绝顺着版本链继续向后回溯寻找更老的版本混沌空间原则介于 min_trx_id 与 max_trx_id 之间条件如果被读取版本的 min_trx_id trx_id max_trx_id判决工作流此时需要拿着这个 trx_id 去跟活跃事务列表 m_ids 进行比对情况 A如果 trx_id存在于m_ids 列表中。说明在当前 Read View 诞生时修改这行数据的事务还处于 活跃/未提交 状态。根据隔离性【不可见】。必须向后回溯版本链情况 B如果 trx_id不存在于m_ids 列表中。说明修改这行数据的事务虽然起步晚ID 比最老的活跃事务大但在当前 Read View 诞生前它已经抢先一步完成了提交。既然已提交那么对当前 Read View 而言就是【可见】的七、MVCC 整体流程MVCC多版本并发控制是指在同一时刻通过维护数据的多个历史版本来实现并发事务的 读-写 操作互不冲突、无需排队排他的一种控制机制在传统的数据库并发设计中为了防止读到未提交的脏数据当一个事务在修改某行时必须对该行加排他锁此时并发的查询事务只能卡死排队等待这种传统的 加锁读 会造成严重的吞吐量瓶颈。而 MVCC 的诞生实现了真正的读写不冲突1. 快照读与当前读在进入 MVCC 的具体执行流之前我们必须首先在代码和底层区分两种完全不同的读取行为。并不是所有的 SELECT 都会走 MVCC 引擎快照读特征描述普通不加锁的 SELECT语句SELECT * FROM t_balance WHERE account_id 1;物理机制快照读完全依赖MVCC驱动。它不需要去抢占任何行锁而是通过 Read View 顺着版本链去读取历史快照当前读特征描述凡是需要显式加锁的读取指令或者涉及到数据变更的 DML 语句都属于当前读SELECT * FROM t_balance WHERE account_id 1 FOR SHARE; -- 共享锁 SELECT * FROM t_balance WHERE account_id 1 FOR UPDATE; -- 排他锁 INSERT ... / UPDATE ... / DELETE ... -- 必须读取最新行进行修改物理机制当前读完全不走 MVCC 版本链。它的执行引擎会强制穿透到 B 树的叶子节点去读取当前最新、最实时的那行记录。如果那行记录正好被别的事务锁住了当前读事务就会立刻进入阻塞锁等待状态3. 快照读的 MVCC 工作流现在让我们完整拆解一个普通的 SELECT 指令在底层的 MVCC 寻址算法链路生成 Read View 事务发起快照读。InnoDB 瞬间在内存中为该会话生成或复用一个 Read View记录下当前全系统的活跃事务快照定位最新记录 通过 B 树索引结构快速定位到该行数据在磁盘或 Buffer Pool 里的聚簇索引叶子节点。此时拿到的是链表头部的最新版本数据获取事务 ID 读取当前最新版本记录头部的隐藏字段 DB_TRX_ID运行可见性算法 拿着这个 DB_TRX_ID 与 Read View 进行判断可见说明这个版本是安全的、已提交的。直接将该版本的字段内容打包返回给客户端当前快照读平稳结束不可见说明这个最新版本是个尚未提交的脏幻象或者是在 未来世界 产生的顺指针回溯若是不可见执行引擎通过隐藏字段 DB_ROLL_PTR 提取出指向 Undo Log 的物理指针顺着版本链滑行到上一个更老的数据版本回滚 到达上一个历史版本后再次提取其对应的 DB_TRX_ID重新返回到步骤 4再次判断。直到在 Undo Log 版本链中找到一个符合规则、确认可见的历史节点将其拼装成结果返回(注如果一整条链到了尽头还是全不可见则证明这行数据在当前事务开启时根本不存在返回空结果)八、RC 与 RR 的区别通过前面对 MVCC 的拆解我们明白了一个核心逻辑事务可见性的判决完全取决于Read View 内部的四个核心变量然而在 MySQL InnoDB 存储引擎中读已提交Read Committed简称 RC与可重复读Repeatable Read简称 RR这两个隔离级别的本质区别既不在于 Undo Log 格式的不同也不在于隐藏字段的差异而仅仅在于快照读生成 Read View 的时机不同1. Read View 生成时机对比RC读已提交的生成规则在 RC 隔离级别下一个事务内部的防线是随动且短寿的物理行为事务每执行一条普通的 SELECT快照读语句执行引擎都会在底层擦除旧的视图并重新生成一个新的 Read View本质这意味着在同一个事务里每一次独立的查询都在给当时当刻的全实例活跃事务做最新检测RR可重复读的生成规则在 RR 隔离级别下一个事务内部的防线则是稳固且长寿的物理行为当事务启动后只有在执行第一条 SELECT快照读语句时系统才会生成一个 Read View。在这个事务随后的生命周期里无论它再执行多少次查询都会复用第一次生成的 Read View本质后续的所有可见性算法都是基于开启第一枪时的系统状态做判决2. 为什么 RC 会出现不可重复读我们用具体的事务时序流转来复盘 RC 的物理缺陷初始状态张三的余额为 1000 元事务 A 开启执行第一次查询SELECT balance FROM t_balance WHERE id 1此时生成Read View 1。假设当前系统没有其他活跃事务张三行的 DB_TRX_ID 属于历史已提交世界算法判决可见返回 1000 元并发事务 B 启动执行UPDATE t_balance SET balance 800 WHERE id 1 并且立刻执行了 COMMIT假设事务 B 的 ID 为 105事务 A 再次执行相同的查询。由于是 RC 级别底层立刻废弃 Read View 1强行生成Read View 2此时执行引擎去扫描版本链表头最新值 balance 800DB_TRX_ID 105在重新生成的Read View 2看来事务 105 已经不在活跃列表 m_ids 中了因为已经提交。根据混沌空间的判决规则不在活跃列表中即代表已提交可见算法判断【可见】结果事务 A 顺利读到了最新的 800 元。同一事务内前后两次读取结果发生了改变不可重复读就此诞生3. 为什么 RR 能解决不可重复读同样的场景切回 MySQL 默认的 RR 隔离级别事务 A 开启执行第一次查询。生成Read View 1记录当前即将分配的下一个事务 ID max_trx_id 101。算法判决可见返回 1000 元并发事务 B 启动被系统分配事务 ID 105将余额修改为 800 元并COMMIT事务 A 再次执行相同的查询。由于是 RR 级别底层拒绝创建新视图直接沿用最初的 Read View 1底层判断链引擎扫描到最新行balance 800, DB_TRX_ID 105拿着 105 去比较Read View 1发现105 max_trx_id(101)根据未来世界原则大于等于 max_trx_id 的版本属于未来不可见引擎顺着 DB_ROLL_PTR 物理指针向后滑行找出上一代 Undo Logbalance 1000。再次判断符合可见性规则直接返回 1000 元结果无论事务 B 提交了多少次在旧 Read View 下事务 A 前后读取到的数值永远是恒定的 1000 元。不可重复读被完美解决4. RR 是否完全解决幻读教科书通常会写RR 级别无法解决幻读只有 Serializable 才能解决。但这句话在 MySQL 的 InnoDB 中并不完全准确标准结论MySQL InnoDB 在 RR 隔离级别下通过 MVCC 与 锁机制已经基本上99%解决了幻读。但在极少数特定的业务代码依然会发生幻读场景一普通的快照读 —— 完美解决如果事务 A 在整个生命周期内全部采用普通的 SELECT 指令。由于 RR 级别下 Read View 是不会改变后续无论别的并发事务新 INSERT 了多少条幽灵记录这些新记录的 DB_TRX_ID 都会大于当前 Read View 的 max_trx_id属于未来世界。MVCC 版本链会把它们全部过滤抹去因此在纯快照读下绝对不会发生幻读场景二加锁的当前读 —— 完美解决如果事务 A 一开启就执行 FOR UPDATE。此时InnoDB 会使用间隙锁和临键锁。 它会把物理磁盘空隙全部死死封锁锁住。此时并发事务 B 尝试执行 INSERT 插入新用户时其线程会直接卡死在锁等待上。写被成功阻断当前读下的幻读也被完美防御。既然两种读取方式都能防御幻读究竟在什么时候才会发生它发生于在同一个事务里混用了 快照读 与 当前读/写操作 的链路中-- 步骤 1事务 A 开启执行纯快照读查询 account_id 99 的用户。 SELECT * FROM t_balance WHERE account_id 99; -- 结果此时表中无此人返回【空结果】 -- 步骤 2并发事务 B 瞬间开启插足进来悄悄插入了 account_id 99 的行并立刻 COMMIT 提交 -- 步骤 3事务 A 莫名其妙地执行了一条全表更新或者针对该行的 UPDATE当前读操作 UPDATE t_balance SET balance 500 WHERE account_id 99; -- 因为 UPDATE 属于当前读它会穿透 MVCC 看到事务 B 刚刚提交的真实最新行 -- 于是这条 UPDATE 居然神奇地执行成功了影响行数显式为Affected rows: 1 -- 更致命的是随着 UPDATE 的成功执行这一行记录头部的隐藏字段 DB_TRX_ID 被强制修改为了当前事务 A 的 ID -- 步骤 4事务 A 再次执行快照读查询该行 SELECT * FROM t_balance WHERE account_id 99;原本在步骤 1 中完全不存在的 account_id 99 的行居然在步骤 4 里面凭空冒了出来在步骤 4 执行可见性判断时执行引擎去检查这一行的 DB_TRX_ID发现它正好等于当前事务自身的creator_trx_id因为步骤 3 里自己刚刚改过它。根据自产自销原则当前事务自己改的数据绝对可见。于是MVCC 被攻破幽灵行彻底显形这就是在 MySQL InnoDB 中RR 级别无法完全利用 MVCC 屏蔽幻读的唯一漏洞。因此在工程实践中我们必须遵循一条原则在一个事务的生命周期内应当保持读取风格的纯粹切勿将快照读与当前读盲目混用总结综上所述我们深入分析了 MySQL 事务隔离背后的底层实现机制从脏读、不可重复读、幻读等并发问题出发理解了不同事务隔离级别产生的原因以及它们之间的区别。随后我们又进一步学习了 Undo Log、版本链、Read View 以及 MVCC 等核心技术并将它们串联起来完整理解了事务一致性读的实现流程至此我们已经能够回答事务中的许多经典问题为什么事务可以回滚、为什么普通 SELECT 不需要加锁也能读取历史数据以及为什么 Read Committed 和 Repeatable Read 会表现出不同的隔离效果不过在实际开发中除了事务之外MySQL 还提供了一种十分重要的数据库对象——视图View。视图本身并不存储数据却能够像一张普通的数据表一样参与查询它不仅能够简化复杂 SQL还能够提高数据安全性和代码复用性因此在下一篇中我们将正式学习 MySQL 视图的相关内容包括视图的创建、修改、更新特性以及实际应用场景进一步完善 MySQL 数据库对象体系