专栏文章
专栏文章
并发算法系列
1. 并发算法 #01:CAS 与 ABA 问题 2. 并发算法 #02:AQS(AbstractQueuedSynchronizer) 3. 并发算法 #03:MVCC(多版本并发控制) 4. 并发算法 #04:无锁队列(Michael-Scott Queue) 5. 并发算法 #05:Disruptor(无锁环形缓冲) 6. 并发算法 #06:RCU(Read-Copy-Update)

并发算法 #03:MVCC(多版本并发控制)

发布于 2026-06-02 03:03 👁 19 次阅读
#算法#并发#数据库#innodb#transaction#mvcc#engineering#snapshot

MVCC(Multi-Version Concurrency Control,多版本并发控制)通过保留数据的历史版本,实现"读不阻塞写、写不阻塞读",是 MySQL InnoDB、PostgreSQL、Oracle 等主流数据库实现高并发事务隔离的核心机制。

相关文章CAS 与 ABA 问题 · AQS(AbstractQueuedSynchronizer) · 无锁队列(Michael-Scott Queue)


目录

章节 说明
核心思想 读写不互斥的原理
InnoDB 隐藏字段 DB_TRX_ID、DB_ROLL_PTR 字段定义
Undo Log 版本链 多版本存储结构
ReadView 结构 快照的四个关键字段
可见性判断 四种情况的完整 if-else 伪代码
版本链遍历 从最新版本往旧找第一个可见版本
RC vs RR 差异 ReadView 创建时机的不同
快照读 vs 当前读 SELECT 与 SELECT FOR UPDATE 的区别
Purge 线程 Undo Log 的清理条件
异常场景 长事务积压、幻读、写偏斜

核心思想

mvcc version chain

传统悲观锁的问题:读加共享锁,写加排他锁,读写互斥,高并发下吞吐量差。

MVCC 的解法:不修改原行,而是写新版本

写操作:
  不覆盖原有数据,而是创建一个新版本
  把旧版本保存在 Undo Log 中
  通过指针把各版本串成链表(版本链)

读操作(快照读):
  根据事务开始时的"快照"(ReadView)
  沿版本链找第一个"对我可见"的版本
  整个过程不加任何锁

结果:
  读者看到的是某个时刻的一致性快照
  写者可以同时修改,互不阻塞

InnoDB 隐藏字段

InnoDB 的每一行数据,除了用户定义的列,还有以下隐藏字段:

行记录结构(简化):
┌─────────────────────────────────────────────────────┐
│  col1  │  col2  │ ... │ DB_ROW_ID │ DB_TRX_ID │ DB_ROLL_PTR │
└─────────────────────────────────────────────────────┘

DB_ROW_ID   (6 bytes):
  若表没有主键,InnoDB 自动生成的行 ID
  有主键时该字段不存在

DB_TRX_ID   (6 bytes):
  最后一次修改本行的事务 ID(单调递增)
  事务 ID 越大,说明事务越新

DB_ROLL_PTR (7 bytes):
  回滚指针(rollback pointer)
  指向 Undo Log 中本行的上一个版本
  通过该指针串成版本链

事务 ID 的分配

规则:
  只读事务(没有修改操作):不分配事务 ID(值为 0)
  读写事务(有 INSERT/UPDATE/DELETE):在第一次修改时分配一个全局单调递增 ID

分配来源:
  InnoDB 全局维护 max_trx_id 计数器
  每次分配后递增:new_trx_id = max_trx_id; max_trx_id++
  该计数器持久化到系统表空间(崩溃恢复时不重置)

Undo Log 版本链

数据结构

版本链节点(Undo Log Record):
  trx_id    : 本版本由哪个事务写入
  roll_ptr  : 指向更旧一个版本的指针(NULL 表示最旧版本)
  col_values: 本版本各列的值(UPDATE 时只存被修改的列)
  type      : INSERT_UNDO 或 UPDATE_UNDO

版本链示意(最新版本在表的数据页中,旧版本在 Undo Log 中):

  当前行(数据页)              Undo Log
  ┌─────────────────┐          ┌──────────────────┐
  │ val="C"         │          │ val="B"          │
  │ DB_TRX_ID=300   │ ──→      │ DB_TRX_ID=200    │ ──→
  │ DB_ROLL_PTR=ptr1│          │ DB_ROLL_PTR=ptr2 │
  └─────────────────┘          └──────────────────┘
                                        │
                                        ↓
                               ┌──────────────────┐
                               │ val="A"          │
                               │ DB_TRX_ID=100    │
                               │ DB_ROLL_PTR=NULL │
                               └──────────────────┘

UPDATE 时版本链的变化

初始:行 R,val="A",DB_TRX_ID=100,DB_ROLL_PTR=NULL

事务 200 执行 UPDATE R SET val="B":
  Step 1:将当前行(val="A", trx=100)复制到 Undo Log,生成旧版本记录 V1
  Step 2:修改当前行:val="B",DB_TRX_ID=200,DB_ROLL_PTR 指向 V1

事务 300 执行 UPDATE R SET val="C":
  Step 1:将当前行(val="B", trx=200)复制到 Undo Log,生成旧版本记录 V2
  Step 2:修改当前行:val="C",DB_TRX_ID=300,DB_ROLL_PTR 指向 V2
  V2.DB_ROLL_PTR 指向 V1(V1 已在 Undo Log 中)

最终版本链(新→旧):
  [val=C, trx=300] → [val=B, trx=200] → [val=A, trx=100] → NULL

ReadView 结构

ReadView 是事务执行快照读时创建的"一致性视图",记录了创建时刻所有活跃(未提交)事务的 ID 集合。

ReadView 字段定义:

  m_ids        : List<trx_id>
                 创建 ReadView 时,所有活跃(已启动但未提交)的读写事务 ID 列表
                 (不包括只读事务,因为只读事务没有 trx_id)

  min_trx_id   : trx_id = min(m_ids)
                 m_ids 中的最小值
                 若 m_ids 为空,则 min_trx_id = max_trx_id
                 含义:小于此值的事务一定已提交

  max_trx_id   : trx_id = 当前 max_trx_id(分配给下一个事务的 ID)
                 注意:这是"下一个将要分配的 ID",不是 m_ids 中的最大值
                 含义:大于等于此值的事务一定在 ReadView 创建之后才启动

  creator_trx_id: trx_id = 创建本 ReadView 的事务的 trx_id
                 只读事务的 creator_trx_id = 0
                 含义:自己修改的数据,自己应该能看到

ReadView 示例

假设系统当前状态:
  已提交事务:100, 101, 102
  活跃事务:103(未提交),105(未提交)
  max_trx_id(下一个待分配)= 107
  当前事务(要做快照读的)= 104(已提交,不在活跃列表)

创建的 ReadView:
  m_ids          = [103, 105]
  min_trx_id     = 103
  max_trx_id     = 107
  creator_trx_id = 104

可见性判断

对版本链中的每个版本 V(V.trx_id 为写入该版本的事务 ID),判断 ReadView rv 是否能看到该版本:

function is_visible(V, rv) -> bool:

  // 情况1:自己写的,自己能看
  if V.trx_id == rv.creator_trx_id:
      return true

  // 情况2:版本的事务 ID < min_trx_id
  //        说明写该版本的事务在 ReadView 创建之前就已提交
  //        → 可见
  if V.trx_id < rv.min_trx_id:
      return true

  // 情况3:版本的事务 ID >= max_trx_id
  //        说明写该版本的事务在 ReadView 创建之后才启动
  //        → 不可见(未来的事务)
  if V.trx_id >= rv.max_trx_id:
      return false

  // 情况4:min_trx_id <= V.trx_id < max_trx_id
  //        需要检查该事务是否在活跃列表中
  if V.trx_id in rv.m_ids:
      // 该事务创建 ReadView 时尚未提交 → 不可见
      return false
  else:
      // 该事务创建 ReadView 时已提交(介于 min 和 max 之间,但不在活跃列表)
      return true

执行追踪

ReadView:m_ids=[103,105], min=103, max=107, creator=104

版本链:[val=C, trx=106] → [val=B, trx=105] → [val=A, trx=102]

判断 V1(trx=106):
  106 != 104            → 不走情况1
  106 < 103 ?  否       → 不走情况2
  106 >= 107 ? 否       → 不走情况3
  106 in [103,105] ? 否 → 走情况4的 else → return true ✅
  结果:V1 可见,返回 val=C

(若 V1 不可见,则继续看 V2...)

判断 V2(trx=105):
  105 != 104            → 不走情况1
  105 < 103 ?  否       → 不走情况2
  105 >= 107 ? 否       → 不走情况3
  105 in [103,105] ? 是 → return false ✗

判断 V3(trx=102):
  102 != 104            → 不走情况1
  102 < 103 ?  是       → return true ✅
  结果:V3 可见,返回 val=A

版本链遍历

// 快照读:找版本链中第一个对 ReadView 可见的版本
function find_visible_version(row, rv) -> row_data | NULL:

  // 从当前行(最新版本)开始
  current_version = row   // row 在数据页中,含 DB_TRX_ID 和 DB_ROLL_PTR

  while current_version != NULL:
      if is_visible(current_version, rv):
          return current_version   // 找到了,返回该版本的列数据

      // 沿回滚指针走到上一个版本
      current_version = follow_roll_ptr(current_version.DB_ROLL_PTR)
      // follow_roll_ptr:根据 DB_ROLL_PTR 从 Undo Log 中读取上一版本记录

  // 版本链全部遍历完,没有可见版本
  // 说明该行对当前事务不可见(可能是在 ReadView 创建后才插入的)
  return NULL

INSERT 的特殊处理

INSERT 生成的 Undo Log 类型为 INSERT_UNDO:
  该类型的 Undo Log 在事务提交后立即可以被 purge 删除
  (因为 INSERT 之前没有旧版本,不需要给任何快照读用)

UPDATE/DELETE 生成的 Undo Log 类型为 UPDATE_UNDO:
  必须等到所有使用该版本的 ReadView 都销毁后才能 purge

RC vs RR 差异

两种隔离级别下,MVCC 的数据结构完全相同,唯一区别是 ReadView 的创建时机

RC(Read Committed,读已提交):
  每次 SELECT 都创建一个新的 ReadView
  → 每次读都能看到最新的已提交数据
  → 同一事务内两次 SELECT 可能看到不同结果(不可重复读)

RR(Repeatable Read,可重复读,InnoDB 默认):
  事务第一次执行快照读时创建 ReadView,之后复用同一个 ReadView
  → 同一事务内每次 SELECT 看到的是同一个快照
  → 解决了不可重复读问题

执行追踪:RC vs RR

事务 A(隔离级别 RC 或 RR):
  t1: BEGIN
  t2: SELECT val FROM t WHERE id=1   ← 首次快照读,两者都在此创建 ReadView

事务 B:
  t3: BEGIN
  t4: UPDATE t SET val="X" WHERE id=1
  t5: COMMIT                         ← 事务 B 提交

事务 A(继续):
  t6: SELECT val FROM t WHERE id=1

RC 下 t6 的结果:
  t6 时创建新 ReadView,B 已提交 → 能看到 val="X" → 返回 "X"(不可重复读)

RR 下 t6 的结果:
  t6 时复用 t2 创建的 ReadView,B 的 trx_id 不在 m_ids 中但 >= max_trx_id
  (因为 B 在 t2 创建 ReadView 之后才修改)→ 不可见 → 返回旧值(可重复读)

快照读 vs 当前读

快照读(Snapshot Read):
  普通 SELECT 语句
  使用 MVCC,读取历史版本,不加锁
  示例:SELECT * FROM orders WHERE user_id=1

当前读(Current Read):
  读取行的最新提交版本,并加锁
  必须等待其他事务释放锁后才能读
  会阻塞,直到拿到锁

  触发当前读的语句:
    SELECT ... FOR UPDATE         (加排他锁 X)
    SELECT ... LOCK IN SHARE MODE (加共享锁 S,MySQL 8.0+ 改为 FOR SHARE)
    INSERT INTO ...
    UPDATE ...
    DELETE ...

为什么 DML 要用当前读:
  UPDATE/DELETE 必须基于最新数据修改,否则可能覆盖其他事务的更新
  快照读可能读到旧版本,如果以旧版本为基础写入,会丢失其他事务的修改

当前读与 Next-Key Lock

SELECT * FROM t WHERE id > 10 FOR UPDATE

InnoDB 当前读不仅锁定满足条件的行,还会加 Next-Key Lock(行锁+间隙锁)
  行锁:锁定 id>10 的现有行
  间隙锁(Gap Lock):锁定 id>10 的间隙,防止其他事务在此范围 INSERT
  → 解决幻读(Phantom Read)

注意:RR 隔离级别下,快照读不会产生幻读(看的是快照)
      但当前读在没有 Gap Lock 时会产生幻读

Purge 线程

清理条件

Purge 线程负责清理不再需要的 Undo Log 记录。

可以清理一个 Undo Log 版本 V 的条件:
  所有活跃的 ReadView 中,min_trx_id > V.trx_id

  也就是说:没有任何活跃事务需要读取 V 这个版本

Purge 线程的工作流程:
  loop:
    purge_trx_id = min(所有活跃 ReadView 的 min_trx_id)
                 = 系统中最老的活跃快照所能看到的最旧事务 ID

    遍历 UPDATE_UNDO 日志:
      for each undo_record in undo_log:
          if undo_record.trx_id < purge_trx_id:
              删除该 undo_record
              更新版本链(将指向该版本的 DB_ROLL_PTR 改为 NULL 或下一有效版本)

    INSERT_UNDO:事务提交后立即可清理,不受 purge_trx_id 限制

    sleep(innodb_purge_batch_size 控制的时间)

清理示例

版本链:[val=C, trx=300] → [val=B, trx=200] → [val=A, trx=100]

系统中所有活跃 ReadView 的 min_trx_id 最小值 = 250

Purge 线程:
  purge_trx_id = 250
  检查 trx=100 的版本 A:100 < 250 → 可删除 ✅
  检查 trx=200 的版本 B:200 < 250 → 可删除 ✅
  检查 trx=300 的版本 C:300 >= 250 → 保留 ✗

清理后版本链:[val=C, trx=300] → NULL

异常场景

场景1:长事务导致 Undo Log 积压

问题描述:
  事务 T 在 t0 时刻开始,持有 ReadView(min_trx_id=1000)
  T 一直不提交(比如忘记 COMMIT,或者程序 bug 挂起)
  在此期间,大量写操作产生版本:trx=1001, 1002, ..., 5000

  Purge 线程:purge_trx_id = min(活跃 ReadView 的 min) = 1000
  所有 trx >= 1000 的版本全部无法清理 → Undo Log 急速膨胀

表现:
  ibdata1 / undo tablespace 文件不断增大
  磁盘可能被撑满
  每次快照读需要遍历更长的版本链,性能下降

监控方式:
  SHOW ENGINE INNODB STATUS\G    ← 查看 History list length(积压条数)
  SELECT * FROM information_schema.INNODB_TRX\G  ← 找长事务

解决:
  SET innodb_max_undo_log_size 控制 Undo Tablespace 大小
  监控长事务,超过阈值自动 KILL
  pt-kill(Percona Toolkit)自动杀死超时事务

场景2:快照读与幻读

问题描述:
  RR 隔离级别下,快照读不会产生幻读(看的是事务开始时的快照)
  但混合使用快照读和当前读时,可能出现幻读:

  t1: BEGIN                               事务 A
  t2: SELECT * FROM t WHERE age>18        快照读,看到 0 行(无数据)
                                          事务 B: INSERT INTO t VALUES(25); COMMIT
  t3: SELECT * FROM t WHERE age>18 FOR UPDATE  当前读,看到 1 行!← 幻读

原因:
  t3 的当前读读取最新提交数据,而不是快照
  尽管 t2 和 t3 在同一事务中,当前读"穿透"了快照

解决:
  始终使用快照读(不用 FOR UPDATE)
  或在第一次读时就加 FOR UPDATE,阻止其他事务 INSERT
  或使用 SERIALIZABLE 隔离级别(所有读都变成当前读,自动加 Next-Key Lock)

场景3:写偏斜(Write Skew)

问题描述(RR 隔离级别下仍存在):

  业务约束:同一班次中,至少有一名医生在岗
  初始状态:医生 Alice 在岗=true,医生 Bob 在岗=true

  事务 A(Alice 请假):
    t1: SELECT COUNT(*) FROM doctors WHERE on_duty=true   ← 快照读,看到 2
    t2: 判断 2 > 1,可以请假
    t3: UPDATE doctors SET on_duty=false WHERE id=alice

  事务 B(Bob 请假,与 A 并发):
    t1: SELECT COUNT(*) FROM doctors WHERE on_duty=true   ← 快照读,也看到 2
    t2: 判断 2 > 1,可以请假
    t3: UPDATE doctors SET on_duty=false WHERE id=bob

  两个事务都提交后:Alice 在岗=false,Bob 在岗=false → 无人在岗!违反约束

原因:
  MVCC 快照读让两个事务都看到了"2人在岗"的旧快照
  各自修改不同的行,没有冲突,都能提交
  但整体状态违反了约束

解决:
  SELECT ... FOR UPDATE(当前读),两个事务会互斥
  或应用层加分布式锁
  或 SERIALIZABLE 隔离级别

场景4:DELETE 后的版本链

问题:DELETE 是否真的删除数据?

不是。DELETE 在 InnoDB 中是"标记删除":
  在当前行(或新版本)上设置 delete_mark = 1
  并创建一条 DELETE_UNDO 记录加入版本链

  快照读逻辑:
    如果读到的版本 V.delete_mark == 1:
      该版本对快照读不可见(视为不存在)
      继续往旧版本找

  物理删除(purge):
    等到所有 ReadView 都不再需要该版本时
    Purge 线程才真正从磁盘删除该行

场景5:RC 下并发 UPDATE 的"丢失更新"

RC 隔离级别下:

  初始:balance=100

  事务 A(快照读):
    SELECT balance → 看到 100(快照)

  事务 B:
    UPDATE account SET balance=balance-50 → balance=50;COMMIT

  事务 A(当前读 / UPDATE):
    UPDATE account SET balance=balance-30
    InnoDB 的 UPDATE 使用当前读:读到最新值 50
    执行后:balance=20 ✅

  InnoDB 实际上会在 UPDATE 时自动使用当前读
  避免了基于旧快照的丢失更新
  但显式的 SELECT + 应用层计算 + UPDATE 模式仍有丢失更新风险
  → 应使用 UPDATE account SET balance=balance-30(原子操作)
     或 SELECT ... FOR UPDATE

参考资料

  • MySQL 官方文档:InnoDB Multi-Versioning
  • MySQL 官方文档:InnoDB Transaction Model
  • 《MySQL 技术内幕:InnoDB 存储引擎》第 6 章,姜承尧著
  • 《高性能 MySQL》第 1 章,Baron Schwartz 等著
  • Jeremy Cole: InnoDB internals: InnoDB File Formats and Source Code Structure
← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论