MVCC 多版本并发控制

深入解析 InnoDB MVCC 实现原理:隐藏字段(DB_TRX_ID、DB_ROLL_PTR)、Undo 版本链、Read View 可见性判断,以及快照读与当前读的区别。

MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现高并发读写不阻塞的核心机制。通过为每行数据维护多个版本,SELECT 读取不需加锁即可获取一致性快照,大幅提高并发性能。本文深入解析 MVCC 的底层实现。


1. MVCC 核心思想

传统数据库使用锁机制控制并发,读请求可能被写请求阻塞。MVCC 的思想是:

为每个事务提供数据库在某一时刻的快照(Snapshot),读写互不阻塞。

传统锁模型:
T1(写) ──► [X Lock] 数据
                │
T2(读) ────────► 阻塞等待!

MVCC 模型:
T1(写) ──► 数据 v1 ──► 修改 ──► 数据 v2(新版本)
                │                    │
T2(读) ────────► 读取 v1 版本(不阻塞!)

2. 隐藏字段:每行数据的额外信息

InnoDB 在每行数据后自动添加三个隐藏字段:

隐藏字段大小含义
DB_TRX_ID6 bytes最后修改该记录的事务 ID
DB_ROLL_PTR7 bytes回滚指针,指向 Undo Log 中的历史版本
DB_ROW_ID6 bytes隐藏主键(当表没有显式主键时使用)

2.1 数据结构可视化

┌─────────────────────────────────────────────────────────────┐
│                     聚簇索引记录格式                         │
├─────────────────────────────────────────────────────────────┤
│  用户定义的列(id, name, balance...)                        │
├─────────────────────────────────────────────────────────────┤
│  DB_TRX_ID (6B)    │  修改此记录的事务号                     │
├─────────────────────────────────────────────────────────────┤
│  DB_ROLL_PTR (7B)  │  Undo Log 地址:                       │
│                    │  Roll Segment(1B) + Page Offset + Slot │
├─────────────────────────────────────────────────────────────┤
│  DB_ROW_ID (6B)    │  隐藏主键(无主键表时使用)             │
└─────────────────────────────────────────────────────────────┘

2.2 事务 ID 生成

-- 查看当前服务器最旧和最新的事务 ID
SELECT MIN(TRX_ID) min_trx, MAX(TRX_ID) max_trx 
FROM information_schema.INNODB_TRX;

-- InnoDB 内部使用 trx_id_t(64位整数),重启后自增不重置

3. Undo Log 与版本链

3.1 版本链结构

每次 UPDATE 或 DELETE 时,InnoDB 不直接修改原有记录,而是通过 Undo Log 保留旧版本:

当前值:balance = 800, TX=10
         │ DB_ROLL_PTR 指向
         ▼
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│ 版本2 (TX=10)   │───►│ 版本1 (TX=5)    │───►│ 版本0 (TX=1)    │───► NULL
│ balance = 800   │PTR │ balance = 500   │PTR │ balance = 1000  │
└─────────────────┘    └─────────────────┘    └─────────────────┘

当前值的 DB_TRX_ID = 10(事务 ID),DB_ROLL_PTR 指向事务 5 的版本。
事务 5 的版本 DB_ROLL_PTR 再指向事务 1 的版本,依此类推形成版本链。

3.2 INSERT 与 DELETE 的特殊处理

操作Undo Log 类型版本链特征
INSERTTRX_UNDO_INSERT新插入行的 DB_TRX_ID = 当前事务 ID,之前无版本
UPDATETRX_UNDO_UPD_EXIST旧值写入 Undo,当前行 DB_TRX_ID 更新
DELETETRX_UNDO_DEL_MARK标记删除,DB_TRX_ID 记录删除事务 ID

4. Read View:可见性判定的快照

Read View 是 MVCC 实现**一致性读(Consistent Read)**的核心数据结构,决定在事务执行期间,能看到哪些版本的数据。

4.1 Read View 结构

struct read_view_t {
    trx_id_t  low_limit_id;      // 高水位:当前活跃事务中最大的事务 ID + 1
    trx_id_t  up_limit_id;       // 低水位:当前活跃事务中最小的事务 ID
    trx_id_t  creator_trx_id;    // 创建此 Read View 的事务 ID
    ids_t     trx_ids;           // 创建 Read View 时所有未提交事务的 ID 列表
};

4.2 可见性判断算法

对于一条数据记录的 DB_TRX_ID

def is_visible(record.trx_id, read_view):
    if record.trx_id == read_view.creator_trx_id:
        return True                     # 当前事务自己的修改总是可见
    
    if record.trx_id < read_view.up_limit_id:
        return True                     # 在 Read View 创建前已提交
    
    if record.trx_id >= read_view.low_limit_id:
        return False                    # 在 Read View 创建后启动的事务,不可见
    
    if record.trx_id in read_view.trx_ids:
        return False                    # 创建 Read View 时仍处于活跃(未提交)
    
    return True                         # 活跃列表中没有,说明已提交

4.3 不可见时的回溯流程

def read_record(view, row):
    while True:
        if is_visible(row.trx_id, view):
            return row                  # 找到可见版本,返回
        
        if row.roll_ptr is None:
            return None                 # 版本链到头,记录已不存在
            
        row = fetch_undo_log(row.roll_ptr)  # 沿着 Undo 链回溯上一版本

5. 不同隔离级别下的 Read View

5.1 READ COMMITTED(每次 SELECT 创建新 Read View)

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN; -- T1, TRX_ID = 100

SELECT * FROM accounts WHERE id = 1;    -- 创建 RV1,看到已提交的数据

-- T2 修改并 COMMIT
SELECT * FROM accounts WHERE id = 1;    -- 创建 RV2,看到 T2 的修改!
COMMIT;

每次 SELECT 重新获取当前活跃事务列表。因此可以读到其他事务新提交的数据 → 不可重复读

5.2 REPEATABLE READ(事务开始时创建 Read View)

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN; -- T1, TRX_ID = 100

SELECT * FROM accounts WHERE id = 1;    -- 创建 RV(仅一次)

-- T2 修改并 COMMIT
SELECT * FROM accounts WHERE id = 1;    -- 复用同一个 RV,仍看到旧数据 ✅
COMMIT;

整个事务期间使用同一个 Read View,确保可重复读

5.3 对比可视化

READ COMMITTED(每次查询刷新视图):

时间点:    t1         t2(T2提交)   t3
T1 Select  RV1[A=100] ──► 是,A 仍为 100
                 ▲
         T2 Update A=200

T1 Select2        ──► RV2[A=200] ──► 不可重复读!A=200

========================================

REPEATABLE READ(事务开始后视图固定):

时间点:    t1         t2(T2提交)   t3
T1 Select  RV[A=100] ──► 是,A 仍为 100
                 ▲
         T2 Update A=200

T1 Select2        ──► 复用 RV[A=100] ──► 可重复读 ✅

6. 快照读 vs 当前读

6.1 快照读(Snapshot Read / Consistent Read)

不加锁的 SELECT,读取创建 Read View 时可见的快照数据:

SELECT * FROM accounts WHERE id = 1;      -- 快照读
SELECT * FROM accounts WHERE balance > 0; -- 快照读

特点:

  • 不加锁,不阻塞其他事务的读写
  • 可能读到旧版本数据(根据隔离级别)
  • InnoDB 默认行为

6.2 当前读(Current Read)

读取最新已提交数据,并加锁:

SELECT * FROM accounts WHERE id = 1 FOR UPDATE;    -- X 锁 + 当前读
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE; -- S 锁
UPDATE accounts SET balance = 100 WHERE id = 1;    -- 当前读 + 更新
DELETE FROM accounts WHERE id = 1;                 -- 当前读 + 删除
INSERT INTO accounts VALUES (...);                 -- 当前读范式

当前读的语义:必须看到最新的数据并对其进行锁定。因此在 REPEATABLE READ 下也会产生幻读(需要通过 Gap Lock 解决)。

6.3 两者对比

维度快照读当前读
SQL 形式纯 SELECTSELECT … FOR UPDATE / UPDATE / DELETE / INSERT
读取版本历史版本(Read View)当前最新版本
是否加锁不加锁加锁(X/S/Next-Key Lock)
是否阻塞写不阻塞阻塞(X 锁)
性能存在锁竞争
出现幻读REPEATABLE READ 下不幻读REPEATABLE READ 下可能幻读

7. MVCC 与幻读的关系

7.1 为什么 MVCC 不能解决当前读的幻读?

-- T1, REPEATABLE READ
BEGIN;
SELECT * FROM accounts WHERE balance > 500; -- 快照读,看到 [A(600), B(700)]

    -- T2
    INSERT INTO accounts (name, balance) VALUES ('C', 600);
    COMMIT;

-- T1 使用当前读
SELECT * FROM accounts WHERE balance > 500 FOR UPDATE; 
-- 看到 [A(600), B(700), C(600)] ← 幻读!

原因:MVCC 的快照读可防止幻读,但当前读读取最新版本(不经过 Read View),因此可能看到新插入的行。

7.2 Gap Lock 解决当前读幻读

InnoDB 的 FOR UPDATE / LOCK IN SHARE MODE 使用 Next-Key Lock(记录锁 + 间隙锁):

-- balance > 500 的范围会被加上 Gap Lock
-- 不仅锁住现有记录,还锁住区间 (500, +∞) 中的"间隙"
-- T2 的 INSERT 被阻塞,直到 T1 COMMIT

8. Purge:清理过期版本

MVCC 产生大量历史版本,不能无限堆积。InnoDB 后台的 Purge 线程负责清理不再需要的 Undo Log:

-- Purge 关键参数
SHOW VARIABLES LIKE 'innodb_purge%';
-- innodb_purge_threads = 4        -- Purge 线程数
-- innodb_purge_batch_size = 300   -- 每批清理页数

-- 检查 Purge 滞后
SHOW ENGINE INNODB STATUS;
-- Purge done for trx's n:o < 123456 undo n:o < 0

当长时间运行的大事务不提交时,它会阻塞其 TRX_ID 之后的 Purge 进度,导致:

  • Undo 表空间无限膨胀(ibdata 文件增大)
  • Buffer Pool 被旧版本数据污染

监控脚本:

-- 发现长事务和风险
SELECT 
    trx_id, 
    trx_mysql_thread_id, 
    trx_state, 
    TIMESTAMPDIFF(SECOND, trx_started, NOW()) as trx_seconds,
    LEFT(trx_query, 100) as query_preview
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;

9. 总结

MVCC 是 InnoDB 并发控制的核心设计:

事务开始
   │
   ▼
Read View(可见性规则快照)
   │
   ├── 快照读(Consistent Read)
   │      └── 读取历史的可见版本(无锁)
   │
   └── 当前读(Current Read)
          └── 读取最新版本 + 加锁 
               └── Next-Key Lock 防止幻读
隔离级别读类型幻读可能性实现手段
READ COMMITTED快照读✓(每次刷新视图范围可能变)MVCC, 每次 SELECT 新 Read View
REPEATABLE READ快照读MVCC, 事务开始固定 Read View
REPEATABLE READ当前读若无 Gap Lock 则 ✓MVCC + Next-Key Lock

深入理解 MVCC 与 Read View 的机制,是分析复杂并发问题、设计高并发读写策略的基础。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「数据库」更多文章

  1. 备份恢复与高可用方案
  2. 数据库性能监控与诊断
  3. NewSQL 选型对比