一 MVCC基本概念
MVCC通过在数据库中维护数据的多个版本来实现并发控制,使得读操作不需要等待写操作完成,写操作也不需要等待读操作完成。
二 InnoDB中的MVCC实现
InnoDB存储引擎通过以下机制实现MVCC:
- 隐藏字段:
DB_TRX_ID:记录最近修改该行数据的事务IDDB_ROLL_PTR:指向该行数据的上一个版本的指针(回滚指针)DB_ROW_ID:行ID(当没有主键时自动生成)
- Undo Log(回滚日志):
- 存储数据被修改前的值,用于事务回滚和MVCC
- 形成版本链,通过
DB_ROLL_PTR可以找到历史版本
- ReadView(读视图):
- 决定事务能看到哪些版本的数据
- 包含:创建该ReadView的事务ID、活跃事务列表、最小活跃事务ID、下一个将要分配的事务ID
三 MVCC工作流程
3.1 ReadView介绍
3.1.1 数据结构保障
ReadView是MySQL InnoDB实现MVCC(多版本并发控制)的核心组件,它通过精确控制事务对数据版本的可见性,确保了并发事务间的隔离性。以下是ReadView保障MVCC执行的详细机制: ReadView包含四个关键字段
struct read_view_t {
trx_id_t creator_trx_id; // 创建者事务ID
ids_t m_ids; // 活跃事务ID集合
trx_id_t min_trx_id; // 最小活跃事务ID
trx_id_t max_trx_id; // 下一个将分配的事务ID
};
3.1.2读视图创建时机
| 隔离级别 | ReadView 创建时机 | 生命周期 |
|---|---|---|
| READ COMMITTED | 每次执行 SELECT 语句时创建新的 ReadView | 仅用于当前 SELECT |
| REPEATABLE READ | 事务中第一次执行 SELECT 时创建 | 整个事务期间复用 |
| SERIALIZABLE | 不使用 ReadView,采用加锁方式 | 不适用 |
3.1.2.1图形化显示
3.2 UPDATE 操作与 ReadView 的关系详解
在 MySQL 的 InnoDB 引擎中,UPDATE 操作与 ReadView 的交互是一个需要特别注意的问题。以下是详细的解析:
3.2.1 解释
UPDATE 操作本身不会创建新的 ReadView,但它会利用事务已有的 ReadView 来判断哪些数据行可以被修改。
3.2.2 UPDATE 操作的处理流程
3.2.2.1 标准处理流程
3.2.2.2. 特殊情况处理
- 当前读:UPDATE 总是基于当前最新提交的数据版本进行修改
- 冲突检测:会检查行是否被其他事务修改过
3.2.3 不同隔离级别的表现
3.2.3.1 REPEATABLE READ (RR) 级别
-- 事务A
BEGIN;
SELECT * FROM account WHERE id = 1; -- 创建ReadView
-- 此时看到的balance=1000
-- 事务B
UPDATE account SET balance=900 WHERE id=1;
COMMIT;
-- 事务A执行UPDATE:
UPDATE account SET balance=balance+100 WHERE id=1;
/*
1. 定位id=1的当前行(DB_TRX_ID=101)
2. 检查行锁是否冲突(无冲突,事务B已提交)
3. 直接读取当前行的balance=900
4. 计算新值: 900 + 100 = 1000
5. 更新数据并设置DB_TRX_ID=100
*/
3.2.3.2. READ COMMITTED (RC) 级别
-- 事务A
BEGIN;
-- 每次UPDATE会看到已提交的最新数据
UPDATE account SET balance=balance+100 WHERE id=1;
/*
1. 不创建新ReadView,但会识别已提交的修改
2. 如果事务B已提交修改,会基于最新值计算
*/
3.2.3.3. UPDATE 与 SELECT 的关键区别
| 操作 | 是否创建 ReadView | 使用的数据版本 | 锁行为 |
|---|---|---|---|
| SELECT | 是(根据隔离级别) | 根据ReadView决定可见性 | 无锁或共享锁 |
| UPDATE | 否 | 总是当前最新提交版本 | 获取排他锁(X锁) |
3.2.4 实际案例分析
3.2.4.1. 案例1:幻读问题
-- 事务A (RR级别)
BEGIN;
SELECT * FROM account WHERE id > 100; -- 创建ReadView, 看到2行
-- 事务B
INSERT INTO account VALUES(101, '新用户', 500);
COMMIT;
-- 事务A
UPDATE account SET balance=balance+100 WHERE id > 100;
-- 会修改3行(包括事务B插入的行),导致幻读
3.2.4.2. 案例2:版本跳过问题
-- 初始数据: id=1, balance=1000 (DB_TRX_ID=50)
-- 事务A (TRX_ID=100)
BEGIN;
UPDATE account SET balance=900 WHERE id=1; -- 未提交
-- 事务B (TRX_ID=101)
BEGIN;
-- 使用ReadView: m_ids=[100], min_trx_id=100
UPDATE account SET balance=balance+100 WHERE id=1;
/*
1. 发现当前行DB_TRX_ID=100(活跃事务)
2. 通过DB_ROLL_PTR找到旧版本(DB_TRX_ID=50)
3. 基于balance=1000计算新值1100
4. 尝试获取锁等待(因为事务A持有锁)
*/
机制说明:
- 事务B尝试当前读
- 首先尝试读取id=1的最新数据行(物理最新版本)
- 发现该行
DB_TRX_ID=100(被事务A修改过)
- 可见性检查
- 事务B的ReadView中
m_ids=[100](事务A活跃) - 判断
DB_TRX_ID=100属于活跃事务 → 当前版本不可见
- 事务B的ReadView中
- 版本链回溯
- 通过
DB_ROLL_PTR找到上一个版本(DB_TRX_ID=50) - 检查
DB_TRX_ID=50:- 50 < min_trx_id(100) → 该版本可见
- 确定计算基准值:
balance=1000
- 通过
- 锁冲突处理
- 基于
balance=1000计算出新值1100 - 尝试获取行的X锁时,发现事务A已持有锁 → 进入锁等待
- 基于
- 最终结果
- 如果事务A提交:
- 事务B获得锁后,会重新检查数据是否被修改
- 如果数据未被其他事务再次修改,则写入
balance=1100
- 如果事务A回滚:
- 事务B会基于事务A回滚后的数据重新计算
- 如果事务A提交:
3.2.5 系统内部实现
3.2.4.5.1. UPDATE 核心逻辑代码
// 简化的InnoDB更新逻辑
void row_update_for_mysql() {
// 不创建新read_view,使用事务现有的
read_view_t* view = trx->read_view;
// 查找要更新的行
row_search_for_update();
// 检查可见性
if (!row_is_visible(view)) {
// 通过undo log找到可见版本
row_vers_build_for_consistent_read();
}
// 获取X锁并修改数据
row_upd();
}
3.2.4.5.2. 重要限制条件
- 锁超时:等待锁超时后返回错误
- 死锁检测:自动检测并回滚其中一个事务
3.3. 规则详解
3.3.1 规则流程
def version_is_visible(trx_id, read_view):
if trx_id == read_view.creator_trx_id:
return True # 自己修改的版本可见
if trx_id < read_view.min_trx_id:
return True # 事务已提交
if trx_id >= read_view.max_trx_id:
return False # 事务还未开始
if trx_id in read_view.m_ids:
return False # 事务未提交
return True # 事务已提交
3.3.2. 自修改检查 (DB_TRX_ID == creator_trx_id)
if (trx_id == read_view_t->creator_trx_id) {
return true; // 可见
}
场景:当前事务修改了这行数据但尚未提交
原理:事务总能看见自己做的修改,即使未提交
示例:
BEGIN;
UPDATE account SET balance=500 WHERE id=1;
-- 同一个事务内查询能立即看到修改
SELECT balance FROM account WHERE id=1; -- 看到500
3.3.3. 历史版本检查 (DB_TRX_ID < min_trx_id)
if (trx_id < read_view_t->min_trx_id) {
return true; // 可见
}
场景:数据版本由已提交的旧事务创建
原理:min_trx_id是当前活跃事务中的最小ID,比它小说明已提交
示例:
活跃事务列表: [101, 102], min_trx_id=101
数据行DB_TRX_ID=100 (100 < 101) → 可见
3.3.4. 未来事务检查 (DB_TRX_ID >= max_trx_id)
if (trx_id >= read_view_t->max_trx_id) {
return false; // 不可见
}
场景:数据版本由未来事务创建(可能因事务ID分配延迟)
原理:max_trx_id是下一个将分配的事务ID,≥它的都属未来
示例:
max_trx_id=105
数据行DB_TRX_ID=105 → 不可见
3.3.5. 活跃事务检查 (DB_TRX_ID ∈ m_ids)
if (trx_id在read_view_t->m_ids中) {
return false; // 不可见
} else {
return true; // 可见
}
场景:数据版本由其他活跃事务创建
原理:m_ids包含所有活跃事务ID,存在其中表示未提交
四 与Undo Log的协同流程
当数据版本不可见时,通过DB_ROLL_PTR访问Undo Log