MySQL MVCC (多版本并发控制) 详解

什么是MVCC

MVCC(Multi-Version Concurrency Control,多版本并发控制)是数据库管理系统中用于提高并发性能的一种重要机制。其核心思想是为每个数据行维护多个版本,使得读操作和写操作可以并发执行而不会相互阻塞。

在传统的锁机制中,当一个事务正在修改数据时,其他事务的读取操作必须等待,这严重影响了系统的并发性能。而MVCC通过保存数据在某个时间点的快照,让读操作可以访问数据的历史版本,从而避免了读写操作之间的相互阻塞。

存储引擎对MVCC的支持

不同的MySQL存储引擎对MVCC的支持情况不同:

存储引擎 MVCC支持 锁机制 并发性能
InnoDB ✅ 支持MVCC 行级锁 高并发
MyISAM ❌ 不支持MVCC 表级锁 低并发

InnoDB存储引擎的MVCC实现

InnoDB是MySQL默认的存储引擎,完整支持MVCC机制:

  • 使用行级锁提高并发性能
  • 通过Undo Log实现多版本数据存储
  • 支持四种标准隔离级别

MyISAM存储引擎的限制

MyISAM存储引擎不支持MVCC:

  • 只支持表级锁,无法实现行级并发控制
  • 不支持事务处理
  • 适用于读多写少的场景

MVCC的核心组件

1. 隐藏字段

InnoDB存储引擎为每行数据自动添加三个隐藏字段:

  • DB_TRX_ID(6字节):记录最后一次修改该行数据的事务ID
  • DB_ROLL_PTR(7字节):回滚指针,指向该行数据的undo log记录
  • DB_ROW_ID(6字节):行ID,当表没有主键和非空唯一索引时自动生成,否则不会生成此字段
image.png

2. Undo Log(回滚日志)

Undo Log是MVCC实现的关键组件,它记录了数据修改前的旧值。每当对数据进行修改时,都会生成对应的Undo Log记录。

Undo Log主要分为两种类型:

  • insert undo log:针对INSERT操作产生的undo log,事务提交后即可被删除
  • update undo log:针对DELETE和UPDATE操作产生的undo log,可能需要保留更长时间以支持MVCC

同一个事务内对同一行数据的多次修改会生成多个undo log记录。每次修改都会产生对应的undo log,并通过回滚指针(DB_ROLL_PTR)指向上一次修改生成的undo log,形成版本链,以支持事务回滚和MVCC机制

3. Read View(读视图)

Read View是事务在某个时间点看到的数据快照。它包含以下关键信息:

  • m_ids:创建Read View时活跃(未提交)的事务ID列表
  • min_trx_idm_ids中的最小事务ID
  • max_trx_id:创建Read View时系统即将分配给下一个事务的ID
  • creator_trx_id:创建该Read View的事务ID

MVCC的工作机制

1. 数据版本链详解

当数据被修改时,不会直接覆盖原有数据,而是通过Undo Log将旧版本数据链接起来形成版本链。下面我们通过一个具体例子来说明这个过程。

初始状态

假设我们有一个用户表,初始状态下有一条记录:

用户表 users:
+----+--------+-----+
| id | name   | age |
+----+--------+-----+
| 1  | Alice  | 25  |
+----+--------+-----+

此时该行数据的隐藏字段为:

  • DB_TRX_ID: 100 (假设由事务100插入,事务ID全局唯一且有序)
  • DB_ROLL_PTR: NULL (初始版本,无回滚指针)
  • DB_ROW_ID: 不生成 (因为表有主键id)

第一次更新

事务200执行更新操作:

UPDATE users SET age = 26 WHERE id = 1;

执行后,系统会:

  1. 将原数据保存到Undo Log中
  2. 更新当前行数据
  3. 设置回滚指针

版本链结构如下:

当前版本 (事务200):
+----+--------+-----+-----------+-------------+
| id | name   | age | DB_TRX_ID | DB_ROLL_PTR |
+----+--------+-----+-----------+-------------+
| 1  | Alice  | 26  | 200       | ptr_to_log1 |
+----+--------+-----+-----------+-------------+

Undo Log版本1 (事务100):
+----+--------+-----+-----------+-------------+
| id | name   | age | DB_TRX_ID | DB_ROLL_PTR |
+----+--------+-----+-----------+-------------+
| 1  | Alice  | 25  | 100       | NULL        |
+----+--------+-----+-----------+-------------+

第二次更新

事务300执行更新操作:

UPDATE users SET age = 27 WHERE id = 1;

执行后,系统会:

  1. 将当前版本(事务200的数据)保存到Undo Log中
  2. 更新当前行数据
  3. 更新回滚指针

版本链结构如下:

当前版本 (事务300):
+----+--------+-----+-----------+-------------+
| id | name   | age | DB_TRX_ID | DB_ROLL_PTR |
+----+--------+-----+-----------+-------------+
| 1  | Alice  | 27  | 300       | ptr_to_log2 |
+----+--------+-----+-----------+-------------+

Undo Log版本2 (事务200):
+----+--------+-----+-----------+-------------+
| id | name   | age | DB_TRX_ID | DB_ROLL_PTR |
+----+--------+-----+-----------+-------------+
| 1  | Alice  | 26  | 200       | ptr_to_log1 |
+----+--------+-----+-----------+-------------+

Undo Log版本1 (事务100):
+----+--------+-----+-----------+-------------+
| id | name   | age | DB_TRX_ID | DB_ROLL_PTR |
+----+--------+-----+-----------+-------------+
| 1  | Alice  | 25  | 100       | NULL        |
+----+--------+-----+-----------+-------------+
image.png

2. 可见性判断算法

当事务读取数据时,InnoDB需要判断该数据版本对当前事务是否可见。判断逻辑如下:

// 简化的可见性判断逻辑
public boolean isVersionVisible(ReadView readView, long trxId) {
    // 如果记录的事务ID小于Read View的最小事务ID
    if (trxId < readView.min_trx_id) {
        // 说明该事务在Read View创建前已提交,可见
        return true;
    }
    
    // 如果记录的事务ID大于等于Read View的最大事务ID
    if (trxId >= readView.max_trx_id) {
        // 说明该事务在Read View创建后才开始,不可见
        return false;
    }
    
    // 事务ID在[min_trx_id, max_trx_id)区间内
    // 判断该事务是否在活跃事务列表中
    if (readView.m_ids.contains(trxId)) {
        // 在活跃列表中,说明事务还未提交,不可见
        return false;
    } else {
        // 不在活跃列表中,说明事务已提交,可见
        return true;
    }
}

3. 版本链遍历过程

当当前版本不可见时,系统会沿着版本链向前查找,直到找到一个可见的版本或遍历完所有版本:

  1. 读取当前版本的数据 (MVCC在读取数据时,从当前数据行的版本开始,沿着undolog中的版本链逐步回溯)
  2. 判断该版本是否对当前事务可见
  3. 如果可见,则返回该版本数据
  4. 如果不可见,则通过DB_ROLL_PTR找到上一个版本
  5. 重复步骤2-4,直到找到可见版本或版本链遍历完成

例如,假设事务400在READ COMMITTED隔离级别下执行查询:

事务400执行: SELECT * FROM users WHERE id = 1;

1. 读取当前版本(事务300): age=27, trx_id=300
2. 判断可见性: trx_id(300) >= max_trx_id(400) → 不可见
3. 通过DB_ROLL_PTR找到版本2(事务200): age=26, trx_id=200
4. 判断可见性: trx_id(200) >= max_trx_id(400) → 不可见
5. 通过DB_ROLL_PTR找到版本1(事务100): age=25, trx_id=100
6. 判断可见性: trx_id(100) < min_trx_id(400) → 可见
7. 返回版本1的数据: Alice, age=25

快照读与当前读

快照读(Snapshot Read)

快照读是指读取记录的可见版本,不加锁,是MVCC的核心实现方式。

特点

  • 不加锁,读取的是数据的一个快照版本
  • 通过Read View判断数据可见性
  • 是InnoDB在READ COMMITTED和REPEATABLE READ隔离级别下普通SELECT的默认行为

示例

-- 普通SELECT语句,执行快照读
SELECT * FROM users WHERE id = 1;
SELECT name, age FROM employees WHERE department = 'IT';

当前读(Current Read)

当前读是指读取记录的最新版本,并且会对记录加锁,保证读取过程中数据不会被修改。

特点

  • 读取的是记录的最新版本
  • 会对读取的记录加锁(共享锁或排他锁)
  • 保证读取过程中数据的一致性

触发当前读的SQL语句

-- 显式加共享锁的SELECT
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;

-- 显式加排他锁的SELECT
SELECT * FROM users WHERE id = 1 FOR UPDATE;

-- DML操作(INSERT/UPDATE/DELETE)中的SELECT
UPDATE users SET age = 26 WHERE id = 1;  -- 先执行当前读找到记录
DELETE FROM users WHERE id = 1;          -- 先执行当前读找到记录
INSERT INTO users (id, name) VALUES (2, 'Bob') 
ON DUPLICATE KEY UPDATE name = 'Bob2';   -- 检查重复键时执行当前读
特性 SELECT FOR UPDATE LOCK IN SHARE MODE
锁类型 排他锁(写锁) 共享锁(读锁)
其他事务读取 不允许 允许
其他事务写入 不允许 不允许
并发性
适用场景 读取后需要更新 只读操作
阻塞情况 阻塞其他读写操作 只阻塞写操作

事务隔离级别与MVCC

隔离级别 MVCC行为
READ UNCOMMITTED 不使用MVCC,直接读取最新数据
READ COMMITTED 使用MVCC,每次SELECT都生成新的Read View
REPEATABLE READ 使用MVCC,事务开始时生成Read View
SERIALIZABLE 不使用MVCC,使用锁机制保证串行化

1. READ UNCOMMITTED(读未提交)

MVCC行为

  • 不使用MVCC机制
  • 直接读取数据的最新版本
  • 可能读取到其他事务未提交的数据(脏读)

示例场景

-- 事务A
BEGIN;
UPDATE accounts SET balance = 1000 WHERE id = 1;

-- 事务B(READ UNCOMMITTED隔离级别)
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 可能读取到1000(未提交数据)
-- 如果事务A回滚,事务B读取到的就是"脏数据"

2. READ COMMITTED(读已提交)

MVCC行为

  • 使用MVCC机制
  • 每次SELECT语句执行时都生成新的Read View
  • 只能读取到已提交的数据
  • 可能出现不可重复读和幻读

Read View生成机制

每次执行SELECT语句时:

  1. 获取当前系统中的活跃事务列表
  2. 创建新的Read View
  3. 根据Read View判断数据版本可见性

示例场景

-- 事务A
BEGIN;
SELECT * FROM users WHERE id = 1;  -- 第一次查询

-- 事务B
BEGIN;
UPDATE users SET age = 26 WHERE id = 1;
COMMIT;

-- 事务A继续
SELECT * FROM users WHERE id = 1;  -- 第二次查询,结果可能不同
COMMIT;

3. REPEATABLE READ(可重复读)

MVCC行为

  • 使用MVCC机制
  • 事务第一次执行SELECT时生成Read View,整个事务期间复用该Read View
  • 保证同一事务内多次查询结果一致
  • MySQL InnoDB的默认隔离级别

Read View生成机制

事务内第一次执行SELECT语句时:

  1. 获取当前系统中的活跃事务列表
  2. 创建Read View并缓存
  3. 后续SELECT语句复用该Read View

示例场景

-- 事务A(REPEATABLE READ隔离级别)
BEGIN;
SELECT * FROM users WHERE id = 1;  -- 第一次查询,创建Read View

-- 事务B
BEGIN;
UPDATE users SET age = 26 WHERE id = 1;
COMMIT;

-- 事务A继续
SELECT * FROM users WHERE id = 1;  -- 第二次查询,结果与第一次相同
COMMIT;

4. SERIALIZABLE(串行化)

MVCC行为

  • 不使用MVCC机制
  • 使用锁机制保证串行化访问

实现方式

  • 对读取的数据加共享锁
  • 对修改的数据加排他锁
  • 事务完全串行执行

示例场景

-- 事务A(SERIALIZABLE隔离级别)
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM users WHERE id = 1;  -- 加共享锁

-- 事务B
BEGIN;
UPDATE users SET age = 26 WHERE id = 1;  -- 阻塞,等待事务A释放锁

写操作中的当前读机制

UPDATE操作的执行过程

当执行UPDATE语句时,InnoDB会执行以下步骤:

UPDATE users SET age = 26 WHERE id = 1;
  1. 当前读阶段

    • 对满足条件的记录加排他锁(X锁)
    • 读取记录的最新版本(当前读)
    • 如果记录被其他事务锁定,则等待
  2. 更新阶段

    • 创建新版本的数据
    • 设置DB_TRX_ID为当前事务ID
    • 将旧版本数据写入Undo Log
    • 更新DB_ROLL_PTR指向Undo Log

DELETE操作的执行过程

DELETE操作同样需要先执行当前读:

DELETE FROM users WHERE id = 1;
  1. 当前读阶段

    • 对满足条件的记录加排他锁(X锁)
    • 读取记录的最新版本
  2. 删除阶段

    • 将记录标记为删除(而不是立即物理删除)
    • 创建相应的Undo Log记录

INSERT操作的特殊性

INSERT操作通常不需要当前读,因为是插入新记录:

INSERT INTO users (id, name, age) VALUES (1, 'Alice', 25);

但如果遇到重复键需要执行更新操作,则可能涉及当前读:

INSERT INTO users (id, name, age) VALUES (1, 'Alice', 25) 
ON DUPLICATE KEY UPDATE age = 26;

版本链不存在的情况处理

在实际的数据库操作中,会遇到版本链不存在的特殊情况,主要包括首次插入数据和长时间后Undo Log被清理的情况。

1. 首次插入数据(无历史版本)

当一条记录是首次插入数据库时,不存在历史版本链:

-- 首次插入记录
INSERT INTO users (id, name, age) VALUES (1, 'Alice', 25);
COMMIT;

-- 查询该记录
SELECT * FROM users WHERE id = 1;  -- 直接访问当前版本

在这种情况下:

  • 记录只有当前版本,没有历史版本链
  • DB_TRX_ID设置为插入时的事务ID
  • DB_ROLL_PTR为空或指向空的Undo Log
  • 查询直接返回当前版本数据,无需版本链遍历
  • MVCC在这种情况下退化为简单的当前版本读取

2. Undo Log被清理(版本链截断)

当旧的事务已经提交很久,相关的Undo Log已经被PURGE线程清理:

-- 事务A(很久之前)
BEGIN;
UPDATE users SET age = 25 WHERE id = 1;  -- 创建Undo Log版本1
COMMIT;  -- 事务提交

-- 事务B(稍后)
BEGIN;
UPDATE users SET age = 26 WHERE id = 1;  -- 创建Undo Log版本2
COMMIT;  -- 事务提交

-- 系统运行很长时间后,Undo Log被PURGE线程清理
-- 假设只有版本2的Undo Log被保留,版本1的被清理

-- 事务C(当前,REPEATABLE READ隔离级别)
BEGIN;
SELECT * FROM users WHERE id = 1;  -- 只能看到版本2或更新的版本

在这种情况下:

  • 只能访问当前版本和仍然存在的历史版本
  • 被清理的版本无法访问,形成版本链截断
  • 查询可能无法获取到期望的历史版本
  • MVCC的完整历史视图能力受到限制

3. 长时间后读取的情况

当数据写入后很长时间才被读取:

-- 数据首次写入
INSERT INTO logs (id, message, created_at) VALUES (1, 'System started', NOW());
COMMIT;

-- 系统运行数月后读取
SELECT * FROM logs WHERE id = 1;  -- 直接读取当前版本
-- 由于是首次插入且长时间未修改,不存在版本链

在这种情况下:

  • 直接访问当前版本数据
  • 无需复杂的版本链遍历
  • 性能最优

4. 系统重启后的情况

系统重启后MVCC状态的恢复:

-- 系统重启前
UPDATE users SET age = 25 WHERE id = 1;
-- 系统重启...

-- 重启后查询
SELECT * FROM users WHERE id = 1;  -- 读取重启后的当前版本

系统重启后:

  • 活跃事务列表会被清空
  • Read View需要重新创建
  • 已持久化的数据版本仍然存在
  • 但内存中的版本链信息需要重新构建

长时间运行事务的影响

1. 长事务导致的版本积累

长时间运行的事务会阻止Undo Log的清理:

-- 长时间运行的事务A
BEGIN;
SELECT * FROM users WHERE id = 1;  -- 创建Read View
-- 长时间运行(几小时甚至几天)
-- 在此期间其他事务的修改都会保留Undo Log

-- 事务B、C、D等在此期间修改同一记录
UPDATE users SET age = 26 WHERE id = 1;
UPDATE users SET age = 27 WHERE id = 1;
UPDATE users SET age = 28 WHERE id = 1;

-- 长事务A再次查询时需要遍历很长的版本链
SELECT * FROM users WHERE id = 1;

2. 版本链过长的影响

-- 长事务保持Read View
BEGIN; -- REPEATABLE READ
SELECT COUNT(*) FROM users;

-- 其他事务频繁更新数据
-- 大量Undo Log无法清理

-- 长事务查询时性能下降
SELECT * FROM users WHERE id = 1; -- 需要遍历长版本链

这种情况会导致:

  • 查询性能下降(需要遍历长版本链)
  • 存储空间占用增加
  • 系统整体性能受影响

MVCC在不同操作中的应用

SELECT操作

普通SELECT语句(一致性非锁定读):

  • 根据当前事务隔离级别创建或复用Read View
  • 遍历版本链找到对当前事务可见的数据版本
  • 不加锁,提高并发性能

UPDATE操作

更新操作的处理流程:

  1. 加排他锁(X锁)
  2. 执行当前读,找到记录的最新版本
  3. 创建新版本数据,设置DB_TRX_ID为当前事务ID
  4. 将旧版本数据写入Undo Log,并设置DB_ROLL_PTR

DELETE操作

删除操作的处理流程:

  1. 加排他锁(X锁)
  2. 执行当前读,找到记录的最新版本
  3. 将记录标记为删除,而不是立即物理删除
  4. 创建Undo Log记录

MVCC的优化与限制

优势

  1. 高并发性能:读写操作互不阻塞,显著提高并发处理能力
  2. 减少锁竞争:避免了读操作对写操作的阻塞,减少锁的使用
  3. 一致性保证:通过Read View机制保证事务看到一致的数据视图
  4. 支持多种隔离级别:灵活支持不同级别的隔离需求

限制与挑战

  1. 存储开销:需要存储多个数据版本,增加存储空间使用
  2. 清理机制复杂:需要PURGE线程定期清理不再需要的旧版本数据
  3. Undo Log管理:需要有效管理Undo Log的生命周期和存储
  4. 长事务影响:长时间运行的事务可能导致大量旧版本数据无法清理

Undo Log清理机制

InnoDB通过PURGE线程来清理不再需要的Undo Log:

  1. 清理条件

    • 事务已提交
    • 没有活跃的Read View需要访问该版本
    • Undo Log不再被任何事务引用
  2. 清理过程

    • 从Undo Log中删除旧版本数据
    • 更新相关数据结构
    • 释放存储空间

实际应用场景分析

场景1:高并发读写系统

在电商系统中,商品信息的读取和更新操作频繁:

-- 大量用户同时查询商品信息(读操作)
SELECT name, price, stock FROM products WHERE id = 1001;

-- 后台系统更新商品价格(写操作)
UPDATE products SET price = 99.99 WHERE id = 1001;

通过MVCC,读操作可以访问商品信息的一致性快照,而写操作可以并发进行,两者互不阻塞。

场景2:报表统计系统

在数据统计系统中,长时间运行的统计查询:

-- 长时间运行的统计查询
SELECT category, COUNT(*), AVG(price) 
FROM products 
GROUP BY category;

MVCC保证统计查询过程中看到的数据是一致的,不会受到其他事务修改的影响。

性能调优建议

1. 合理设置隔离级别

根据业务需求选择合适的隔离级别,避免不必要的性能损耗。

2. 控制事务长度

避免长时间运行的事务,减少对Undo Log清理的影响。

3. 监控Undo Log使用情况

定期监控Undo Log的使用情况,及时发现和处理异常。

4. 优化查询设计

合理设计查询,避免不必要的全表扫描和大数据量操作。

总结

MVCC是MySQL InnoDB存储引擎实现高并发性能的关键技术。通过维护数据的多个版本、使用Undo Log记录历史数据、以及Read View机制控制数据可见性,MVCC实现了读写操作的并发执行,显著提高了数据库系统的并发处理能力。

理解MVCC的工作原理对于数据库性能优化、事务设计和问题排查都具有重要意义。在实际应用中,需要根据业务特点合理选择隔离级别,控制事务长度,并监控相关性能指标,以充分发挥MVCC的优势。

特别需要注意的是,在版本链不存在的特殊情况下(如首次插入或长时间后读取),MVCC机制会退化为简单的当前版本读取,但这并不影响其整体性能优势。掌握这些特殊情况的处理方式,有助于更好地设计和优化数据库应用。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容