当前位置: 首页 > news >正文

Mysql的MVCC是什么

简单来说,MVCC 是一种让数据库在同一时间支持多人读写的技术。它就像给数据库里的每一份数据都保留了多个历史版本,这样,当有人正在修改数据时,其他人仍然可以看到修改前的数据版本,互不干扰。

1. 为什么我们需要 MVCC?

想象一下,你和你的朋友同时在编辑一份共享文档。

  • 没有 MVCC 的世界(悲观锁的世界):当你的朋友打开文档编辑时,文档就会被“锁”起来,你必须等他编辑完并保存后,你才能打开编辑。如果你在等待过程中,你的朋友又去吃了个饭,你就得一直等着。这叫做“悲观锁”,因为它总是假设会发生冲突,所以提前把资源锁住,效率很低。

  • 有 MVCC 的世界(乐观锁的思想):你们俩可以同时打开文档编辑。你看到的是文档当前的最新版本,他看到的是他打开时的那个版本。当你们都修改完保存时,系统会协调处理,比如看看有没有冲突(如果修改了同一个地方),如果没有冲突就各自保存,如果有冲突就提示解决。数据库里的 MVCC 更智能一些,它通过保存多个版本,让读操作几乎不会被写操作阻塞。

核心痛点: 在高并发的数据库系统中,如果读操作和写操作互相等待,效率会非常低下。例如,一个耗时很长的报表查询(读操作)可能会阻塞住一个重要的交易更新(写操作),反之亦然。我们希望读不阻塞写,写不阻塞读。MVCC 就是解决这个问题的关键技术。

2. MVCC 的核心思想:版本化数据

MVCC 的全称是 Multi-Version Concurrency Control,即多版本并发控制。顾名思义,它的核心在于“多版本”。

数据库中的每一行数据,当它被修改时,并不是直接在原地修改,而是会创建一个新的版本。旧的版本会保留下来,而不是立即删除。

你可以把数据库想象成一个时间机器,每一行数据在不同的时间点,都有一个对应的“快照”。

思考:

  • 为什么要保留旧版本?
  • 这些版本是如何区分的?
  • 什么时候旧版本会被清理掉?

3. MVCC 的实现原理:幕后的英雄们

MVCC 并不是一个单一的“功能”,它是一套协同工作的机制。在 MySQL 的 InnoDB 存储引擎中,MVCC 主要依赖于以下几个“幕后英雄”:

  • 隐藏列(Hidden Columns)
  • Undo Log(回滚日志)
  • Read View(读视图)

我们来逐一了解它们扮演的角色。

3.1 隐藏列:行记录的“身份证”和“保质期”

InnoDB 表的每一行记录,除了你定义的那些列(比如 name, age),还会默默地增加几个隐藏列。这些隐藏列就像是给每一行数据附加的“元数据”,记录着它的生命周期信息。

主要的隐藏列有:

  • DB_TRX_ID (Transaction ID):事务 ID。记录了最近一次修改(插入或更新)本行记录的事务 ID。这个 ID 是一个递增的数字,每个新事务都会获得一个更大的 ID。

  • DB_ROLL_PTR (Roll Pointer):回滚指针。指向 Undo Log 中的一条记录。通过这个指针,可以找到该行数据上一个版本的记录(也就是当前版本修改前的样子)。多个版本通过这个指针连接起来,形成一个版本链

  • DB_ROW_ID (Row ID):行 ID。这个是隐式主键,如果表没有定义主键,InnoDB 会自动生成一个。

  • DB_TRX_ID 就像是数据被“出生”或“改造”时的“时间戳”或“事件编号”。

  • DB_ROLL_PTR 就像是通往“前世”的传送门,通过它你可以追溯到数据的所有历史版本。

可视化模拟:数据行的演变

假设我们有一个 users 表:

idnameage(隐藏)DB_TRX_ID(隐藏)DB_ROLL_PTR

Step 1: 插入一条记录

事务 T1 插入一条记录 (1, 'Alice', 25)

idnameageDB_TRX_IDDB_ROLL_PTR
1Alice25T1NULL
  • DB_TRX_ID 是 T1 的事务 ID。
  • DB_ROLL_PTR 为 NULL,因为它没有上一个版本。

Step 2: 事务 T2 更新记录

事务 T2 将 Aliceage 更新为 26

关键点: InnoDB 不会在原地修改!它会:

  1. Undo Log 中,把当前 (1, 'Alice', 25) 这条记录(即旧版本)记录下来。
  2. 在原记录行上,修改 age26,并更新 DB_TRX_IDDB_ROLL_PTR
idnameageDB_TRX_IDDB_ROLL_PTR
1Alice26T2指向 T1 版本的 Undo Log

现在,看起来只有一条记录。但实际上,通过 DB_ROLL_PTR,我们可以回溯到 Undo Log 中 T1 插入的那个版本。

Step 3: 事务 T3 再次更新记录

事务 T3 将 Alicename 更新为 Ali

  1. Undo Log 中,把当前 (1, 'Alice', 26) 这个版本记录下来。
  2. 在原记录行上,修改 nameAli,并更新 DB_TRX_IDDB_ROLL_PTR
idnameageDB_TRX_IDDB_ROLL_PTR
1Ali26T3指向 T2 版本的 Undo Log

版本链形成:

当前行记录
(id=1, name=Ali, age=26)
DB_TRX_ID=T3
DB_ROLL_PTR -> B
Undo Log 记录 (T2)
(id=1, name=Alice, age=26)
DB_TRX_ID=T2
DB_ROLL_PTR -> C
Undo Log 记录 (T1)
(id=1, name=Alice, age=25)
DB_TRX_ID=T1
DB_ROLL_PTR = NULL

从当前最新版本开始,通过 DB_ROLL_PTR 就能像链条一样,一直回溯到数据的最初版本。这就是 MVCC 的版本链(Version Chain)

3.2 Undo Log:记录历史的时光机

Undo Log,顾名思义,是用来回滚(undo)操作的日志。但它在 MVCC 中扮演的角色远不止于此。它实际上是存储旧版本数据的地方。

  • 回滚用途: 当事务需要回滚时,InnoDB 可以根据 Undo Log 中的记录,将数据恢复到事务开始前的状态。
  • MVCC 用途: 当一个读事务需要读取某个数据行的历史版本时,它会沿着 DB_ROLL_PTR 指向的 Undo Log 链条,找到符合其 Read View 条件的旧版本数据。

每次对数据进行 INSERT, UPDATE, DELETE 操作时,都会生成对应的 Undo Log

  • INSERT 对应的 Undo Log:记录新插入行的主键信息,用于回滚时删除该行。
  • UPDATE 对应的 Undo Log:记录被修改行的旧值,以及旧版本的 DB_TRX_IDDB_ROLL_PTR。这正是构建版本链的关键。
  • DELETE 对应的 Undo Log:记录被删除行的所有信息,用于回滚时恢复该行。

思考:

  • Undo Log 会一直增长吗?
  • 旧版本的 Undo Log 什么时候会被清理?

当所有的活跃事务都不再需要某个旧版本的数据时,这些旧版本的 Undo Log 就会被 Purge 线程清理掉。这个清理过程是自动进行的,确保 Undo Log 文件不会无限膨胀。

3.3 Read View:定义事务的“时间维度”

Read View (读视图) 是 MVCC 最精髓的部分。它决定了一个事务在某一时刻能够“看到”哪些数据版本。

当一个事务启动时,它会生成一个 Read View。这个 Read View 就像是给事务拍了一张“快照”,记录了当前活跃的事务 ID 列表

Read View 主要包含以下几个关键信息:

  • m_ids当前活跃的事务 ID 列表。这个列表记录了在生成 Read View 时,所有正在进行但还未提交的事务 ID。
  • min_trx_id (或 up_limit_id):m_ids 中最小的事务 ID。比这个 ID 小的事务,都已经被提交了。
  • max_trx_id (或 low_limit_id):InnoDB 系统中,下一个将被分配的事务 ID(即当前最大的事务 ID 加 1)。比这个 ID 大的事务,都是在 Read View 之后才启动的。
  • creator_trx_id:创建这个 Read View 的事务本身的 ID。

判断规则:事务 T 尝试读取一行记录 R(其 DB_TRX_IDtrx_id_R)时,根据事务 T 的 Read View,会进行如下判断:

  1. trx_id_R < min_trx_id

    • 表示修改该行记录的事务 trx_id_R 在当前事务的 Read View 建立之前就已经提交了
    • 结论: 记录 R 是可见的。
  2. trx_id_R >= max_trx_id

    • 表示修改该行记录的事务 trx_id_R 在当前事务的 Read View 建立之后才启动或提交
    • 结论: 记录 R 是不可见的(因为是未来的修改)。
  3. min_trx_id <= trx_id_R < max_trx_id

    • 表示修改该行记录的事务 trx_id_R 在当前事务的 Read View 建立时是活跃的(或者在 Read View 建立后,但在 max_trx_id 之前提交的)
    • 此时需要进一步判断 trx_id_R 是否在 m_ids 列表中:
      • 如果 trx_id_R m_ids 列表中:说明 trx_id_R 对应的事务在 Read View 生成时是活跃的(或尚未提交),因此不可见。需要沿着 DB_ROLL_PTR 找上一个版本,继续判断。
      • 如果 trx_id_R 不在 m_ids 列表中:说明 trx_id_R 对应的事务在 Read View 生成时已经提交
        • 如果 trx_id_R 等于 creator_trx_id(即当前事务自己修改的),则可见。
        • 否则,该版本是可见的。

总结可见性判断流程:

Yes
No
Yes
No
Yes
No
Yes
No
读取行记录 R
行事务ID: trx_id_R
trx_id_R < min_trx_id ?
可见: 返回该行
trx_id_R >= max_trx_id ?
不可见: 沿着DB_ROLL_PTR
找更旧的版本继续判断
trx_id_R 在 m_ids 中 ?
trx_id_R == creator_trx_id ?

深入思考:

为什么需要 min_trx_idmax_trx_id?它们可以快速排除掉大部分情况。m_ids 是最核心的判断,它精准地描绘了“快照”那一刻的活跃事务。

4. MVCC 与隔离级别

MVCC 并不是适用于所有的事务隔离级别。它主要服务于读已提交 (Read Committed)可重复读 (Repeatable Read) 这两个隔离级别。

  • 读未提交 (Read Uncommitted):直接读取最新版本,不使用 MVCC。可能读到脏数据。

  • 读已提交 (Read Committed, RC):每次 SELECT 语句执行时,都会重新生成一个 Read View。这意味着在一个事务中,两次相同的 SELECT 查询可能会读到不同的数据(如果其他事务在这两次查询之间提交了)。它能避免脏读,但可能出现不可重复读。

  • 可重复读 (Repeatable Read, RR)在一个事务的整个生命周期内,只在事务开始时生成一次 Read View。此后的所有 SELECT 查询都使用这个固定的 Read View。这保证了在同一个事务中,无论查询多少次,看到的数据都是一致的,从而避免了脏读和不可重复读。

    注意:RR 隔离级别下,MVCC 结合间隙锁 (Gap Lock) 才能彻底解决幻读。MVCC 本身只能解决快照读(Snapshot Read)下的幻读,无法解决当前读(Current Read,如 SELECT ... FOR UPDATE)下的幻读。

  • 串行化 (Serializable):强制事务串行执行,读写都会加锁,不使用 MVCC。

总结 MVCC 在不同隔离级别中的表现:

隔离级别Read View 生成时机特点
Read Committed每条 SELECT 语句开始时只能看到已提交的最新版本,可能出现不可重复读。
Repeatable Read事务开始时(第一次读操作时)事务内看到的都是固定快照,不会出现不可重复读。

5. MVCC 的操作流程模拟(以 RR 隔离级别为例)

让我们通过一个具体的例子,模拟 MVCC 在 Repeatable Read 隔离级别下的工作流程。

假设:

  • accounts,初始数据:id=1, balance=100
  • 事务 T1, T2, T3
  • 事务 ID 递增,T1 < T2 < T3

初始状态:
accounts 表中只有一条记录,其 DB_TRX_ID 为 T0(假设是初始化事务 ID)。

idbalanceDB_TRX_IDDB_ROLL_PTR
1100T0NULL

场景模拟:

时间点 A:
事务 T1 启动
SELECT * FROM accounts WHERE id = 1; (这是 T1 的第一次读操作,生成 Read View)

  • T1 的 Read View 建立:

    • m_ids: [] (此时没有其他活跃事务)
    • min_trx_id: 假设为 100 (如果 T0 是 99)
    • max_trx_id: 101 (下一个事务 ID)
    • creator_trx_id: T1 (假设 T1 的 ID 是 100)
  • T1 读判断:

    • 读取行 (id=1, balance=100, DB_TRX_ID=T0)
    • T0 < min_trx_id (T0 < 100) -> 可见
  • 结果: T1 读到 (id=1, balance=100)


时间点 B:
事务 T2 启动
UPDATE accounts SET balance = 150 WHERE id = 1;
T2 提交。

T2 执行过程:

  1. Undo Log 中保存当前行 (id=1, balance=100, DB_TRX_ID=T0)
  2. 更新当前行:id=1, balance=150, DB_TRX_ID=T2, DB_ROLL_PTR 指向 Undo Log 中的 T0 版本。

当前数据行状态:

idbalanceDB_TRX_IDDB_ROLL_PTR
1150T2指向 T0 版本的 Undo Log

版本链:
当前行(T2: balance=150) --> Undo Log(T0: balance=100)


时间点 C:
事务 T3 启动
UPDATE accounts SET balance = 200 WHERE id = 1;
T3 提交。

T3 执行过程:

  1. Undo Log 中保存当前行 (id=1, balance=150, DB_TRX_ID=T2)
  2. 更新当前行:id=1, balance=200, DB_TRX_ID=T3, DB_ROLL_PTR 指向 Undo Log 中的 T2 版本。

当前数据行状态:

idbalanceDB_TRX_IDDB_ROLL_PTR
1200T3指向 T2 版本的 Undo Log

版本链:
当前行(T3: balance=200) --> Undo Log(T2: balance=150) --> Undo Log(T0: balance=100)


时间点 D:
事务 T1 再次执行读操作
SELECT * FROM accounts WHERE id = 1;

  • T1 使用其初始的 Read View

    • m_ids: [] (这个 Read View 是在时间点 A 生成的,当时 T2, T3 还没启动)
    • min_trx_id: 100 (T1 自己的 ID)
    • max_trx_id: 101 (下一个事务 ID)
    • creator_trx_id: T1 (100)
  • T1 读判断:

    1. 读取当前行: (id=1, balance=200, DB_TRX_ID=T3)

      • trx_id_R = T3
      • min_trx_id = 100, max_trx_id = 101
      • T3 >= max_trx_id (T3 肯定比 T1 的 max_trx_id 大,因为 T3 是后来启动的事务) -> 不可见
      • 沿着 DB_ROLL_PTR 找下一个版本。
    2. 查找 Undo Log 中的 T2 版本: (id=1, balance=150, DB_TRX_ID=T2)

      • trx_id_R = T2
      • min_trx_id = 100, max_trx_id = 101
      • T2 >= max_trx_id -> 不可见
      • 沿着 DB_ROLL_PTR 找下一个版本。
    3. 查找 Undo Log 中的 T0 版本: (id=1, balance=100, DB_TRX_ID=T0)

      • trx_id_R = T0
      • min_trx_id = 100
      • T0 < min_trx_id -> 可见
  • 结果: T1 再次读到 (id=1, balance=100)


整个过程可视化:

Yes
Yes
Yes
Yes
事务T1启动
第一次读操作
T1生成ReadView
m_ids=[], min_trx_id=100, max_trx_id=101
读取当前行 (DB_TRX_ID=T0)
T0 < min_trx_id ?
T1看到: (id=1, balance=100)
事务T2启动
更新id=1
balance=150
T2提交
数据行更新
DB_TRX_ID=T2
DB_ROLL_PTR指向T0版本UndoLog
事务T3启动
更新id=1
balance=200
T3提交
数据行再次更新
DB_TRX_ID=T3
DB_ROLL_PTR指向T2版本UndoLog
T1再次读操作
使用T1的旧ReadView
读取当前行 (DB_TRX_ID=T3)
T3 >= max_trx_id ?
沿着DB_ROLL_PTR
找T2版本UndoLog
读取T2版本 (DB_TRX_ID=T2)
T2 >= max_trx_id ?
沿着DB_ROLL_PTR
找T0版本UndoLog
读取T0版本 (DB_TRX_ID=T0)
T0 < min_trx_id ?
T1看到: (id=1, balance=100)
(与第一次读相同)
T1结束
保持可重复读

通过这个模拟,我们可以清楚地看到,即使其他事务在 T1 期间修改并提交了数据,T1 也能通过其固定的 Read View 和版本链,找到它“应该看到”的那个旧版本数据,从而实现了可重复读

6. MVCC 的优点和缺点

优点:

  1. 提高并发性能: 读操作不再需要等待写操作释放锁,写操作也不需要等待读操作。实现了读写并行,大大提高了数据库的并发处理能力。
  2. 避免脏读和不可重复读: 通过 Read View 和版本链,每个事务都能读到一致的数据快照。
  3. 减轻锁的开销: 很多读操作(快照读)不再需要加锁,减少了锁竞争,降低了死锁的风险。
    简单来说,MVCC 是一种让数据库在同一时间支持多人读写的技术。它就像给数据库里的每一份数据都保留了多个历史版本,这样,当有人正在修改数据时,其他人仍然可以看到修改前的数据版本,互不干扰。

让我们从最最基础的概念开始,一步步揭开 MVCC 的神秘面纱。

1. 为什么我们需要 MVCC?

想象一下,你和你的朋友同时在编辑一份共享文档。

  • 没有 MVCC 的世界(悲观锁的世界):当你的朋友打开文档编辑时,文档就会被“锁”起来,你必须等他编辑完并保存后,你才能打开编辑。如果你在等待过程中,你的朋友又去吃了个饭,你就得一直等着。这叫做“悲观锁”,因为它总是假设会发生冲突,所以提前把资源锁住,效率很低。

  • 有 MVCC 的世界(乐观锁的思想):你们俩可以同时打开文档编辑。你看到的是文档当前的最新版本,他看到的是他打开时的那个版本。当你们都修改完保存时,系统会协调处理,比如看看有没有冲突(如果修改了同一个地方),如果没有冲突就各自保存,如果有冲突就提示解决。数据库里的 MVCC 更智能一些,它通过保存多个版本,让读操作几乎不会被写操作阻塞。

核心痛点: 在高并发的数据库系统中,如果读操作和写操作互相等待,效率会非常低下。例如,一个耗时很长的报表查询(读操作)可能会阻塞住一个重要的交易更新(写操作),反之亦然。我们希望读不阻塞写,写不阻塞读。MVCC 就是解决这个问题的关键技术。

2. MVCC 的核心思想:版本化数据

MVCC 的全称是 Multi-Version Concurrency Control,即多版本并发控制。顾名思义,它的核心在于“多版本”。

数据库中的每一行数据,当它被修改时,并不是直接在原地修改,而是会创建一个新的版本。旧的版本会保留下来,而不是立即删除。

你可以把数据库想象成一个时间机器,每一行数据在不同的时间点,都有一个对应的“快照”。

思考:

  • 为什么要保留旧版本?
  • 这些版本是如何区分的?
  • 什么时候旧版本会被清理掉?

别急,我们一步步来。

3. MVCC 的实现原理:幕后的英雄们

MVCC 并不是一个单一的“功能”,它是一套协同工作的机制。在 MySQL 的 InnoDB 存储引擎中,MVCC 主要依赖于以下几个“幕后英雄”:

  • 隐藏列(Hidden Columns)
  • Undo Log(回滚日志)
  • Read View(读视图)

我们来逐一了解它们扮演的角色。

3.1 隐藏列:行记录的“身份证”和“保质期”

InnoDB 表的每一行记录,除了你定义的那些列(比如 name, age),还会默默地增加几个隐藏列。这些隐藏列就像是给每一行数据附加的“元数据”,记录着它的生命周期信息。

主要的隐藏列有:

  • DB_TRX_ID (Transaction ID):事务 ID。记录了最近一次修改(插入或更新)本行记录的事务 ID。这个 ID 是一个递增的数字,每个新事务都会获得一个更大的 ID。
  • DB_ROLL_PTR (Roll Pointer):回滚指针。指向 Undo Log 中的一条记录。通过这个指针,可以找到该行数据上一个版本的记录(也就是当前版本修改前的样子)。多个版本通过这个指针连接起来,形成一个版本链
  • DB_ROW_ID (Row ID):行 ID。这个是隐式主键,如果表没有定义主键,InnoDB 会自动生成一个。

联想:

  • DB_TRX_ID 就像是数据被“出生”或“改造”时的“时间戳”或“事件编号”。
  • DB_ROLL_PTR 就像是通往“前世”的传送门,通过它你可以追溯到数据的所有历史版本。

可视化模拟:数据行的演变

假设我们有一个 users 表:

idnameage(隐藏)DB_TRX_ID(隐藏)DB_ROLL_PTR

Step 1: 插入一条记录

事务 T1 插入一条记录 (1, 'Alice', 25)

idnameageDB_TRX_IDDB_ROLL_PTR
1Alice25T1NULL
  • DB_TRX_ID 是 T1 的事务 ID。
  • DB_ROLL_PTR 为 NULL,因为它没有上一个版本。

Step 2: 事务 T2 更新记录

事务 T2 将 Aliceage 更新为 26

关键点: InnoDB 不会在原地修改!它会:

  1. Undo Log 中,把当前 (1, 'Alice', 25) 这条记录(即旧版本)记录下来。
  2. 在原记录行上,修改 age26,并更新 DB_TRX_IDDB_ROLL_PTR
idnameageDB_TRX_IDDB_ROLL_PTR
1Alice26T2指向 T1 版本的 Undo Log

现在,看起来只有一条记录。但实际上,通过 DB_ROLL_PTR,我们可以回溯到 Undo Log 中 T1 插入的那个版本。

Step 3: 事务 T3 再次更新记录

事务 T3 将 Alicename 更新为 Ali

  1. Undo Log 中,把当前 (1, 'Alice', 26) 这个版本记录下来。
  2. 在原记录行上,修改 nameAli,并更新 DB_TRX_IDDB_ROLL_PTR
idnameageDB_TRX_IDDB_ROLL_PTR
1Ali26T3指向 T2 版本的 Undo Log

版本链形成:

当前行记录
(id=1, name=Ali, age=26)
DB_TRX_ID=T3
DB_ROLL_PTR -> B
Undo Log 记录 (T2)
(id=1, name=Alice, age=26)
DB_TRX_ID=T2
DB_ROLL_PTR -> C
Undo Log 记录 (T1)
(id=1, name=Alice, age=25)
DB_TRX_ID=T1
DB_ROLL_PTR = NULL

从当前最新版本开始,通过 DB_ROLL_PTR 就能像链条一样,一直回溯到数据的最初版本。这就是 MVCC 的版本链(Version Chain)

3.2 Undo Log:记录历史的时光机

Undo Log,顾名思义,是用来回滚(undo)操作的日志。但它在 MVCC 中扮演的角色远不止于此。它实际上是存储旧版本数据的地方。

  • 回滚用途: 当事务需要回滚时,InnoDB 可以根据 Undo Log 中的记录,将数据恢复到事务开始前的状态。
  • MVCC 用途: 当一个读事务需要读取某个数据行的历史版本时,它会沿着 DB_ROLL_PTR 指向的 Undo Log 链条,找到符合其 Read View 条件的旧版本数据。

每次对数据进行 INSERT, UPDATE, DELETE 操作时,都会生成对应的 Undo Log

  • INSERT 对应的 Undo Log:记录新插入行的主键信息,用于回滚时删除该行。
  • UPDATE 对应的 Undo Log:记录被修改行的旧值,以及旧版本的 DB_TRX_IDDB_ROLL_PTR。这正是构建版本链的关键。
  • DELETE 对应的 Undo Log:记录被删除行的所有信息,用于回滚时恢复该行。

思考:

  • Undo Log 会一直增长吗?
  • 旧版本的 Undo Log 什么时候会被清理?

当所有的活跃事务都不再需要某个旧版本的数据时,这些旧版本的 Undo Log 就会被 Purge 线程清理掉。这个清理过程是自动进行的,确保 Undo Log 文件不会无限膨胀。

3.3 Read View:定义事务的“时间维度”

Read View (读视图) 是 MVCC 最精髓的部分。它决定了一个事务在某一时刻能够“看到”哪些数据版本。

当一个事务启动时,它会生成一个 Read View。这个 Read View 就像是给事务拍了一张“快照”,记录了当前活跃的事务 ID 列表

Read View 主要包含以下几个关键信息:

  • m_ids当前活跃的事务 ID 列表。这个列表记录了在生成 Read View 时,所有正在进行但还未提交的事务 ID。
  • min_trx_id (或 up_limit_id):m_ids 中最小的事务 ID。比这个 ID 小的事务,都已经被提交了。
  • max_trx_id (或 low_limit_id):InnoDB 系统中,下一个将被分配的事务 ID(即当前最大的事务 ID 加 1)。比这个 ID 大的事务,都是在 Read View 之后才启动的。
  • creator_trx_id:创建这个 Read View 的事务本身的 ID。

判断规则:事务 T 尝试读取一行记录 R(其 DB_TRX_IDtrx_id_R)时,根据事务 T 的 Read View,会进行如下判断:

  1. trx_id_R < min_trx_id

    • 表示修改该行记录的事务 trx_id_R 在当前事务的 Read View 建立之前就已经提交了
    • 结论: 记录 R 是可见的。
  2. trx_id_R >= max_trx_id

    • 表示修改该行记录的事务 trx_id_R 在当前事务的 Read View 建立之后才启动或提交
    • 结论: 记录 R 是不可见的(因为是未来的修改)。
  3. min_trx_id <= trx_id_R < max_trx_id

    • 表示修改该行记录的事务 trx_id_R 在当前事务的 Read View 建立时是活跃的(或者在 Read View 建立后,但在 max_trx_id 之前提交的)
    • 此时需要进一步判断 trx_id_R 是否在 m_ids 列表中:
      • 如果 trx_id_R m_ids 列表中:说明 trx_id_R 对应的事务在 Read View 生成时是活跃的(或尚未提交),因此不可见。需要沿着 DB_ROLL_PTR 找上一个版本,继续判断。
      • 如果 trx_id_R 不在 m_ids 列表中:说明 trx_id_R 对应的事务在 Read View 生成时已经提交
        • 如果 trx_id_R 等于 creator_trx_id(即当前事务自己修改的),则可见。
        • 否则,该版本是可见的。

总结可见性判断流程:

Yes
No
Yes
No
Yes
No
Yes
No
读取行记录 R
行事务ID: trx_id_R
trx_id_R < min_trx_id ?
可见: 返回该行
trx_id_R >= max_trx_id ?
不可见: 沿着DB_ROLL_PTR
找更旧的版本继续判断
trx_id_R 在 m_ids 中 ?
trx_id_R == creator_trx_id ?

深入思考:

为什么需要 min_trx_idmax_trx_id?它们可以快速排除掉大部分情况。m_ids 是最核心的判断,它精准地描绘了“快照”那一刻的活跃事务。

4. MVCC 与隔离级别

MVCC 并不是适用于所有的事务隔离级别。它主要服务于读已提交 (Read Committed)可重复读 (Repeatable Read) 这两个隔离级别。

  • 读未提交 (Read Uncommitted):直接读取最新版本,不使用 MVCC。可能读到脏数据。

  • 读已提交 (Read Committed, RC):每次 SELECT 语句执行时,都会重新生成一个 Read View。这意味着在一个事务中,两次相同的 SELECT 查询可能会读到不同的数据(如果其他事务在这两次查询之间提交了)。它能避免脏读,但可能出现不可重复读。

  • 可重复读 (Repeatable Read, RR)在一个事务的整个生命周期内,只在事务开始时生成一次 Read View。此后的所有 SELECT 查询都使用这个固定的 Read View。这保证了在同一个事务中,无论查询多少次,看到的数据都是一致的,从而避免了脏读和不可重复读。

    注意:RR 隔离级别下,MVCC 结合间隙锁 (Gap Lock) 才能彻底解决幻读。MVCC 本身只能解决快照读(Snapshot Read)下的幻读,无法解决当前读(Current Read,如 SELECT ... FOR UPDATE)下的幻读。

  • 串行化 (Serializable):强制事务串行执行,读写都会加锁,不使用 MVCC。

总结 MVCC 在不同隔离级别中的表现:

隔离级别Read View 生成时机特点
Read Committed每条 SELECT 语句开始时只能看到已提交的最新版本,可能出现不可重复读。
Repeatable Read事务开始时(第一次读操作时)事务内看到的都是固定快照,不会出现不可重复读。

5. MVCC 的操作流程模拟(以 RR 隔离级别为例)

让我们通过一个具体的例子,模拟 MVCC 在 Repeatable Read 隔离级别下的工作流程。

假设:

  • accounts,初始数据:id=1, balance=100
  • 事务 T1, T2, T3
  • 事务 ID 递增,T1 < T2 < T3

初始状态:
accounts 表中只有一条记录,其 DB_TRX_ID 为 T0(假设是初始化事务 ID)。

idbalanceDB_TRX_IDDB_ROLL_PTR
1100T0NULL

场景模拟:

时间点 A:
事务 T1 启动
SELECT * FROM accounts WHERE id = 1; (这是 T1 的第一次读操作,生成 Read View)

  • T1 的 Read View 建立:

    • m_ids: [] (此时没有其他活跃事务)
    • min_trx_id: 假设为 100 (如果 T0 是 99)
    • max_trx_id: 101 (下一个事务 ID)
    • creator_trx_id: T1 (假设 T1 的 ID 是 100)
  • T1 读判断:

    • 读取行 (id=1, balance=100, DB_TRX_ID=T0)
    • T0 < min_trx_id (T0 < 100) -> 可见
  • 结果: T1 读到 (id=1, balance=100)


时间点 B:
事务 T2 启动
UPDATE accounts SET balance = 150 WHERE id = 1;
T2 提交。

T2 执行过程:

  1. Undo Log 中保存当前行 (id=1, balance=100, DB_TRX_ID=T0)
  2. 更新当前行:id=1, balance=150, DB_TRX_ID=T2, DB_ROLL_PTR 指向 Undo Log 中的 T0 版本。

当前数据行状态:

idbalanceDB_TRX_IDDB_ROLL_PTR
1150T2指向 T0 版本的 Undo Log

版本链:
当前行(T2: balance=150) --> Undo Log(T0: balance=100)


时间点 C:
事务 T3 启动
UPDATE accounts SET balance = 200 WHERE id = 1;
T3 提交。

T3 执行过程:

  1. Undo Log 中保存当前行 (id=1, balance=150, DB_TRX_ID=T2)
  2. 更新当前行:id=1, balance=200, DB_TRX_ID=T3, DB_ROLL_PTR 指向 Undo Log 中的 T2 版本。

当前数据行状态:

idbalanceDB_TRX_IDDB_ROLL_PTR
1200T3指向 T2 版本的 Undo Log

版本链:
当前行(T3: balance=200) --> Undo Log(T2: balance=150) --> Undo Log(T0: balance=100)


时间点 D:
事务 T1 再次执行读操作
SELECT * FROM accounts WHERE id = 1;

  • T1 使用其初始的 Read View

    • m_ids: [] (这个 Read View 是在时间点 A 生成的,当时 T2, T3 还没启动)
    • min_trx_id: 100 (T1 自己的 ID)
    • max_trx_id: 101 (下一个事务 ID)
    • creator_trx_id: T1 (100)
  • T1 读判断:

    1. 读取当前行: (id=1, balance=200, DB_TRX_ID=T3)

      • trx_id_R = T3
      • min_trx_id = 100, max_trx_id = 101
      • T3 >= max_trx_id (T3 肯定比 T1 的 max_trx_id 大,因为 T3 是后来启动的事务) -> 不可见
      • 沿着 DB_ROLL_PTR 找下一个版本。
    2. 查找 Undo Log 中的 T2 版本: (id=1, balance=150, DB_TRX_ID=T2)

      • trx_id_R = T2
      • min_trx_id = 100, max_trx_id = 101
      • T2 >= max_trx_id -> 不可见
      • 沿着 DB_ROLL_PTR 找下一个版本。
    3. 查找 Undo Log 中的 T0 版本: (id=1, balance=100, DB_TRX_ID=T0)

      • trx_id_R = T0
      • min_trx_id = 100
      • T0 < min_trx_id -> 可见
  • 结果: T1 再次读到 (id=1, balance=100)


整个过程可视化:

Yes
Yes
Yes
Yes
事务T1启动
第一次读操作
T1生成ReadView
m_ids=[], min_trx_id=100, max_trx_id=101
读取当前行 (DB_TRX_ID=T0)
T0 < min_trx_id ?
T1看到: (id=1, balance=100)
事务T2启动
更新id=1
balance=150
T2提交
数据行更新
DB_TRX_ID=T2
DB_ROLL_PTR指向T0版本UndoLog
事务T3启动
更新id=1
balance=200
T3提交
数据行再次更新
DB_TRX_ID=T3
DB_ROLL_PTR指向T2版本UndoLog
T1再次读操作
使用T1的旧ReadView
读取当前行 (DB_TRX_ID=T3)
T3 >= max_trx_id ?
沿着DB_ROLL_PTR
找T2版本UndoLog
读取T2版本 (DB_TRX_ID=T2)
T2 >= max_trx_id ?
沿着DB_ROLL_PTR
找T0版本UndoLog
读取T0版本 (DB_TRX_ID=T0)
T0 < min_trx_id ?
T1看到: (id=1, balance=100)
(与第一次读相同)
T1结束
保持可重复读

通过这个模拟,我们可以清楚地看到,即使其他事务在 T1 期间修改并提交了数据,T1 也能通过其固定的 Read View 和版本链,找到它“应该看到”的那个旧版本数据,从而实现了可重复读

6. MVCC 的优点和缺点

优点:

  1. 提高并发性能: 读操作不再需要等待写操作释放锁,写操作也不需要等待读操作。实现了读写并行,大大提高了数据库的并发处理能力。
  2. 避免脏读和不可重复读: 通过 Read View 和版本链,每个事务都能读到一致的数据快照。
  3. 减轻锁的开销: 很多读操作(快照读)不再需要加锁,减少了锁竞争,降低了死锁的风险。

缺点:

  1. 空间开销: 需要存储多个旧版本的数据(在 Undo Log 中),会占用额外的存储空间。
  2. 清理成本: 需要后台线程(Purge 线程)定期清理不再需要的旧版本数据,这会带来一定的系统开销。
  3. 实现复杂性: MVCC 的实现机制相对复杂,需要考虑版本链的管理、Read View 的生成和判断逻辑、以及与锁机制的协同。

MVCC 是现代关系型数据库(特别是面向 OLTP 场景的数据库)并发控制的核心基石。它巧妙地利用了时间维度(事务 ID)和空间维度(版本链在 Undo Log 中的存储),实现了读写分离的并发控制,极大地提升了数据库的吞吐量和用户体验。

理解 MVCC,不仅是理解一个数据库特性,更是理解一种乐观并发控制的思想:宁愿多存储一些历史数据,也不愿让用户互相等待。这种思想在分布式系统、版本控制系统(如 Git)中也有类似的体现。

最终,MySQL 中的 MVCC,就是通过隐藏列、Undo Log 和 Read View 这三者精妙的配合,为每个事务提供了一个特定时间点的数据快照,从而实现了“读不阻塞写,写不阻塞读”的强大并发能力。
缺点:

  1. 空间开销: 需要存储多个旧版本的数据(在 Undo Log 中),会占用额外的存储空间。
  2. 清理成本: 需要后台线程(Purge 线程)定期清理不再需要的旧版本数据,这会带来一定的系统开销。
  3. 实现复杂性: MVCC 的实现机制相对复杂,需要考虑版本链的管理、Read View 的生成和判断逻辑、以及与锁机制的协同。

MVCC 是现代关系型数据库(特别是面向 OLTP 场景的数据库)并发控制的核心基石。它巧妙地利用了时间维度(事务 ID)和空间维度(版本链在 Undo Log 中的存储),实现了读写分离的并发控制,极大地提升了数据库的吞吐量和用户体验。

理解 MVCC,不仅是理解一个数据库特性,更是理解一种乐观并发控制的思想:宁愿多存储一些历史数据,也不愿让用户互相等待。这种思想在分布式系统、版本控制系统(如 Git)中也有类似的体现。

最终,MySQL 中的 MVCC,就是通过隐藏列、Undo Log 和 Read View 这三者精妙的配合,为每个事务提供了一个特定时间点的数据快照,从而实现了“读不阻塞写,写不阻塞读”的强大并发能力。

http://www.lryc.cn/news/608844.html

相关文章:

  • 主成分分析法 PCA 是什么
  • 2、RabbitMQ的5种模式基本使用(Maven项目)
  • kafka 是一个怎样的系统?是消息队列(MQ)还是一个分布式流处理平台?
  • Linux常用命令分类总结
  • 井盖识别数据集-2,700张图片 道路巡检 智能城市
  • 本地环境vue与springboot联调
  • ThinkPHP 与 Vue.js 结合的全栈开发模式
  • 十八、Javaweb-day18-前端实战-登录
  • 《前端无障碍设计的深层逻辑与实践路径》
  • 【openlayers框架学习】十一:openlayers实战功能介绍与前端设计
  • K8S几种常见CNI深入比较
  • 企业自动化交互体系的技术架构与实现:从智能回复到自动评论—仙盟创梦IDE
  • ThinkPHP8学习篇(一):安装与配置
  • Go语言--语法基础7--函数定义与调用--自定义函数
  • Mysql深入学习:慢sql执行
  • Docker 国内可用镜像
  • ABP VNext + Quartz.NET vs Hangfire:灵活调度与任务管理
  • [嵌入式embed]C51单片机STC-ISP提示:正在检测目标单片机
  • 深度学习(鱼书)day10--与学习相关的技巧(后两节)
  • LWIP从FreeRTOS到uC/OS-III的适配性改动
  • 第六章第三节 TIM 输出比较
  • 关于Web前端安全防御之安全头配置
  • 位运算在权限授权中的应用及Vue3实践
  • 深入理解Java中String.intern()方法:从原理到并发控制实践
  • ElementUI常用的组件展示
  • 高质量数据集|大模型技术正从根本上改变传统数据工程的工作模式
  • Android 之 串口通信
  • zookeeper分布式锁 -- 读锁和写锁实现方式
  • 【Android】RecyclerView循环视图(2)——动态加载数据
  • 【C 学习】04-了解变量