MVCC:多版本并发控制原理解析

92 阅读12分钟

在数据库领域,并发控制是保证数据一致性的核心挑战。当多个事务同时读写数据时,如何避免脏读、不可重复读、幻读等问题,同时又能最大化并发性能?MVCC(Multi-Version Concurrency Control,多版本并发控制)给出了优雅的答案。

一、引言

在探讨 MVCC 之前,我们先明确一个前提:数据库的本质是"共享资源" ,多个事务同时操作时必然会产生冲突。最直观的冲突场景有三种:

  1. 读-写冲突:事务 A 读取数据时,事务 B 正在修改该数据
  2. 写-读冲突:事务 A 修改数据时,事务 B 正在读取该数据
  3. 写-写冲突:两个事务同时修改同一数据

早期数据库用"锁机制"解决这些冲突:读操作加共享锁(多个读可共存),写操作加排他锁(阻塞所有读写)。但这种方式有明显缺陷:

  • 性能低下:读操作会被写操作阻塞,写操作也会被读操作阻塞,并发能力差
  • 灵活性不足:无法满足不同场景对"一致性"和"并发度"的平衡需求

MVCC 的出现正是为了突破锁机制的局限。它的核心思想是:通过维护数据的多个版本,让读写操作在不同版本上工作,从而实现"读不加锁、读写不冲突"

二、MVCC 核心概念

要理解 MVCC 的工作原理,必须先掌握以下几个核心概念,它们是构建多版本控制的基础。

2.1 事务 ID(Transaction ID)

数据库会为每个事务分配一个唯一的、递增的 ID(以下简称 trx_id)。这个 ID 有两个关键作用:

  • 标记事务的"出生时间":ID 越大,事务启动越晚
  • 标记数据的"修改者":每条数据的最新版本都会记录最后修改它的事务 ID

2.2 隐藏字段

InnoDB 存储引擎会给表中的每一行数据自动添加隐藏字段:

隐藏字段含义
DB_TRX_ID记录最后一次修改该数据的事务 ID(新增、更新都会改变这个值)
DB_ROLL_PTR回滚指针,指向该数据的上一个版本(存储在 undo log 中)
DB_ROW_ID隐藏主键,当表没有指定主键时,InnoDB 会用这个字段作为默认聚簇索引键

举个例子:当我们创建一张 user 表时:

CREATE TABLE user (id INT, name VARCHAR(10));

InnoDB 实际存储的每行数据结构类似:

id: 1, name: '张三', DB_TRX_ID: 100, DB_ROLL_PTR: 0x000001, DB_ROW_ID: 1

重要说明DB_ROW_ID 只有在表没有显式定义主键时才会生成。建议所有表都定义显式主键。

2.3 Undo Log

undo log(回滚日志)是 MVCC 实现多版本的关键,它有两个核心作用:

  • 事务回滚:当事务执行失败时,通过 undo log 恢复数据到修改前的状态
  • 版本存储:保存数据的历史版本,供其他事务读取

当事务修改数据时,InnoDB 会先将数据的旧版本写入 undo log,再更新当前数据。例如:

  1. 事务 100 插入一条数据,DB_TRX_ID=100DB_ROLL_PTR=NULL(无历史版本)
  2. 事务 200 更新该数据,InnoDB 会:
    • 复制当前数据(trx_id=100)到 undo log
    • 更新原数据的 name 字段,将 DB_TRX_ID 改为 200
    • 原数据的 DB_ROLL_PTR 指向 undo log 中刚才保存的旧版本

这样,通过 DB_ROLL_PTR 就能串联起数据的所有历史版本,形成一条"版本链":

当前版本(trx_id=200, roll_ptr→旧版本1)
→ 旧版本1(trx_id=100, roll_ptr→NULL)

2.4 Read View

有了多版本数据后,事务如何判断哪个版本对自己可见?这就需要 Read View(读视图)—— 一个在事务读取数据时生成的"可见性规则集合"。

Read View 的实际参数(基于MySQL源码):

// MySQL源码:storage/innobase/include/read0types.h
class ReadView {
private:
    trx_id_t m_low_limit_id;    /* 大于等于此ID的事务均不可见 */
    trx_id_t m_up_limit_id;     /* 小于此ID的事务均可见 */
    trx_id_t m_creator_trx_id;  /* 创建该read view的事务ID */
    ids_t m_ids;                /* 创建read view时的活跃事务列表 */
    trx_id_t m_low_limit_no;    /* 用于purge的事务号 */


};

参数说明

  • m_low_limit_id:高水位,下一个待分配的事务ID,大于等于此值的事务都不可见
  • m_up_limit_id:低水位,活跃事务列表中的最小ID,小于此值的事务都可见
  • m_ids:创建Read View时活跃(未提交)的事务ID列表
  • m_creator_trx_id:创建此Read View的事务ID

可见性判断规则(假设数据版本的事务 ID 为 trx_id):

public:
    // 判断事务是否可见的核心方法
    bool changes_visible(trx_id_t id, const table_name_t& name) const {
        if (id < m_up_limit_id || id == m_creator_trx_id) {
            return(true);
        }
        check_trx_id_sanity(id, name);
        if (id >= m_low_limit_id) {
            return(false);
        } else if (m_ids.empty()) {
            return(true);
        }
        const ids_t::value_type* p = m_ids.data();
        return(!std::binary_search(p, p + m_ids.size(), id));
    }

三、MVCC 工作流程:完整生命周期解析

结合上述概念,我们通过一个具体案例演示 MVCC 的完整工作流程。

场景设定

  • 初始数据:id=1, name='张三'(假设由事务 100 插入,DB_TRX_ID=100DB_ROLL_PTR=NULL
  • 事务 200(trx_id=200):更新 name 为"李四"(未提交)
  • 事务 300(trx_id=300):查询 id=1 的数据

步骤 1:事务 200 修改数据,生成版本链

  1. 事务 200 启动,获得 trx_id=200
  2. 修改数据前,InnoDB 将旧版本(trx_id=100)写入 undo log
  3. 更新原数据:name='李四'DB_TRX_ID=200DB_ROLL_PTR 指向 undo log 中的旧版本
  4. 此时版本链为:
    新版本(trx_id=200, roll_ptr→旧版本)
    → 旧版本(trx_id=100, roll_ptr→NULL)
    
  5. 事务 200 未提交(仍处于活跃状态)

步骤 2:事务 300 读取数据,生成 Read View

  1. 事务 300 启动,获得 trx_id=300
  2. 执行查询时,生成 Read View:
    • m_ids = [200](当前只有事务 200 活跃)
    • m_up_limit_id = 200(活跃事务中的最小ID)
    • m_low_limit_id = 301(下一个事务ID)
    • m_creator_trx_id = 300

步骤 3:遍历版本链,匹配可见版本

  1. 事务 300 先读取最新版本(trx_id=200):
    • 检查 trx_id=200 是否在 m_ids 中 → 是(事务 200 未提交)→ 不可见
  2. 跟随 DB_ROLL_PTR 读取上一版本(trx_id=100):
    • 检查 trx_id=100 < m_up_limit_id=200 → 可见(事务 100 已提交)
  3. 返回该版本数据:name='张三'

步骤 4:事务 200 提交后,版本可见性变化

  1. 事务 200 提交,从活跃事务列表 activeIDs 中移除
  2. 此时若有新事务 400 启动并查询:
    • 生成 Read View 时,m_ids 为空,m_up_limit_id 等于 m_low_limit_id=301
    • 读取最新版本(trx_id=200):200 < 301 且不在 m_ids 中 → 可见
    • 返回 name='李四'

四、MVCC 与锁机制的协同工作

MVCC 和锁在 InnoDB 中不是相互替代的关系,而是协同工作、各司其职的伙伴。理解它们的协作方式,是掌握 MySQL 并发控制的关键。

4.1 快照读与当前读:两种读取模式

快照读

定义:读取数据在某个时间点的历史版本,不读取最新数据,也不加锁。

触发方式

-- 普通的 SELECT 查询(在 RR 和 RC 隔离级别下)
SELECT * FROM table_name WHERE condition;

特点

  • 基于 MVCC 机制
  • 读取 Read View 确定的历史版本
  • 无锁操作,读写不冲突
  • 在 RR 级别下保证可重复读

当前读

定义:读取数据的最新版本,并对读取的记录加锁。

触发方式

-- 加锁的 SELECT
SELECT * FROM table_name WHERE condition FOR UPDATE;      -- 排他锁
SELECT * FROM table_name WHERE condition LOCK IN SHARE MODE; -- 共享锁

-- 数据修改操作
UPDATE table_name SET column = value WHERE condition;    -- 排他锁
DELETE FROM table_name WHERE condition;                  -- 排他锁
INSERT INTO table_name ... ;                             -- 排他锁

特点

  • 绕过 MVCC,直接读取最新数据
  • 需要加锁,可能阻塞其他操作
  • 用于需要获取实时数据的场景

4.2 MVCC 与锁的职责划分

并发问题解决机制具体实现
读-写冲突MVCC 主要负责通过版本链和 Read View,读操作访问历史版本,写操作创建新版本
写-写冲突锁机制主要负责通过行锁、间隙锁保证同一时间只有一个事务能修改数据
快照读的幻读MVCC 解决RR 级别下复用 Read View,保证同一事务看到相同的数据快照
当前读的幻读间隙锁解决锁定索引范围,阻止其他事务插入

实际工作流程

-- 事务A:混合使用快照读和当前读
START TRANSACTION;

-- 快照读:使用 MVCC,无锁
SELECT * FROM accounts WHERE user_id = 1; 

-- 当前读:使用锁机制,加排他锁
SELECT * FROM accounts WHERE user_id = 1 FOR UPDATE;

-- 当前读:使用锁机制,加排他锁  
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;

COMMIT;

4.3 不同操作的锁使用策略

快照读的锁策略

-- 无锁操作(RR 和 RC 隔离级别)
SELECT * FROM table;
SELECT * FROM table WHERE id = 1;
SELECT * FROM table WHERE name LIKE 'A%';

当前读的锁策略

SELECT FOR UPDATE

-- 对满足条件的记录加排他锁(X Lock)
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- 其他事务无法修改这些记录,直到当前事务提交

SELECT LOCK IN SHARE MODE

-- 对满足条件的记录加共享锁(S Lock)
SELECT * FROM products WHERE stock > 0 LOCK IN SHARE MODE;
-- 其他事务可以读,但不能修改这些记录

UPDATE/DELETE

-- 更新前先执行当前读,对记录加排他锁
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- 等效于:SELECT * FROM products WHERE id = 1 FOR UPDATE; + 更新操作

INSERT

-- 对插入的新记录加排他锁
INSERT INTO orders (user_id, amount) VALUES (1, 100);
-- 同时可能对相关索引加间隙锁,防止幻读

4.4 幻读问题的分治解决方案

MVCC 解决快照读的幻读

场景:同一个事务中的多次普通查询

-- 事务A(RR隔离级别)
START TRANSACTION;

-- 第一次查询,生成Read View
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 返回5条记录

-- 事务B插入新订单并提交
-- INSERT INTO orders (user_id, amount) VALUES (1, 100); COMMIT;

-- 事务A第二次查询(快照读)
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 仍然返回5条记录 ✅
-- MVCC通过复用Read View,保证看到相同的数据快照

原理

  • 在RR级别下,事务第一次快照读时生成Read View
  • 后续所有快照读都复用这个Read View
  • 通过版本链找到对当前Read View可见的数据版本
  • 其他事务的插入操作对当前事务不可见

间隙锁解决当前读的幻读

场景:加锁的查询和数据修改操作

-- 事务A(RR隔离级别)
START TRANSACTION;

-- 当前读,加间隙锁
SELECT * FROM orders WHERE user_id = 1 FOR UPDATE; 
-- 不仅锁定现有记录,还锁定user_id=1的记录范围(间隙锁)

-- 事务B尝试插入(被阻塞)
-- INSERT INTO orders (user_id, amount) VALUES (1, 200); -- 被阻塞!

-- 事务A可以安全操作
UPDATE orders SET status = 'processed' WHERE user_id = 1;
COMMIT; -- 提交后,事务B的插入才执行

间隙锁的工作原理

  • 锁定索引记录之间的"间隙"
  • 防止其他事务在锁定范围内插入新记录
  • 确保当前读操作的一致性

间隙锁的触发条件

-- 以下操作在RR级别可能触发间隙锁:
SELECT * FROM table WHERE id BETWEEN 10 AND 20 FOR UPDATE;
DELETE FROM table WHERE age > 25;
UPDATE table SET status = 'X' WHERE category_id = 5;

五、MVCC 与事务隔离级别的关系

MVCC 是 InnoDB 实现事务隔离级别的基础,不同隔离级别通过"生成 Read View 的时机"来区分:

隔离级别生成 Read View 的时机效果说明
读未提交(RU)不使用 MVCC,直接读取最新版本可能读取到未提交的数据(脏读)
读已提交(RC)每次查询时生成新的 Read View只能看到已提交的版本,解决脏读,但可能不可重复读
可重复读(RR)事务启动时生成一次 Read View,之后复用同一事务中多次查询结果一致,解决不可重复读
串行化(S)不依赖 MVCC,通过加锁强制事务串行执行完全避免并发问题,但性能最差

关键点:InnoDB 的默认隔离级别是"可重复读(RR)",通过 MVCC 实现了"读不加锁"的可重复读,配合间隙锁解决了幻读问题。

六、MVCC 的优势与局限

优势

  1. 读写不冲突:读操作无需加锁,写操作只锁定必要的数据,大幅提升并发性能
  2. 多版本共存:不同事务可以同时操作不同版本的数据,满足多样化的隔离需求
  3. 事务回滚支持:通过 undo log 实现事务的原子性(ACID 中的 A)

局限

  1. 存储空间开销:undo log 会占用额外存储空间,需要定期清理(InnoDB 会自动 purge 过期版本)
  2. 版本链遍历成本:如果数据被频繁修改,版本链会很长,查询时遍历版本链可能影响性能
  3. 不适用于所有场景:写-写冲突仍需通过锁机制解决(如行锁、间隙锁)

七、总结

MVCC 是数据库领域解决并发问题的伟大发明,它通过"事务 ID + 隐藏字段 + undo log + Read View"的组合,构建了一个多版本的数据世界。核心逻辑可以概括为:

  1. 写操作生成新版本:每次修改数据时,保留旧版本到 undo log,形成版本链
  2. 读操作选择可见版本:通过 Read View 的规则,从版本链中找到当前事务可见的版本