MySQL的MVCC原理

117 阅读7分钟

一 MVCC基本概念

MVCC通过在数据库中维护数据的多个版本来实现并发控制,使得读操作不需要等待写操作完成,写操作也不需要等待读操作完成。

二 InnoDB中的MVCC实现

InnoDB存储引擎通过以下机制实现MVCC:

  1. ​隐藏字段​​:
    • DB_TRX_ID:记录最近修改该行数据的事务ID
    • DB_ROLL_PTR:指向该行数据的上一个版本的指针(回滚指针)
    • DB_ROW_ID:行ID(当没有主键时自动生成)
  2. ​Undo Log(回滚日志)​​:
    • 存储数据被修改前的值,用于事务回滚和MVCC
    • 形成版本链,通过DB_ROLL_PTR可以找到历史版本
  3. ​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图形化显示

image.png

3.2 UPDATE 操作与 ReadView 的关系详解

在 MySQL 的 InnoDB 引擎中,UPDATE 操作与 ReadView 的交互是一个需要特别注意的问题。以下是详细的解析:

3.2.1 解释

​UPDATE 操作本身不会创建新的 ReadView​​,但它会利用事务已有的 ReadView 来判断哪些数据行可以被修改。

3.2.2 UPDATE 操作的处理流程

3.2.2.1 标准处理流程

image.png

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持有锁)
*/
机制说明​​:
  1. ​事务B尝试当前读​
    • 首先尝试读取id=1的​​最新数据行​​(物理最新版本)
    • 发现该行DB_TRX_ID=100(被事务A修改过)
  2. ​可见性检查​
    • 事务B的ReadView中m_ids=[100](事务A活跃)
    • 判断DB_TRX_ID=100属于活跃事务 → ​​当前版本不可见​
  3. ​版本链回溯​
    • 通过DB_ROLL_PTR找到上一个版本(DB_TRX_ID=50
    • 检查DB_TRX_ID=50
      • 50 < min_trx_id(100) → ​​该版本可见​
    • 确定计算基准值:balance=1000
  4. ​锁冲突处理​
    • 基于balance=1000计算出新值1100
    • 尝试获取行的X锁时,发现事务A已持有锁 → ​​进入锁等待​
  5. ​最终结果​
    • 如果事务A提交:
      • 事务B获得锁后,会​​重新检查​​数据是否被修改
      • 如果数据未被其他事务再次修改,则写入balance=1100
    • 如果事务A回滚:
      • 事务B会基于事务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

image.png