定义
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_ID | 6 字节 | 记录最后一次修改该行记录的事务 ID |
DB_ROLL_PTR | 7 字节 | 回滚指针,指向该行上一个版本的 Undo Log |
DB_ROW_ID | 6 字节 | 隐藏的行 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 1Read 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 ;最佳实践
避免长事务:事务越短越好,及时提交
合理选择隔离级别:
- 大多数场景:READ COMMITTED 或 REPEATABLE READ
- 高并发场景:READ COMMITTED(版本链更短)
- 强一致性要求:REPEATABLE READ
监控 Undo 表空间:定期检查大小,设置告警
配置 Purge 线程:根据负载调整
innodb_max_purge_lag分批处理大数据量:避免单个事务更新太多行
定期 VACUUM/OPTIMIZE:清理无用版本,回收空间
关联术语
- [[事务隔离级别]]
- [[Undo Log]]
- [[快照读]]
- [[当前读]]
- [[死锁]]
参考资料
- MySQL 官方文档: InnoDB Multi-Versioning
- InnoDB 源码:
storage/innobase/read/read0read.cc - 《高性能 MySQL》第 7 章:事务与锁
- PostgreSQL 文档: MVCC