撕开表象:MySQL MVCC、Undo Log 与 Read View 实现原理深度解析

本文系统介绍了 MySQL InnoDB 中 MVCC(多版本并发控制)的工作机制,包括快照读与当前读的区别、MVCC 解决的典型问题(读写不阻塞、降低死锁、实现一致性读),以及 InnoDB 通过 undo log 版本链与 Read View 来判断「当前事务能看到哪一个数据版本」的完整过程。通过多组事务时序示例,文章直观展示了在可重复读与读已提交隔离级别下,事务为何能看到不同的数据版本,从而帮助读者从实现层面真正理解 MySQL 的事务隔离与并发控制。

post

Vitah Lin

12 min read
0

/

介绍

什么是MVCC

MVCC 英文全称是 Multiversion Concurrency Control,即多版本并发控制技术。

多版本并发控制是通过保存数据在某个时间点的快照来实现并发控制的。不管事务执行多长时间,事务内部看到的数据是不受其它事务影响的,根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。

简单来说,多版本并发控制的思想就是保存数据的历史版本,通过对数据行的多个版本管理来实现数据库的并发控制。这样我们就可以通过比较版本号决定数据是否显示出来,读取数据的时候不需要加锁也可以保证事务的隔离效果。

MySQL 的大多数事务型存储引擎实现的都不是简单的行级锁。基于提升并发性能的考虑,它们一般都同时实现了 MVCC。不仅是 MySQL,包括 Oracle、PostgreSQL 等其他数据库系统也都实现了MVCC,但各自的实现机制不尽相同,因为 MVCC 没有一个统一的实现标准,典型的有乐观(optimistic)并发控制和悲观(pessimistic)并发控制。

MySQL中的MVCC

MySQL 在可重复读隔离级别下为了保证事务的隔离性,同样的 SQL 查询语句在一个事务里多次执行查询结果相同,就算其他事务对数据有修改也不会影响当前事务 SQL 的查询结果。这个隔离性就是靠 MVCC 机制来保证,对一行数据的读和写两个操作默认是不会通过加锁互斥来保证隔离性,避免来频繁加锁互斥,而在串行化隔离级别为了保证较高的隔离性是通过将所有操作加锁互斥来实现的。

MVCC 解决了什么问题

1. 读写之间阻塞的问题

通过 MVCC 可以让读写互相不阻塞,即读不阻塞写,写不阻塞读,这样可以提升事务并发处理能力。

提高并发的演进思路:

  • 普通锁,只能串行执行。
  • 读写锁,可以实现读读并发。
  • MVCC,可以实现读写并发。

2. 降低死锁概率

InnoDB 的 MVCC 采用了乐观锁的方式,读取数据时不需要加锁,对于写操作,也只锁定必要的行。

3. 解决一致性读的问题

一致性读也被称为快照读,当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结果,而不能看到这个时间点之后事务提交的更新结果。

快照读和当前读

快照读

像不加锁的 select 操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。

之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于 MVCC。可以认为MVCC 是行锁的一个变种,它在很多情况下,避免了加锁操作,降低了开销。

快照读是一种一致性不加锁的读,是 InnoDB 并发高的核心原因之一。这里一致性指事务读到的数据,要么是事务开始前就已经存在的数据,要么是事务自身插入或修改的数据。快照读读到的并不一定是数据的最新版本,有可能是之前的历史版本,毕竟 MVCC 就是多版本并发控制技术。

当前读

select lock in share mode(共享锁)select for updateupdateinsertdelete 这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

当前读/快照读和 MVCC 的关系

  • MVCC 多版本并发控制指的是”维持一个数据的多个版本,使得读写操作没有冲突”这么一个概念。仅仅是一个理想概念。
  • 而在 MySQL 中,实现这么一个 MVCC 理想概念,我们就需要 MySQL 提供具体的功能去实现它,而快照读就是 MySQL 为我们实现 MVCC 理想模型的其中一个具体非阻塞读功能。而相对而言,当前读就是悲观锁的具体功能实现。
  • 要说的再细致一些,快照读本身也是一个抽象概念,再深入研究。MVCC 模型在 MySQL 中的具体实现则是由 undo log日志和 Read View 去完成的,

InnoDB MVCC原理

undo log 日志版本链

在 MySQL 的数据表,存储着一行行的数据记录,对每行数据而言,不仅仅记录自定义的字段值,还会有额外两个字段 row_trx_id 和 roll_pointer,前者表示更新本行数据的事务 ID,后者表示回滚指针,它指向该行数据的上一个版本的 undo log

对于每行有两个隐藏的字段,在《高性能MySQL》第三版的 13 页中也把它们叫做数据的更新时间和过期时间,这两个字段存储的不是真实时间,而是事务的版本号。

当我们进行数据的新增、删除、修改操作时,会写 redo log(解决数据库宕机重启丢失数据的问题)和 binlog(主要用来做复制、数据备份等操作),另外还会写 undo log,它是为了实现事务的回滚操作

每一条 undo log 的日志会记录对应的事务 ID,还会记录当前事务将数据修改后的最新值,以及指向当前行数据上一个版本的 undo log 的指针,也就是 roll_pointer。

为了方便理解,每一行 undo log 可以简化为如下所示的结构:

image.png

假设,现在有一个事务A,事务ID为10,向表中插入了一条数据,数据为A,那么此时对应的 undo log 如下所示:

image.png

因为是新插入的数据,所以这行数据是第一个版本,也就是它没有上一个版本,所以 roll_pointer 指针为 null

接着事务 B trx_id=20,将这行数据的值修改为 B,同样也会记录一条 undo log,它的 roll_pointer 指针会指向上一个版本的 undo log,也就是指向事务 A 写入的那一行 undo log,如下图所示:

image.png

再接着,事务 C trx_id=30,将这行数据值修改为 C,对应图如下:

image.png

只要有事务修改了这一行的数据,那么就会记录一条对应的 undo log,一条 undo log 对应这行数据的一个版本,当这行数据有多个版本时,就会有多条 undo log 日志,undo log 之间 roll_pointer 指针连接,我们把这个称为 undo log 版本链,版本链的头节点就是当前记录的值。

维护了 undo log 链后,不同的事务 ABC 肯定不是说能看到全部的 undo log 链数据,接下来 MySQL 就需要判断每个事务能看到的 data 数据的是什么,这就需要 ReadView 来配合了。

ReadView(一致性视图)

在 MySQL 的 InnoDB 存储引擎中,ReadView(一致性视图)是实现 MVCC 的核心机制,它与 undo log 紧密配合,确保了事务隔离性。

简单来说:

  • Undo Log 负责存储数据行被修改的历史版本。
  • Read View 负责判断当前事务能够看到数据行的哪个历史版本

对于使用 READ UNCOMMITTED 隔离级别的事务来说,直接读取记录的最新版本就好了,对于使用 SERIALIZABLE 隔离级别的事务来说,使用加锁的方式来访问记录。对于使用 READ COMMITTEDREPEATABLE READ 隔离级别的事务来说,就需要用到我们上边所说的版本链了,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。

ReadView 内容

在可重复读隔离级别,当事务开启时,执行任何查询 SQL 时会生成当前事务的一致性视图 ReadView,该视图在事务结束之前都不会变化(如果是读已提交隔离级别在每次执行查询 SQL 时都会重新生成),这个Read View 中主要包含这些东西:

  • m_ids,当前系统中所有的活跃事务 ID,活跃事务指的是当前系统中开启了事务,但是还没有提交的事务;
  • min_trx_id,m_ids 中最小的,活跃事务的最小 ID;
  • max_trx_id,当前系统中事务的 ID 值最大的那个事务 ID 值再加 1,也就是系统中下一个要生成的事务 ID;
  • creator_trx_id,表示生成该当前事务 ID。

事务里的任何 SQL 查询结果都需要从版本链里的最新数据开始逐条跟 Read View 做对比从而得到最终的快照结果。

版本链对比规则

  1. 如果 row_trx_id 等于当前事务的 ID,那表示这条数据就是当前事务修改的,能读取到。
  2. 如果 row_trx_id < min_trx_id,表示这条数据是在当前事务开启之前,其他事务就已经将这条数据修改了并提交了,所以当前事务能读取到。
  3. 如果 row_trx_id >= max_trx_id ,表示在当前事务开启以后,过了一段时间,系统中有新的事务开启了,新的事务修改了这行数据的值并提交了事务,所以当前事务肯定是不能读取到的,因此这是后面的事务修改提交的数据,不能读到数据。
  4. 如果当前数据的 row_trx_id 处于 min_trx_id <= row_trx_id < max_trx_id 的范围之间,说明这个行记录最近一次更新的事务可能是活跃列表中的事务也可能是已经成功提交的事务(事务ID 号大的事务可能会比 ID 号小的事务先进行提交),比如说初始时有 5 个事务在并发执行,事务ID 分别是 1001~1005,1004 事务完成提交,1001 事务进行普通 select 的时候创建的 read-viewm_ids 就是 [1002, 1003, 1005] 。因此 min_trx_id 就是 1002, max_trx_id 就是 1006。对于这种情况,我们需要在活跃事务列表中进行遍历(因为活跃事务列表中的事务 ID 是有序的,因此用二分查找),确定 row_trx_id 是否在活跃事务列表中。又需要分2种情况:
    1. row_trx_idm_ids 数组中,那么当前事务不能读取到。为什么呢?row_trx_idm_ids 数组中表示的是和当前事务在同一时刻开启的事务,修改了数据的值,并提交了事务,所以不能让当前事务读取到;
    2. row_trx_id 不在 m_ids 数组中,那么当前事务能读取到。row_trx_id 不在 m_ids 数组中说明这个行记录最近一次更新的事务是在创建快照之前提交的事务,此行记录对当前事务是可见的,也就是说当前事务有资格访问此行记录。

事务ID分配规则

命令begin/start transaction 并不是一个事务的起点,在执行到它们之后的第一个修改操作 InnoDB 表的语句, 事务才真正启动,才会向 MySQL 申请事务 ID,MySQL 内部是严格按照事务的启动顺序来分配事务 ID 的。只读事务不分配 trx_id(会分配一个假trx_id),只有涉及到数据更新才会分配事务 ID。可以参考:MySQL的事务ID分配时机

示例1-查询其他事务修改的数据

接下里我们用一个示例来详细说明版本链的计算规则。

表格如下,列表示不同的事务,从上到下是执行顺序,然后我们来分析 select1 和 select2 查询的结果到底会是什么?

执行顺序事务100事务200事务300select1select2
1begin;begin;begin;begin;begin;
2update test set c1 = '123' where id =1;
3update test set c1 = '666' where id =5;
4update account set name = 'a' where id = 1;
commit;
5select name from account where id=1
6update account set name = 'b' where id = 1;
7update account set name = 'c' where id = 1;
8select name from account where id=1
9commit;
10update account set name = 'd' where id = 1;
11update account set name = 'e' where id = 1;
12select name from account where id=1select name from account where id=1
13commit;

步骤5:此时,select1ReadView 的 m_ids 值为 [100,200],max_trx_id=301,此时事务 100 和事务 200 都还没提交,事务 300 已经提交,所以当前数据对应的 row_trx_id=300,根本版本链对比规则查询结果 name=a

步骤8:此时,select1 中 ReadView 的 m_ids 值为 [100,200],max_trx_id=301,此时事务 100 和事务 200 虽然更新了数据,都还没提交,事务 300 已经提交,所以当前数据对应的 row_trx_id=300,根本版本链对比规则查询结果 name=a

步骤12:

  • 此时,select1 中 ReadView 的 m_ids 值为 [100,200],max_trx_id=301,此时事务 100 和事务200 都还没提交,事务 300 已经提交,所以当前数据对应的 row_trx_id=300,根本版本链对比规则,查询结果 name=a
  • select2 的 ReadView m_ids=[200],min_trx_id=200,max_trx_id=301,事务100已经提交,row_trx_id=100,根据对比规则,结果 name=c

示例2-事务同时修改数据

上述的示例展示了其他事务修改数据时,当前事务的查询结果影响。我们还需要考虑 2 个事务同时修改数据时的情况,继续举例说明。

步骤1,原始数据

假设表中有一条数据,它的 rox_trx_id=10roll_pointer=null,那么此时 undo log 的版本链就是这样:

image.png

步骤2,事务A和B创建

现在有事务 A 和事务 B 并发执行,事务 A 的事务 ID 为 20,事务 B 的事务 ID 为 30。那么对于事务 A 来说,它的 Read View 如下:

m_idsmin_trx_idmax_trx_idcreator_trx_id
[20,30]203120

此时事务 A 去读取数据,在 undo log版本链中,数据的最新版本的事务 ID 是 10,这个值小于事务 A的 Read Viewmin_trx_id 的值,表示这个数据的版本是事务 A 开启之前其他事务提交的,因为事务 A 可以读取到,读取到的值是 data0。

image.png

步骤3,事务 B 修改数据

接着,事务 B 去修改数据,将数据修改为 dataB,先不提交事务。虽然不提交事务,但是仍然会记录 undo log,现在的版本链变为如下所示,新的 undo logroll_pointer 指针会指向前一条 undo log

image.png

步骤4,事务A读数据

事务A read-view

m_idsmin_trx_idmax_trx_idcreator_trx_id
[20,30]203120

接着,事务A去读取数据,在 undo log 版本链中,数据最新版本的事务 id 为30,这个值处于事务A的 ReadView 中的 min_trx_id 和 max_trx_id 之间,因为还需要判断这个数据版本的值是否在 m_ids 数组中。结果发现,30 确实在 m_ids 数组中,表示这个版本的数组是和自己同一时刻启动的事务修改的,因此这个版本的数据,事务 A 读取不到。

需要沿着版本链向前找,接着会找到该数据的上一个版本 row_trx_id=10,这个版本的 row_trx_id 小于 min_trx_id,因此事务 A 能读取到该版本的值,即事务 A 读取到的值是 data0。此时的版本链如下图:

image.png

步骤5,事务B提交数据,事务A再读取

紧接着事务 B 提交事务,那么此时系统中活跃的事务就只有 id 为 20 的事务了,也就是事务 A

那么此时事务 A 再去读取数据,它能读取到什么值呢?其实还是 data0 ,为什么?

虽然系统中当前只剩下 ID 为 20 的活跃事务了,但是事务 A 开启的瞬间,它已经生成了 ReadView ,后面即使有其他事务提交了,但是事务 A 的 ReadView 不会修改,还是如下:

m_idsmin_trx_idmax_trx_idcreator_trx_id
[20,30]203120

此时事务 A 根据版本链去查找数据,还是只能读到 data0

步骤6,新事务 C 修改数据,事务 A 再读取

此时,新开了一个事物 C,事务ID 为40,它的 Read View 如下:

m_idsmin_trx_idmax_trx_idcreator_trx_id
[20,40]204140

事务C将数据修改为 dataC,并提交事务,此时版本链就变为如下图:

image.png

事务A的 Read View

m_idsmin_trx_idmax_trx_idcreator_trx_id
[20,30]203120

此时,事务 A 再次去读数据,在版本链中最新版本的事务 ID 为 40,比较事务 A 的 read-view,max_trx_id<40,表示当前版本的数据是在事务 A 之后提交的,肯定不能读取到。所以此时事务 A 继续往上 undo log 版本向前找,结果发现上一个版本的 rox_trx_id=30,自己还是不能读取到,再继续往前找,最终可以读取到 rox_trx_id=10 的版本数据,因此最终事务 A 还是只能读取到 data0

步骤7-事务A修改数据后再读取

接着事务 A(trx_id=20) 去修改数据,将数据修改为 dataA,那么就会记录一条 undo log,示意图如下:

image.png

然后事务 A(trx_id=20) 再去读取数据,在 undo log 版本链中,数据最新版本的事务 ID 为 20,事务 A 一对比,发现该版本的事务 ID 与自己的事务 ID 相等,这表示这个版本的数据就是自己修改的,既然是自己修改的,那就肯定能读取到了,此时读取到是 data_A

总结

核心要点回顾

1. MVCC 的价值与目标

MVCC 机制通过提供数据的多个历史版本,有效地解决了数据库并发操作中的典型问题:

  • 读写不阻塞:读操作(快照读)无需等待写锁释放,写操作也无需阻塞读操作。
  • 降低死锁风险:减少了锁的使用范围和持有时间,从而降低了事务间因资源竞争导致的死锁概率。
  • 实现一致性读:保证了事务在特定隔离级别下,读取到的数据在事务生命周期内(或快照生成时)是逻辑一致的。

2. 快照读 vs. 当前读

理解 MVCC 的起点在于区分两种读取方式:

  • 快照读(Snapshot Read):普通 SELECT 语句,它利用 MVCC 机制读取数据的历史版本(即“快照”),是实现高性能并发的基础。
  • 当前读(Current Read):特殊的 SELECT 语句(如 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE),以及 INSERT, UPDATE, DELETE 操作。它们总是读取数据的最新版本,并会加锁以保证数据的修改是正确的。

3. MVCC 的两大支柱:Undo Log 版本链与 Read View

InnoDB 实现 MVCC 依赖于两个关键结构:

  • Undo Log 版本链
    • 每次对数据行进行修改(UPDATEDELETE)时,都会将该行的旧版本数据记录到 Undo Log 中。
    • 通过隐藏字段中的 回滚指针(roll_pointer,这些历史版本被链接起来,形成一条完整的回溯链条,确保了任何历史版本的数据都可以被找回。
  • Read View(一致性读视图)
    • 这是一个由当前事务创建的快照,包含 m_ids(当前活跃的事务 ID 列表)等关键信息。
    • 判断逻辑:当一个事务进行快照读时,它会拿着自己的 Read View 沿着 Undo Log 版本链去遍历,直到找到一个事务 ID 满足可见性规则的行版本,这就是当前事务应该看到的数据。

通过对 MVCC 机制、Undo Log 版本链和 Read View 的系统性理解,我们不仅知道了 MySQL 事务隔离的表象,更掌握了其底层实现。这对于我们编写高效、正确的并发代码,以及排查数据库并发问题至关重要。