ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【1】MySQL MVCC 与隔离级别

【1】MySQL MVCC 与隔离级别

配套视频:在 B 站观看

在 InnoDB 中,一条记录被修改后,查询仍然可能读到修改前的值。MySQL 通过多版本并发控制(MVCC)保留和读取不同版本,让查询与修改可以并发进行。

本文以 MySQL 8.4 的 InnoDB 为例,主要讲三个问题:

  • 旧版本怎么还原?看 Undo 日志和版本链。
  • 哪些版本能读?看 Read View 中的事务号和活跃列表。
  • 读已提交(RC)和可重复读(RR)为什么读出不同结果?看两者建立 Read View 的时机。

图 1:本文的三部分内容。先讨论普通 SELECT,更新和加锁读取放在后面单独说明。

先看一个价格修改的例子

一件商品原价128 元,有两个事务同时操作这条记录:

  • 事务 2(内部事务号12)先把价格修改为100 元,暂时不提交。
  • 事务 1随后查询这件商品的价格。

图 2:查询发生时,事务 2 还没有提交。

用两个独立连接分别执行这两个事务。商品的初始记录已经提交,价格为 128 元;不同隔离级别各演示一轮。图中的内部事务号是示意编号,不是运行 SQL 后固定得到的值。

事务 2 先修改:

-- 事务 2STARTTRANSACTION;UPDATEproductsSETprice=100WHEREid=1;-- 此时还没有执行 COMMIT

事务 1 使用另一个连接。先设置为读已提交,再开启事务、查询价格:

-- 事务 1 所在连接SETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;STARTTRANSACTION;SELECTpriceFROMproductsWHEREid=1;

这次查询返回128 元。事务 2 虽然已经把价格修改为 100 元,但还没有提交,读已提交下的这次查询不能读取这个版本。

如果换成读未提交,按相同顺序另做一轮,事务 1 就允许读到 100 元。此时事务 2 仍然可以回滚,把价格恢复为 128 元;事务 1 却已经读到了这笔未提交的修改,这就是脏读。两种行为的区别见 官方隔离级别说明。

重做实验前,先结束上一轮事务并恢复初始价格。读未提交使用READ UNCOMMITTED,可重复读使用REPEATABLE READ,都在事务开始前设置;具体语法见 SET TRANSACTION。

回到读已提交的这次查询:记录里的价格已经是 100 元,要返回 128 元,就需要找到修改前的版本。

旧版本是怎么还原出来的?

InnoDB 修改记录时,会把还原旧值所需的信息写入Undo 日志。查询需要旧版本时,就利用这些信息重建修改前的记录。

这里涉及聚簇索引记录中的两个隐藏字段:

  • DB_TRX_ID:写出这个版本的事务编号。
  • DB_ROLL_PTR:回滚指针,指向还原旧版本所需的 Undo 记录。

事务 2 的内部事务号是12,100 元版本上的修改事务号也就是 12。原来的 128 元版本由已经提交的事务 7 写出。

这里,“事务 1、事务 2”是两个操作角色的名称,12、7 才是判断版本可见性时要比较的内部事务号。

图 3:同一条商品记录的两个逻辑版本。沿回滚指针,利用 Undo 信息还原出 128 元的旧版本。

一条记录被修改多次,就可以继续还原更早的版本,这就是版本链。

图中的两张卡片表示同一条商品记录的两个逻辑版本。InnoDB 保存还原旧值所需的信息,在查询需要时重建旧版本,无须为每次修改复制整张表。

前面 RC 下的普通SELECT属于一致性非锁定读,也常称为快照读。只要还有相关一致性读需要这些旧版本信息,后台就不能将其清理。InnoDB 多版本机制介绍了这条重建路径和清理条件。

Read View 里记录了什么?

沿着版本链可以找到旧版本,但旧版本不一定都能读。查询还需要一份判断依据,这就是Read View。

它记录建立视图时的事务状态。查询把版本上的修改事务号与这些状态比较,判断这个版本对本次读取是否可见。

Read View 用来判断版本可见性,不保存整份数据,也不缓存上次的查询结果。

沿用刚才的商品例子。事务 1 建立 Read View 的时候:

  • 修改价格的事务 2,内部事务号是12,此时还没提交。
  • 另一个事务的内部事务号是10,在修改其他记录,也还没提交。
  • 下一个待分配的事务号是13。

这份视图中的活跃列表就是[10, 12],两个事务号边界是 10 和 13。

图 4:建立视图时的状态。后面判断版本是否可见,都以这个时刻为准。

和可见性判断直接相关的字段有四个:

  • m_creator_trx_id:创建者事务标识。用来识别当前事务自己的修改。自己写出的版本,自己可以看到。
  • m_ids:建立视图时,其他活跃读写事务的编号列表。本例是[10, 12]。创建者自己的编号不放在名单里,自己的修改由m_creator_trx_id单独识别;名单也不会收录所有尚未结束的只读事务。
  • m_up_limit_id:较小的边界。本例是 10。比它更小的修改事务号,对应的版本可见。
  • m_low_limit_id:较大的边界。本例是 13。修改事务号不小于它的版本,对这份视图不可见。

up和low的名字容易记反。这里按数值大小,把 10 称为下界,把 13 称为上界。本例事务 1 只有查询,没有自己的修改版本。

字段名称与判断顺序,可以对照 MySQL 8.4.0 的 ReadView 定义;活跃列表的构造见 copy_trx_ids。

用这份视图,判断 100 元和 128 元能不能读

当前事务自己写出的版本可以直接看到。对于其他事务的修改,需要结合边界和活跃列表判断。

商品记录上要比较的两个版本,分别是编号 12 写出的 100 元和编号 7 写出的 128 元。对照视图中的边界和名单,判断它们能不能读。

图 5:同一份 Read View 下,7 和 11 可见,10、12、13 不可见。

小于 10:128 元版本可见

128 元版本的修改事务号是7,小于下界 10。建立视图时,仍活跃的最小读写事务号是 10,事务 7 已经提交,所以可以读取它写出的 128 元。

不小于 13:对这份视图不可见

建立视图时,编号 13 还没有分配。使用 13 或更大事务号写出的版本,都不属于这份视图能够读取的版本。

即使这些事务后来提交了,对这份旧视图来说仍然不可见。

10 到 12 之间:还要查活跃列表

100 元版本的修改事务号是12,落在两个边界之间。这时只比较大小不够,还需要查[10, 12]这份活跃列表。

  • 11 不在列表里,说明建立视图时它已经不再活跃,写出的已提交版本可见。
  • 12 在列表里,说明当时还没提交,写出的版本不可见。
  • 10 也在列表里,同样不可见。

11 和 13 都不在活跃列表中,却不能都按可见处理:11 可见,13 不可见。这也是为什么要先比较边界,再查活跃列表。

把这套判断用到商品记录上,查询会按下面的顺序进行:

  1. 检查100 元版本。它的修改事务号是 12,还在活跃列表里,跳过。
  2. 沿 Undo 还原到128 元版本。它的修改事务号是 7,小于下界 10,可以读。

因此,这次查询返回128 元。

RC 和 RR 为什么会读到不同结果?

两者都会用 Read View 判断版本可见性,区别是建立视图的时机。

读已提交:每次一致性读建立新视图

第一次查询时,事务 2 尚未提交,事务 1 读到 128 元。随后事务 2 提交,事务 1 还没有结束,又查了一次价格。

第二次查询会建立新的 Read View。编号12早已分配,小于这份新视图的上界;事务 2 也已经提交,12 不在新的活跃列表里,因此可以读到它写出的100 元。

同一个事务,两次查询同一条记录,结果从 128 元变成 100 元,这就是不可重复读。

可重复读:沿用首次一致性读的视图

把价格恢复为 128 元,重新开始两个事务,按相同顺序再做一轮:

  1. 事务 2 修改价格为 100 元,暂时不提交。
  2. 事务 1 第一次查询,建立 Read View,返回 128 元。
  3. 事务 2 提交。
  4. 事务 1 再次查询,仍然返回128 元。

图 6:两轮演示的操作顺序相同。每轮的两次查询都在同一个事务 1 中,事务 2(编号 12)在两次查询之间提交。

RR 通常沿用当前事务首次一致性读建立的 Read View。第一次查询时,事务 2 还没提交,编号 12 因此出现在活跃列表中。这份名单记录的是建立视图那一刻的状态,不会随着事务 2 提交而更新。

第二次查询继续使用这份视图,编号 12 对应的 100 元仍然不可见,于是沿 Undo 读到 128 元。这次查询仍然访问数据库,只是沿用了原来的可见性判断依据。

等事务 1 结束,再开启新事务、建立新视图,就能读到已提交的 100 元。

这里还要区分“开启事务”和“建立快照”:普通START TRANSACTION不会立刻创建快照,本例的 Read View 建立在第一次一致性读时。具体规则见 一致性非锁定读。

补充:哪些读取不能直接套用这套规则

前面比较的是 RC 和 RR 下的普通SELECT,也就是这里讨论的快照读。加锁读取和可串行化下的普通查询,需要另外判断。

普通查询与加锁读取

下面两条语句虽然都在查询价格,但第二条会请求加锁:

-- 普通查询(快照读)SELECTpriceFROMproductsWHEREid=1;-- 加锁读取SELECTpriceFROMproductsWHEREid=1FORUPDATE;

仍用前面的两个连接和显式事务。事务 2 已把价格修改为 100 元,但还没提交;事务 1 没有修改过这条记录,此时执行SELECT ... FOR UPDATE,会等待事务 2 释放冲突的锁。

事务 2 提交后,如果期间没有其他事务修改这条记录,事务 1 成功获得锁就会读到100 元,而不是沿旧视图返回 128 元。具体规则见 加锁读取。

UPDATE、DELETE也有各自的处理规则,不能直接套用前面两次快照读的结果。

另外,事务能够看到自己的修改。因此,同一事务中混合使用快照读、加锁读取和更新时,要分别判断版本可见性、自身写入和锁冲突。官方文档说明了这些边界。

可串行化下的普通 SELECT

仍用这件商品,不过先把价格恢复为 128 元,隔离级别设为可串行化,并且关闭自动提交。

这次让事务 1 先查询。在这个条件下,InnoDB 会把普通SELECT隐式转换成共享加锁读取。

事务 1 还没有结束,事务 2 就来修改同一条记录。这时出现锁冲突,事务 2 需要等待,直到事务 1 结束并释放相应的锁。

图 7:先读后写时的等待过程。实际加锁范围还取决于索引和查询条件。

上面的过程以关闭自动提交为前提。如果自动提交开启,而且没有显式开启事务,普通SELECT自成一个单语句只读事务,可以按一致性非锁定读执行。

但即使连接开启了自动提交,也可以用START TRANSACTION或BEGIN开启多语句事务;事务期间不会逐条自动提交,不能再套用上面的单语句例外。具体条件见 SERIALIZABLE 的说明和 事务与自动提交。

总结

同一件商品从 128 元修改为 100 元后,查询能读到哪个值,取决于三件事:

  • Undo 和版本链提供还原旧版本的信息。
  • Read View判断某个版本对这次读取是否可见。
  • RC 与 RR采用不同的视图创建时机:前者每次一致性读建立新视图,后者通常复用首次一致性读的视图。

因此,RC 下第二次查询可以看到刚提交的 100 元;RR 下第二次查询仍可能看到 128 元。这个比较针对普通快照读,更新、加锁读取和自身修改需要分别处理。


作者:风火嘟嘟。本文由同题视频整理为图文;观看视频。图中的事务编号和价格用于机制演示。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进