Skip to content

定义 ​

MVCC (Multi-Version Concurrency Control,多版本并发控制) 是一种先进的并发控制机制,它通过维护数据的多个历史版本,使得读操作和写操作可以并发执行而互不阻塞。

MVCC 的核心思想是:

  • 读操作:读取数据的历史快照,无需加锁
  • 写操作:创建新的数据版本,不影响其他事务的读

这使得数据库能够实现高并发性能,同时保证事务的隔离性。

详细笔记 ​

核心原理 ​

MVCC vs 传统锁机制 ​

传统锁机制 (2PL):

事务 A: SELECT * FROM users WHERE id = 1;  -- 加共享锁(S锁)
事务 B: UPDATE users SET name = '新名' WHERE id = 1;  -- 需要排他锁(X锁),被阻塞

结果:
- 读阻塞写
- 写阻塞读
- 并发度低

MVCC 机制:

初始状态:
users 表: {id: 1, name: '张三', version: 1}

事务 A: SELECT * FROM users WHERE id = 1;
  → 读取 version=1 的快照
  → 不加锁,立即返回

事务 B: UPDATE users SET name = '李四' WHERE id = 1;
  → 创建新版本: {id: 1, name: '李四', version: 2}
  → 不影响事务 A 的读

结果:
- 读不阻塞写
- 写不阻塞读
- 并发度高

InnoDB 的 MVCC 实现 ​

隐藏列 ​

InnoDB 为每行记录添加了三个隐藏列:

sql
CREATE TABLE users (
  id INT PRIMARY KEY,
  name VARCHAR(50)
);

-- 实际存储结构(包含隐藏列):
{
  id: 1,
  name: '张三',
  DB_TRX_ID: 100,    -- 创建此版本的事务 ID
  DB_ROLL_PTR: 0xABC,  -- 回滚指针,指向 Undo Log
  DB_ROW_ID: 500   -- 隐藏的行 ID(如果没有主键)
}

隐藏列说明:

列名大小作用
DB_TRX_ID6 字节记录最后一次修改该行记录的事务 ID
DB_ROLL_PTR7 字节回滚指针,指向该行上一个版本的 Undo Log
DB_ROW_ID6 字节隐藏的行 ID,当表没有主键时使用

Undo Log 版本链 ​

当前版本 (version 3): {name: '王五', DB_TRX_ID: 300, DB_ROLL_PTR → Undo2}
          ↑
         事务 C 修改

Undo2:    {name: '李四', DB_TRX_ID: 200, DB_ROLL_PTR → Undo1}
          ↑
         事务 B 修改

Undo1:    {name: '张三', DB_TRX_ID: 100, DB_ROLL_PTR → NULL}
          ↑
         初始版本

版本链: version 3 → version 2 → version 1

Read View(读视图) ​

Read View 是事务启动时创建的快照,用于判断哪些版本对当前事务可见。

Read View 包含:

cpp
// InnoDB 源码: read0types.h
class ReadView {
  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;    // 活跃事务 ID 列表
};

可见性判断算法:

cpp
/**
 * 判断某个版本是否对当前事务可见
 * 
 * @param version_trx_id  版本的事务 ID
 * @param read_view   当前事务的 Read View
 * @return      是否可见
 */
bool is_visible(trx_id_t version_trx_id, const ReadView& read_view) {
  
  // 情况一:版本事务 ID < 低水位
  if (version_trx_id < read_view.m_up_limit_id) {
    return true;  // 版本在事务启动前已提交,可见
  }
  
  // 情况二:版本事务 ID >= 高水位
  if (version_trx_id >= read_view.m_low_limit_id) {
    return false;  // 版本在事务启动后才创建,不可见
  }
  
  // 情况三:版本事务 ID 在活跃事务列表中
  if (read_view.m_ids.contains(version_trx_id)) {
    return false;  // 版本所属事务尚未提交,不可见
  }
  
  // 情况四:版本事务 ID 不在活跃事务列表中
  return true;  // 版本所属事务已提交,可见
}

MVCC 的工作流程 ​

场景一:快照读(普通 SELECT) ​

sql
-- 时间线演示

T1: 事务 A 启动
  START TRANSACTION;

T2: 事务 A 第一次查询
  SELECT * FROM users WHERE id = 1;
  → 创建 Read View A
  → 看到: {name: '张三', version: 1}

T3: 事务 B 修改数据(未提交)
  START TRANSACTION;
  UPDATE users SET name = '李四' WHERE id = 1;
  → 创建新版本: {name: '李四', version: 2, DB_TRX_ID: B}

T4: 事务 A 再次查询
  SELECT * FROM users WHERE id = 1;
  → 使用相同的 Read View A
  → 检查 version 2: DB_TRX_ID = B,在活跃事务列表中
  → 不可见!沿版本链找到 version 1
  → 返回: {name: '张三', version: 1}  ✅ 一致性读

T5: 事务 B 提交
  COMMIT;

T6: 事务 A 再次查询
  SELECT * FROM users WHERE id = 1;
  → 仍然使用 Read View A
  → version 2 现在可见了? NO!
  → Read View 在第一次查询时创建,之后不变
  → 仍然返回: {name: '张三', version: 1}

T7: 事务 A 提交
  COMMIT;

T8: 事务 C 查询
  SELECT * FROM users WHERE id = 1;
  → 创建 Read View C
  → 看到: {name: '李四', version: 2}  ✅ 最新已提交版本

场景二:当前读(锁定读) ​

sql
-- 某些查询需要读取最新版本,称为"当前读"

-- 当前读的语句:
SELECT ... LOCK IN SHARE MODE; -- 共享锁
SELECT ... FOR UPDATE;     -- 排他锁
INSERT ...         -- 插入
UPDATE ...         -- 更新
DELETE ...         -- 删除

-- 示例
START TRANSACTION;

-- 当前读:读取最新版本,并加锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;
→ 跳过 MVCC,直接读取最新版本
→ 即使该版本由未提交事务创建
→ 如果该行被其他事务锁定,则等待

-- 普通读:使用 MVCC
SELECT * FROM users WHERE id = 1;
→ 使用 Read View
→ 可能读到历史版本

MVCC 在不同隔离级别的表现 ​

读未提交(READ UNCOMMITTED) ​

sql
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

-- 不使用 MVCC,直接读取最新版本
-- 可能读到未提交的数据(脏读)

事务 A: START TRANSACTION;
事务 B: UPDATE users SET name = '李四' WHERE id = 1;  -- 未提交
事务 A: SELECT * FROM users WHERE id = 1;
  → 看到: '李四'  ❌ 脏读!

读已提交(READ COMMITTED,RC) ​

sql
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 每次 SELECT 都创建新的 Read View

事务 A: START TRANSACTION;

事务 A: SELECT * FROM users WHERE id = 1;
  → 创建 Read View 1
  → 看到: '张三'

事务 B: UPDATE users SET name = '李四' WHERE id = 1;
事务 B: COMMIT;

事务 A: SELECT * FROM users WHERE id = 1;
  → 创建 Read View 2(新的!)
  → 看到: '李四'  ✅ 看到了已提交的更改

-- 特点:
-- - 可重复读:❌ 同一事务内两次读可能不同(不可重复读)
-- - 避免脏读:✅ 只能看到已提交的版本

可重复读(REPEATABLE READ,RR) - MySQL 默认 ​

sql
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 第一次 SELECT 时创建 Read View,之后复用

事务 A: START TRANSACTION;

事务 A: SELECT * FROM users WHERE id = 1;
  → 创建 Read View A
  → 看到: '张三'

事务 B: UPDATE users SET name = '李四' WHERE id = 1;
事务 B: COMMIT;

事务 A: SELECT * FROM users WHERE id = 1;
  → 复用 Read View A
  → 看到: '张三'  ✅ 与第一次相同(可重复读)

事务 A: COMMIT;

事务 A: SELECT * FROM users WHERE id = 1;
  → 新事务,创建新的 Read View
  → 看到: '李四'  ✅ 看到最新已提交版本

-- 特点:
-- - 可重复读:✅ 同一事务内多次读一致
-- - 避免脏读:✅
-- - 避免不可重复读:✅
-- - 幻读问题:部分解决(通过 Next-Key Lock)

串行化(SERIALIZABLE) ​

sql
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- 所有 SELECT 都加锁,退化为传统锁机制
-- 不使用 MVCC 的并发优势

-- 特点:
-- - 完全隔离:✅
-- - 并发度:❌ 最低

MVCC 的优势 ​

优势一:高并发 ​

传统锁机制:
  - 100 个并发读 + 1 个写
  - 写操作需要等待所有读释放锁
  - 或读操作需要等待写完成
  - 吞吐量: 约 1000 TPS

MVCC:
  - 100 个并发读 + 1 个写
  - 读操作无锁,立即返回
  - 写操作创建新版本,不阻塞读
  - 吞吐量: 约 10000 TPS
  
性能提升: 10 倍

优势二:非阻塞读 ​

sql
-- 报表查询(耗时 10 秒)
SELECT COUNT(*), SUM(amount) FROM orders;

-- 传统锁机制:
-- - 查询期间,订单表被锁定
-- - 新订单无法插入
-- - 业务中断!

-- MVCC:
-- - 查询读取快照,不加锁
-- - 新订单正常插入
-- - 业务不受影响

优势三:一致性备份 ​

bash
# mysqldump 利用 MVCC 实现热备份

mysqldump --single-transaction your_database > backup.sql

# 原理:
# 1. 启动一个事务
# 2. 创建 Read View
# 3. 导出该快照的数据
# 4. 不影响正常业务

MVCC 的代价 ​

代价一:存储空间 ​

原始数据: 10GB

MVCC 开销:
- Undo Log: 约 20-50% 额外空间
- 版本链: 取决于更新频率
- 总存储: 12-15GB

空间增加: 20-50%

代价二:清理开销 ​

sql
-- Purge 线程定期清理旧版本

-- 清理条件:
-- 1. 版本对所有活跃事务都不可见
-- 2. 超过 undo_retention 配置的时间

-- MySQL 配置
SHOW VARIABLES LIKE 'innodb_max_purge_lag';
-- 控制 Purge 线程的速度

-- PostgreSQL 配置
SHOW vacuum_cost_delay;
-- VACUUM 的成本延迟

代价三:版本链遍历 ​

查询: SELECT * FROM users WHERE id = 1;

最好情况:
  - 最新版本就可见
  - 遍历 1 次

最坏情况:
  - 需要沿版本链回溯多个版本
  - 遍历 10+ 次
  - 性能下降

优化:
  - InnoDB 会定期清理无用版本
  - 保持版本链较短

源码分析 ​

Read View 创建 ​

cpp
// storage/innobase/read/read0read.cc

/**
 * 创建 Read View
 */
void read_view_open_now(
  trx_t*    trx,    // 当前事务
  read_view_t*  view)   // 输出的 Read View
{
  // 1. 获取全局事务系统的锁
  mutex_enter(&trx_sys->mutex);
  
  // 2. 设置高低水位
  view->m_low_limit_id = trx_sys->max_trx_id;  // 下一个分配的事务 ID
  view->m_up_limit_id = trx_sys->min_active_trx_id;  // 最小活跃事务 ID
  
  // 3. 复制活跃事务列表
  view->m_ids = trx_sys->active_trx_list;
  
  // 4. 设置创建者事务 ID
  view->m_creator_trx_id = trx->id;
  
  // 5. 释放锁
  mutex_exit(&trx_sys->mutex);
}

版本可见性检查 ​

cpp
// storage/innobase/include/read0types.h

/**
 * 检查版本可见性
 */
inline bool changes_visible(
  trx_id_t  id,      // 版本的事务 ID
  const ReadView* view)  // 当前 Read View
{
  ut_ad(id > 0);
  
  // 情况一:版本太新
  if (id >= view->m_low_limit_id) {
    return false;
  }
  
  // 情况二:版本够老
  if (id < view->m_up_limit_id) {
    return true;
  }
  
  // 情况三:检查是否在活跃事务列表中
  return !view->m_ids.contains(id);
}

读取历史版本 ​

cpp
// storage/innobase/row/row0sel.cc

/**
 * 根据 Read View 选择合适的版本
 */
const rec_t* row_sel_get_prev_version(
  const rec_t*  rec,    // 当前记录
  const ReadView* view,   // Read View
  mtr_t*    mtr)    // Mini-transaction
{
  // 1. 获取记录的 DB_TRX_ID
  trx_id_t trx_id = row_get_rec_trx_id(rec);
  
  // 2. 检查是否可见
  if (changes_visible(trx_id, view)) {
    return rec;  // 可见,直接返回
  }
  
  // 3. 不可见,沿版本链回溯
  roll_ptr_t roll_ptr = row_get_rec_roll_ptr(rec);
  
  // 4. 从 Undo Log 获取旧版本
  const rec_t* old_version = trx_undo_get_version(roll_ptr, mtr);
  
  // 5. 递归检查旧版本
  return row_sel_get_prev_version(old_version, view, mtr);
}

MVCC 的实际应用 ​

场景一:长事务问题 ​

sql
-- 问题:长事务导致版本链过长

事务 A: START TRANSACTION;  -- 早上 9 点启动

-- 白天业务正常运行
-- 事务 B、C、D... 不断更新同一行
-- 版本链: v100 → v99 → v98 → ... → v1

事务 A: SELECT * FROM users WHERE id = 1;  -- 晚上 8 点查询
  → 需要沿版本链回溯 100 个版本!
  → 性能极差

-- 解决方案:
-- 1. 避免长事务
-- 2. 及时提交或回滚
-- 3. 监控长事务

-- 监控脚本
SELECT 
  trx_id,
  trx_started,
  TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds,
  trx_state,
  trx_query
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;  -- 超过 1 分钟

场景二:大事务的 Undo 膨胀 ​

sql
-- 问题:大批量更新产生大量 Undo

UPDATE orders SET status = 1 WHERE create_time < '2023-01-01';
-- 更新 100 万行,产生 100 万个 Undo 记录

-- 影响:
-- 1. Undo 表空间暴涨
-- 2. Purge 线程跟不上
-- 3. 磁盘空间不足

-- 解决方案:分批更新
DELIMITER $$
CREATE PROCEDURE batch_update_orders()
BEGIN
  DECLARE rows_affected INT DEFAULT 1;
  
  WHILE rows_affected > 0 DO
    UPDATE orders 
    SET status = 1 
    WHERE create_time < '2023-01-01'
    LIMIT 10000;
    
    SET rows_affected = ROW_COUNT();
    COMMIT;  -- 每批提交,释放 Undo
    
    DO SLEEP(0.1);
  END WHILE;
END$$
DELIMITER ;

最佳实践 ​

  1. 避免长事务:事务越短越好,及时提交

  2. 合理选择隔离级别:

  • 大多数场景:READ COMMITTED 或 REPEATABLE READ
  • 高并发场景:READ COMMITTED(版本链更短)
  • 强一致性要求:REPEATABLE READ
  1. 监控 Undo 表空间:定期检查大小,设置告警

  2. 配置 Purge 线程:根据负载调整 innodb_max_purge_lag

  3. 分批处理大数据量:避免单个事务更新太多行

  4. 定期 VACUUM/OPTIMIZE:清理无用版本,回收空间

关联术语 ​

  • [[事务隔离级别]]
  • [[Undo Log]]
  • [[快照读]]
  • [[当前读]]
  • [[死锁]]

参考资料 ​

  • MySQL 官方文档: InnoDB Multi-Versioning
  • InnoDB 源码: storage/innobase/read/read0read.cc
  • 《高性能 MySQL》第 7 章:事务与锁
  • PostgreSQL 文档: MVCC

Released under MIT License.