MVCC深度解析:MySQL如何实现高效无阻塞的并发读写

MVCC,正是MySQL实现"高并发、低阻塞"的核心技术------它让"读操作不用等写操作,写操作也不用等读操作"成为可能,其实核心就是"给数据存多个版本,不同事务按规则读对应版本"。

一、 MVCC初印象:数据库的"时光机"

1. 为什么需要MVCC?

想象一下图书馆的场景:

  • 没有MVCC:一本书只能一个人看,其他人必须排队等待(传统锁机制)
  • 有MVCC :每个人都可以拿到这本书的"副本",同时阅读不同版本,互不干扰

MVCC(多版本并发控制) 就是数据库的"时光机 "技术,它维护数据的多个历史版本,在不加锁的前提下,让读操作不阻塞写操作,写操作也不阻塞读操作

2. 核心概念对比:当前读 vs 快照读

类型 工作机制 类似场景 示例SQL
当前读 读取最新数据,并加锁防止别人修改 排队买限量商品,买完前别人不能动 SELECT ... FOR UPDATE UPDATE ... DELETE ...
快照读 读取某个时间点的数据版本不加锁 查看历史交易记录,不影响当前交易 普通 SELECT
bash 复制代码
-- 示例:银行转账场景
-- 当前读(加锁,防止并发问题)
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- 锁定账户
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 扣款

-- 快照读(不加锁,不影响别人操作)
SELECT * FROM transaction_history WHERE date = '2024-01-01';  -- 查看历史记录

关键:不同隔离级别下的快照读差异

快照读的"快照生成时机",会随事务隔离级别的不同而变化,这直接影响读取结果的一致性:

  • Read Committed(读已提交,RC)每次执行select都会生成一个新的快照(读最新的历史版本);
  • Repeatable Read(可重复读,RR):一个事务中,只有select第一次执行时才生成快照,后续所有select都复用这个快照(保证多次读结果一致);
  • Serializable(串行化):最严格的隔离级别,快照读会退化为当前读(加锁阻塞,完全放弃并发)。

重点:MySQL默认隔离级别是RR(可重复读),正是靠MVCC的"快照复用"实现了"可重复读",避免了"不可重复读"问题。

1.3 MVCC核心定义:多版本并发控制

MVCC 全称 Multi-Version Concurrency Control(多版本并发控制),官方定义:通过维护数据的多个版本,让读写操作不冲突,从而实现非阻塞读的并发控制技术。

核心要点拆解:

  • "多版本":每条数据被修改时,都会生成一个新的版本,旧版本保留(存在Undo Log中);
  • "并发控制":通过规则让不同事务读取到自己"有权限"的版本,实现读写不阻塞;
  • 实现载体:快照读(简单select)是MVCC的具体应用场景,当前读不依赖MVCC。

二、 MVCC三大支柱:隐藏字段、Undo Log、ReadView

MVCC不是单一技术,而是由"数据记录的隐藏字段 ""Undo Log版本链 ""ReadView(读视图)"三个组件协同实现的。就像"时光相册"能正常使用,需要"照片的元信息""相册的排序""访问相册的权限清单"三者配合。

1. 隐藏字段:每条记录的"身份证"

InnoDB引擎会给每张表的每条记录,自动添加3个隐藏字段(不用手动定义,数据库底层维护),用于标记数据的版本和关联历史版本:

bash 复制代码
-- 实际存储结构(你看不到,但确实存在)
CREATE TABLE invisible_fields (
    id INT PRIMARY KEY,
    -- 以下是隐藏字段 --
    DB_TRX_ID BIGINT,     -- 最后修改的事务ID
    DB_ROLL_PTR POINTER,  -- 指向上一个版本的指针
    DB_ROW_ID BIGINT,     -- 隐藏主键(如果没有主键)
    -- 你的表字段 --
    name VARCHAR(50),
    age INT
);

字段详解:

  • DB_TRX_ID:最后修改这条记录的事务ID。(事务ID是自增的,越大表示事务越新)。比如:事务10插入一条记录,DB_TRX_ID=10;之后事务20修改了这条记录,DB_TRX_ID就更新为20。
  • DB_ROLL_PTR:指向这条记录上一个版本的指针(类似链表),相当于"时光相册"里的"上一张照片"索引。旧版本的数据存在Undo Log中,通过这个指针就能串联起所有历史版本,形成"版本链"。
  • DB_ROW_ID:隐藏主键,保证每行记录唯一。如果表没有手动指定主键(PRIMARY KEY),InnoDB会自动生成这个隐藏字段作为主键(自增);如果表已有主键,这个字段就不会生成。作用是保证每条记录的唯一性,和MVCC的版本控制间接相关。

可视化理解 :一条记录的结构就像"照片+标签"------数据内容是"照片",DB_TRX_ID、DB_ROLL_PTR是"标签",标注了这张照片的"拍摄者(事务ID)"和"上一张照片的位置(回滚指针)"。

2. Undo Log(回滚日志):数据的"后悔药"和"时光胶囊"

Undo Log不仅是回滚工具,还是MVCC的"版本库"

bash 复制代码
// Undo Log的两种角色
class UndoLog {
    // 1. 回滚日志(事务失败时撤销操作)
    function rollback() {
        // 回滚INSERT:删除新记录
        // 回滚UPDATE:恢复旧值
        // 回滚DELETE:恢复已删除记录
    }
    
    // 2. MVCC版本链(存储历史版本)
    function createVersionChain() {
        // 每次修改都保留旧版本
        // 新版本指向旧版本,形成链表
    }
}

先回顾Undo Log的核心特性

  • Insert操作的Undo Log:只在事务回滚时需要,事务提交后会立即删除(因为插入的记录只有当前事务可见,其他事务不会读插入前的"空版本");
  • Update/Delete操作的Undo Log:不仅用于回滚,还用于MVCC的快照读(其他事务可能需要读修改前的旧版本),所以事务提交后不会立即删除,要等没有事务需要这个版本时才会被清理(由purge线程负责)。

Undo Log版本链的形成过程

当不同事务或同一事务多次修改同一条记录时,会生成多条Undo Log,这些日志通过"回滚指针(DB_ROLL_PTR)"串联成一条"版本链"。版本链的特点:

  • 链表的"头部":最新的旧版本(离当前时间最近的修改版本);
  • 链表的"尾部":最早的旧版本(第一次修改前的原始版本);
  • 每条版本都包含自己的DB_TRX_ID(修改该版本的事务ID)和DB_ROLL_PTR(指向上一个版本的指针)。

示例:版本链如何生成?

假设表user(id, name)(id为主键),有3个事务依次修改同一条记录(id=1,初始name="张三"):

  1. 事务10(TRX_ID=10):将name改为"李四",生成一条Update Undo Log,记录旧值name="张三",DB_TRX_ID=10,DB_ROLL_PTR指向null(此时是第一个版本);
  2. 事务20(TRX_ID=20):将name改为"王五",生成一条Update Undo Log,记录旧值name="李四",DB_TRX_ID=20,DB_ROLL_PTR指向事务10生成的版本;
  3. 事务30(TRX_ID=30):将name改为"赵六",生成一条Update Undo Log,记录旧值name="王五",DB_TRX_ID=30,DB_ROLL_PTR指向事务20生成的版本。

最终形成的版本链:

bash 复制代码
【当前最新版本(name="赵六",TRX_ID=30)】 ← 【版本2(name="王五",TRX_ID=20)】 ← 【版本1(name="李四",TRX_ID=10)】 ← 【原始版本(name="张三")】

3. ReadView(读视图):决定你能看到什么

有了"版本链"(时光相册里的所有照片),还需要一个"规则 "来判断:当前事务能读取版本链中的哪一个版本?这个规则就是 ReadView (读视图)。
ReadView的核心作用:记录并维护"当前系统中活跃的事务ID"(未提交的事务),作为快照读时判断版本可见性的依据。就像"访问相册的权限清单",规定了哪些"照片(版本)"可以看。

bash 复制代码
class ReadView {
    // 四个核心属性
    long creator_trx_id;  // 创建者的事务ID
    long[] m_ids;         // 活跃事务ID列表(未提交的事务)
    long min_trx_id;      // 最小活跃事务ID
    long max_trx_id;      // 最大事务ID + 1
    
    // 核心方法:判断版本是否可见
    boolean isVisible(Version version) {
        // 判断逻辑(后面详细讲解)
    }
}

ReadView的4个核心字段

每个ReadView都包含4个固定字段,用于判断版本可见性:

  • m_ids:当前活跃的事务ID集合(所有未提交的事务ID列表);
  • min_trx_id:当前活跃事务ID中的最小值(最小的"未提交事务ID");
  • max_trx_id:预分配的下一个事务ID(当前系统中最大的事务ID + 1,因为事务ID是自增的);
  • creator_trx_id:创建这个ReadView的事务ID(当前执行快照读的事务ID)。

三、 版本链访问规则:判断是否可见

快照读时,InnoDB会先生成ReadView,再从版本链的"头部"(最新旧版本)开始,依次判断每个版本的DB_TRX_ID(修改该版本的事务ID)是否符合规则。如果符合,就读取这个版本;如果不符合,就通过回滚指针找下一个版本,直到找到符合规则的版本(或版本链结束,返回空)。

1. 判断版本可见性的四大规则

bash 复制代码
// 简化版可见性判断逻辑
boolean isVersionVisible(Version version, ReadView readView) {
    // 规则1:自己修改的,自己能看到
    if (version.trx_id == readView.creator_trx_id) {
        return true;
    }
    
    // 规则2:版本事务ID < 最小活跃事务ID → 已提交,可见
    if (version.trx_id < readView.min_trx_id) {
        return true;
    }
    
    // 规则3:版本事务ID >= 最大事务ID → 将来事务创建的,不可见
    if (version.trx_id >= readView.max_trx_id) {
        return false;
    }
    
    // 规则4:版本事务ID在活跃事务列表中 → 未提交,不可见
    if (readView.m_ids.contains(version.trx_id)) {
        return false;
    }
    
    // 其他情况:事务已提交且不在活跃列表中,可见
    return true;
}

以下是通用的版本可见性判断规则(RC和RR隔离级别共用这组规则,差异仅在于ReadView的生成时机):

  1. 如果当前版本的DB_TRX_ID == creator_trx_id(修改这个版本的事务,就是当前执行快照读的事务):
    → 可见(自己修改的版本,自己当然能看);
  2. 如果当前版本的DB_TRX_ID < min_trx_id(修改这个版本的事务,在当前所有活跃事务之前就已提交):
    → 可见(事务已提交,其修改的版本对其他事务可见);
  3. 如果当前版本的DB_TRX_ID >= max_trx_id(修改这个版本的事务,是在当前ReadView生成之后才启动的):
    → 不可见(事务还没开始,其修改的版本还没生成,自然看不到);
  4. 如果当前版本的min_trx_id <= DB_TRX_ID < max_trx_id(修改这个版本的事务,在当前活跃事务范围内):
    → 再判断DB_TRX_ID是否在m_ids(活跃事务集合)中:
    • 若在:不可见(事务未提交,其修改的版本还不能被其他事务看);
    • 若不在:可见(事务已提交,其修改的版本对其他事务可见)。

简化记忆

  • 自己改的:能看;
  • 比所有未提交事务都早提交的:能看;
  • 还没开始的事务改的:不能看;
  • 正在运行(未提交)的事务改的:不能看;已提交的:能看。

2. 实战示例:银行账户余额变化

假设账户初始余额为1000元:

bash 复制代码
-- 事务执行顺序
-- 事务100:INSERT INTO accounts(id, balance) VALUES(1, 1000);
-- 事务101:UPDATE accounts SET balance = 800 WHERE id = 1;
-- 事务102:UPDATE accounts SET balance = 900 WHERE id = 1;
-- 事务103:SELECT balance FROM accounts WHERE id = 1;  -- 此时事务101已提交,事务102未提交

版本链:

bash 复制代码
V3: balance=900 (trx_id=102, 未提交) ← 指向 V2
     ↑
V2: balance=800 (trx_id=101, 已提交) ← 指向 V1
     ↑
V1: balance=1000 (trx_id=100, 已提交) ← 链表尾部
  • DB_TRX_ID:102
  • m_ids:[102];
  • min_trx_id:[102];
  • max_trx_id:[104];
  • creator_trx_id:创建这个ReadView的事务ID(当前执行快照读的事务ID)。

不同事务的读取结果:

  • 事务103(创建ReadView时活跃事务:[102]):

    • 检查V3:trx_id=102在活跃列表中 → 不可
    • 检查V2:trx_id=101 < min_trx_id → 可见 → 返回800
  • 如果是事务102自己查询:

    • 检查V3:trx_id=102 = creator_trx_id → 可见 → 返回900

四、 隔离级别的实现差异

1. READ COMMITTED(读已提交)

核心特点 :每次快照读都创建新的ReadView。这意味着,每次快照读都会获取"当前最新的活跃事务状态",只能读到"已提交的最新版本"。

示例场景:

事务A(TRX_ID=100)执行快照读,此时系统中有活跃事务B(TRX_ID=200,未提交)和已提交事务C(TRX_ID=150)。

  1. 第一次select :生成ReadView(m_ids=[200], min_trx_id=200, max_trx_id=201, creator_trx_id=100);
    → 版本链中,事务C(150)的版本符合规则(150 < 200),读取事务C的版本;
  2. 事务B提交(TRX_ID=200);
  3. 第二次select :重新生成ReadView(m_ids=[], min_trx_id=201, max_trx_id=201, creator_trx_id=100);
    → 版本链中,事务B(200)的版本符合规则(200 < 201),读取事务B的版本;
  4. 结论:两次select读到不同版本(不可重复读),这就是RC隔离级别的特点。

2. REPEATABLE READ(可重复读)

核心特点 :一个事务中,只有第一次执行select (快照读)时生成ReadView,后续所有快照读都复用这个ReadView。这意味着,后续即使有其他事务提交,当前事务也不会看到其修改的版本,从而实现"可重复读"。

示例场景:

用上面的场景:事务A(TRX_ID=100)执行快照读,系统中有活跃事务B(TRX_ID=200,未提交)和已提交事务C(TRX_ID=150)。

  1. 第一次select :生成ReadView(m_ids=[200], min_trx_id=200, max_trx_id=201, creator_trx_id=100);→ 读取事务C(150)的版本
  2. 事务B提交(TRX_ID=200);
  3. 第二次select :复用第一次的ReadView(m_ids仍为[200],min_trx_id仍为200);
    → 事务B的DB_TRX_ID=200,在m_ids中(虽然事务B已提交,但ReadView没更新,仍认为它是活跃的),所以事务B的版本不可见;继续找下一个版本,还是读取事务C(150)的版本
  4. 结论:两次select读到相同版本(可重复读),这就是MySQL默认隔离级别的实现原理。

关键差异 :RC和RR的版本访问规则完全相同,唯一差异是ReadView的生成时机------RC每次快照读都生成新的,RR只生成一次并复用。这也是MVCC实现不同隔离级别一致性的核心。

3. 两种隔离级别的对比

特性 READ COMMITTED REPEATABLE READ
ReadView创建时机 每次快照读都创建 第一次快照读创建
可见性变化 能看到其他事务的已提交修改 看不到其他事务的已提交修改
幻读问题 可能出现幻读 通过MVCC避免幻读
适用场景 数据实时性要求高 数据一致性要求高
性能影响 频繁创建ReadView,开销较大 ReadView复用,开销较小

五、 MVCC完整工作流程

1. 数据插入流程

bash 复制代码
-- 插入一条新记录
INSERT INTO users(id, name, age) VALUES(1, '张三', 25);

-- MVCC内部执行:
-- 1. 分配事务ID(假设trx_id=100)
-- 2. 设置隐藏字段:
--    DB_TRX_ID = 100
--    DB_ROLL_PTR = NULL(没有历史版本)
-- 3. 创建Undo Log(用于回滚)

2. 数据更新流程

bash 复制代码
-- 更新记录
UPDATE users SET age = 26 WHERE id = 1;

-- MVCC内部执行(假设事务ID=101):
-- 1. 创建新版本(Copy-on-Write)
-- 2. 设置新版本:
--    DB_TRX_ID = 101
--    DB_ROLL_PTR → 指向旧版本
-- 3. 修改旧版本的DB_ROLL_PTR指向新版本
-- 4. 创建Undo Log记录旧值

3. 数据读取流程

bash 复制代码
-- 读取记录(假设在事务102中)
SELECT * FROM users WHERE id = 1;

-- MVCC内部执行:
-- 1. 找到记录的最新版本(DB_TRX_ID=101)
-- 2. 创建ReadView(或在RR中复用已有)
-- 3. 从最新版本开始,沿版本链回溯:
--    a. 检查版本101:是否可见?
--    b. 如果不,继续检查版本100
--    c. 找到第一个可见的版本返回

六、 MVCC与锁的协同工作

1. 什么时候用MVCC?什么时候用锁?

bash 复制代码
-- 场景1:只读查询 → 使用MVCC(快照读)
SELECT * FROM products WHERE category = '电子产品';
-- 不加锁,读取历史版本,不影响其他事务写操作

-- 场景2:读写冲突 → 使用锁(当前读)
SELECT * FROM orders WHERE id = 100 FOR UPDATE;
UPDATE orders SET status = '已发货' WHERE id = 100;
-- 加锁,确保数据一致性

-- 场景3:混合场景 → MVCC + 锁
BEGIN;
-- 快照读:使用MVCC读取库存
SELECT stock FROM products WHERE id = 1;  -- MVCC
-- 当前读:修改库存时加锁
UPDATE products SET stock = stock - 1 WHERE id = 1;  -- 加锁
COMMIT;

2. MVCC的优势与局限

优势:

  • 高并发:读写不冲突,大幅提升并发性能
  • 无阻塞读:读取操作永远不需要等待
  • 避免死锁:读操作不加锁,减少死锁概率
  • 实现隔离级别:支持RC和RR隔离级别

局限:

  • 存储开销:需要额外空间存储多版本
  • 版本清理:需要定期清理过期版本
  • 写冲突:写操作仍然需要加锁处理
  • 历史数据:长期运行的事务可能阻止旧版本清理

七、 实战:MVCC如何解决并发问题

1. 脏读问题解决

bash 复制代码
-- 事务A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 未提交

-- 事务B(使用MVCC)
BEGIN;
-- 脏读:如果事务B能读到未提交的修改,就是脏读
SELECT balance FROM accounts WHERE id = 1;  -- MVCC会返回之前已提交的版本
-- 结果:不会看到事务A未提交的修改

2. 不可重复读问题解决

bash 复制代码
-- 事务A
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 返回1000

-- 事务B提交修改
UPDATE accounts SET balance = 800 WHERE id = 1;
COMMIT;

-- 事务A再次查询
SELECT balance FROM accounts WHERE id = 1;  -- 在RR级别下,MVCC保证仍返回1000
-- 结果:可重复读

3. 幻读问题解决

bash 复制代码
-- 事务A
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- 返回5

-- 事务B插入新记录
INSERT INTO orders(status) VALUES ('pending');
COMMIT;

-- 事务A再次查询
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- 在RR级别下,MVCC保证仍返回5
-- 结果:没有幻读

八、 MVCC性能优化与监控

1. 监控MVCC相关指标

bash 复制代码
-- 查看Undo Log使用情况
SHOW ENGINE INNODB STATUS\G
-- 查看"TRANSACTIONS"部分的History list length

-- 查看长事务(可能阻止版本清理)
SELECT * FROM information_schema.INNODB_TRX
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60;

-- 查看等待清理的版本
SELECT 
    NAME AS table_name,
    NUM_ROWS,
    CLUST_INDEX_SIZE,
    OTHER_INDEX_SIZE
FROM information_schema.INNODB_SYS_TABLESTATS;

2. 优化建议

bash 复制代码
-- 1. 控制事务大小,及时提交
-- 不好的做法:
BEGIN;
-- 执行大量操作...
-- 长时间不提交,占用版本链
COMMIT;

-- 好的做法:
BEGIN;
-- 快速完成操作
COMMIT;

-- 2. 避免长查询
-- 设置查询超时
SET max_execution_time = 5000;  -- 5秒超时

-- 3. 定期维护
-- 在业务低峰期执行
OPTIMIZE TABLE large_table;

九、 常见问题解答

Q1:MVCC能完全避免锁吗
A:不能。MVCC主要优化了读操作,避免了读锁。但写操作仍然需要加锁来保证数据一致性,尤其是:

  • 当前读操作(SELECT ... FOR UPDATE)
  • INSERT、UPDATE、DELETE操作
  • 唯一约束检查

Q2:版本链会无限增长吗?
A :不会。InnoDB有purge线程专门清理不再需要的旧版本。清理条件:

  • 没有活跃事务需要这个版本
  • 版本已提交且超过一定时间
  • Undo Log空间需要回收

Q3:MVCC对存储有什么影响?
A:MVCC会增加存储开销,主要体现在:

  • 额外字段:每行数据多3个隐藏字段
  • Undo Log:存储历史版本
  • 版本链 :维护多个版本
    但相比带来的并发性能提升,这个开销通常是值得的。

Q4:为什么RC级别下能看到已提交的修改?
A:因为RC级别每次快照读都创建新的ReadView,新的ReadView能看到之前已提交的事务。

Q5:MVCC和锁哪个性能更好?
A:不同场景适用不同技术:

  • 高并发读 :MVCC性能远优于锁
    -高并发写:锁机制更直接高效
  • 混合负载:MVCC+锁的组合最优

十、 总结

MVCC的本质的是"用空间换时间":通过保留数据的历史版本(占用Undo Log空间),避免了读写操作的阻塞(节省并发等待时间)。其核心逻辑可以总结为:

  1. 数据修改时:生成新版本,记录DB_TRX_ID和DB_ROLL_PTR,旧版本存入Undo Log并串联成版本链;
  2. 快照读时:生成ReadView(RC每次生成,RR只生成一次);
  3. 版本判断:从版本链头部开始,用ReadView的规则判断版本可见性,找到符合规则的版本并读取。

MVCC的核心价值

  • 高并发:实现读写不阻塞,大幅提升并发访问性能(MySQL能支撑高并发,MVCC功不可没);
  • 一致性:配合隔离级别,实现不同程度的数据一致性(如RR的可重复读);
  • 高效回滚:Undo Log不仅支撑MVCC,还支撑事务回滚,一举两得。

必记的核心要点

  • MVCC只作用于快照读(简单select),当前读(加锁select、DML)不依赖MVCC;
  • 三大核心组件:隐藏字段(DB_TRX_ID、DB_ROLL_PTR)、Undo Log版本链、ReadView;
  • RC和RR的差异:ReadView生成时机不同(每次vs一次),导致是否可重复读;
  • Undo Log的作用:回滚 + 存储数据历史版本(支撑MVCC)。
相关推荐
austin流川枫2 小时前
🔥MySQL的大表优化方案 (实战分享)
java·mysql·性能优化
程序员根根2 小时前
MySQL 事务全解析:从 ACID 特性到实战落地(部门 - 员工场景)
数据库·后端
爱吃山竹的大肚肚2 小时前
MySQL 支持的各类索引
java·数据库·sql·mysql·spring·spring cloud
黑白极客2 小时前
mysql的 order by是怎么工作的?redo-log和binlog为什么采用双确认机制?
数据库·mysql
程序员水自流2 小时前
MySQL常用内置函数详细介绍
java·数据库·mysql
慌糖2 小时前
开发当中常见注解备注
数据库·sql
小韩博2 小时前
PHP-MySQL 数据请求与 SQL 注入多样性(小迪 43 课笔记整理)
sql·mysql·php
TAEHENGV2 小时前
关于应用模块 Cordova 与 OpenHarmony 混合开发实战
android·javascript·数据库
赵思空2 小时前
window docker 安装 mysql 数据库,及不能连接问题
数据库·mysql·docker