Skip to content
清晨的一缕阳光
返回

事务隔离级别与 MVCC

引言

MVCC(多版本并发控制)是 InnoDB 实现高并发读写的核心机制,它让读写操作互不阻塞,大幅提升了数据库的并发性能。理解 MVCC 原理,对于掌握事务隔离级别的实现至关重要。

本章将深入讲解:

事务隔离级别详解

四种隔离级别对比

-- 查看当前隔离级别
SELECT @@transaction_isolation;

-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

完整对比表:

隔离级别脏读不可重复读幻读锁机制并发性能
READ UNCOMMITTED⚠️⚠️⚠️无锁⭐⭐⭐⭐⭐
READ COMMITTED⚠️⚠️行锁⭐⭐⭐⭐
REPEATABLE READ⚠️(部分)行锁 + 间隙锁⭐⭐⭐
SERIALIZABLE表锁⭐⭐

READ UNCOMMITTED(读未提交)

特点:

示例:

-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;  -- 读到 1000

-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;  -- 未提交

-- 会话 1
SELECT balance FROM accounts WHERE id = 1;  -- 读到 900(脏读!)

-- 会话 2
ROLLBACK;  -- 回滚

-- 会话 1
SELECT balance FROM accounts WHERE id = 1;  -- 变回 1000
-- 会话 1 发现之前读到的是脏数据!

适用场景:

READ COMMITTED(读已提交)

特点:

示例:

-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;  -- 读到 1000

-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;  -- 提交

-- 会话 1
SELECT balance FROM accounts WHERE id = 1;  -- 读到 900
-- 同一事务内两次读取结果不同(不可重复读)!

MySQL 实现:

REPEATABLE READ(可重复读)

特点:

示例:

-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;  -- 读到 1000

-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;  -- 提交

-- 会话 1
SELECT balance FROM accounts WHERE id = 1;  -- 仍然读到 1000
-- 即使会话 2 已提交,会话 1 仍读到事务开始时的数据!

MySQL 实现:

SERIALIZABLE(串行化)

特点:

示例:

-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT * FROM accounts WHERE balance > 500;

-- 会话 2
START TRANSACTION;
INSERT INTO accounts (id, balance) VALUES (101, 600);
-- 阻塞!等待会话 1 提交

-- 会话 1
COMMIT;

-- 会话 2
-- 现在可以执行了

实现方式:

MVCC 核心原理

什么是 MVCC

MVCC(Multi-Version Concurrency Control) 多版本并发控制,是一种提高并发性能的机制。

核心思想:

graph LR
    subgraph 传统锁机制
        A1[读操作] -->|加共享锁 | B1[数据]
        A2[写操作] -->|加排他锁 | B1
        A1 -.阻塞.-> A2
    end
    
    subgraph MVCC 机制
        A3[读操作] -->|读历史版本 | B2[数据版本 1]
        A4[写操作] -->|创建新版本 | B3[数据版本 2]
        A3 -.不阻塞.-> A4
    end
    
    style A1 fill:#ffcccc
    style A2 fill:#ffcccc
    style A3 fill:#ccffcc
    style A4 fill:#ccffcc

MVCC 实现三要素

InnoDB 通过以下三个要素实现 MVCC:

  1. 隐藏列:每行数据自带的隐藏字段
  2. Undo Log:存储数据的历史版本
  3. Read View:事务读取数据时的快照
-- InnoDB 每行数据的隐藏列
-- 1. DB_TRX_ID:最近修改该行的事务 ID
-- 2. DB_ROLL_PTR:回滚指针,指向 Undo Log
-- 3. DB_ROW_ID:行 ID(无主键时自动生成)

-- 用户不可见,但 InnoDB 内部使用

隐藏列详解

表结构:

CREATE TABLE accounts (
    id BIGINT PRIMARY KEY,
    balance DECIMAL(10,2)
) ENGINE=InnoDB;

实际存储(InnoDB 内部):

+----+---------+-------------+---------------+-------------+
| id | balance | DB_TRX_ID   | DB_ROLL_PTR   | DB_ROW_ID   |
+----+---------+-------------+---------------+-------------+
| 1  | 1000    | 101         | NULL          | 1           |
+----+---------+-------------+---------------+-------------+

隐藏列说明:

列名大小说明
DB_TRX_ID6 字节最近修改该行的事务 ID
DB_ROLL_PTR7 字节回滚指针,指向 Undo Log
DB_ROW_ID6 字节行 ID(无主键时自动生成)

Undo Log 版本链

Undo Log 记录数据的历史版本:

初始状态:
+----+---------+-------------+---------------+
| id | balance | DB_TRX_ID   | DB_ROLL_PTR   |
+----+---------+-------------+---------------+
| 1  | 1000    | 100         | NULL          |
+----+---------+-------------+---------------+

事务 101 修改:
UPDATE accounts SET balance = 900 WHERE id = 1;

+----+---------+-------------+---------------+
| id | balance | DB_TRX_ID   | DB_ROLL_PTR   |
+----+---------+-------------+---------------+
| 1  | 900     | 101         | → Undo Log    |
+----+---------+-------------+---------------+

              Undo Log: {balance: 1000, trx_id: 100}

事务 102 修改:
UPDATE accounts SET balance = 800 WHERE id = 1;

+----+---------+-------------+---------------+
| id | balance | DB_TRX_ID   | DB_ROLL_PTR   |
+----+---------+-------------+---------------+
| 1  | 800     | 102         | → Undo Log    |
+----+---------+-------------+---------------+

              Undo Log: {balance: 900, trx_id: 101}

              Undo Log: {balance: 1000, trx_id: 100}

-- 形成版本链!

版本链的特点:

Read View 读视图

Read View 是事务读取数据时的快照,包含以下信息:

// Read View 结构
struct ReadView {
    trx_id_t creator_trx_id;     // 创建 Read View 的事务 ID
    trx_id_t min_trx_id;         // 活跃事务中最小的事务 ID
    trx_id_t max_trx_id;         // 下一个要分配的事务 ID
    vector<trx_id_t> m_ids;      // 活跃事务 ID 列表
};

字段说明:

字段说明示例
creator_trx_id当前事务 ID105
min_trx_id活跃事务最小 ID101
max_trx_id下一个事务 ID110
m_ids活跃事务 ID 列表[101, 103, 105]

活跃事务: 已启动但未提交的事务

可见性判断规则

当事务读取数据时,通过以下规则判断版本可见性:

graph TD
    A[读取数据行] --> B{获取 DB_TRX_ID}
    B --> C{DB_TRX_ID = 当前事务 ID?}
    C -->|是 | D[可见 - 自己修改的]
    C -->|否 | E{DB_TRX_ID < min_trx_id?}
    E -->|是 | F[可见 - 已提交事务]
    E -->|否 | G{DB_TRX_ID >= max_trx_id?}
    G -->|是 | H[不可见 - 未来事务]
    G -->|否 | I{DB_TRX_ID 在 m_ids 中?}
    I -->|是 | J[不可见 - 未提交事务]
    I -->|否 | K[可见 - 已提交事务]
    
    style D fill:#ccffcc
    style F fill:#ccffcc
    style H fill:#ffcccc
    style J fill:#ffcccc
    style K fill:#ccffcc

判断规则详解:

-- 规则 1:自己修改的数据可见
DB_TRX_ID == creator_trx_id → 可见

-- 规则 2:已提交事务的修改可见
DB_TRX_ID < min_trx_id → 可见

-- 规则 3:未来事务的修改不可见
DB_TRX_ID >= max_trx_id → 不可见

-- 规则 4:活跃事务的修改不可见
DB_TRX_ID in m_ids → 不可见
DB_TRX_ID not in m_ids → 可见(已提交)

示例:

Read View:
- creator_trx_id: 105
- min_trx_id: 101
- max_trx_id: 110
- m_ids: [101, 103, 105]

数据版本判断:
- DB_TRX_ID = 105 → 可见(自己修改的)
- DB_TRX_ID = 100 → 可见(100 < 101,已提交)
- DB_TRX_ID = 101 → 不可见(在 m_ids 中,未提交)
- DB_TRX_ID = 102 → 可见(不在 m_ids 中,已提交)
- DB_TRX_ID = 110 → 不可见(110 >= 110,未来事务)

不同隔离级别的 MVCC 行为

READ COMMITTED 的 MVCC

特点:每次 SELECT 都生成新的 Read View

-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- Read View 1: min_trx_id=100, max_trx_id=105, m_ids=[]
-- 读到 balance=1000

-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;

-- 会话 1
SELECT balance FROM accounts WHERE id = 1;
-- 生成新的 Read View 2: min_trx_id=101, max_trx_id=106, m_ids=[]
-- 读到 balance=900(会话 2 已提交,可见)
-- 不可重复读!

时序图:

会话 1                          会话 2
------                          ------
BEGIN
SELECT (Read View 1)
  balance=1000
                              BEGIN
                              UPDATE balance=900
                              COMMIT
SELECT (Read View 2)
  balance=900  ← 读到新数据

REPEATABLE READ 的 MVCC

特点:事务开始生成一次 Read View,全程使用

-- 会话 1
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- Read View: min_trx_id=100, max_trx_id=105, m_ids=[]
-- 读到 balance=1000

-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;

-- 会话 1
SELECT balance FROM accounts WHERE id = 1;
-- 使用同一个 Read View
-- 读到 balance=1000(版本 100 < min_trx_id,可见)
-- 可重复读!

时序图:

会话 1                          会话 2
------                          ------
BEGIN
SELECT (Read View)
  balance=1000
                              BEGIN
                              UPDATE balance=900
                              COMMIT
SELECT (同一 Read View)
  balance=1000  ← 仍读旧数据

幻读的 MVCC 解决方案

MVCC 解决快照读(普通 SELECT)的幻读:

-- 会话 1(REPEATABLE READ)
START TRANSACTION;
SELECT * FROM accounts WHERE balance > 500;
-- 返回 10 条记录
-- Read View: max_trx_id=105

-- 会话 2
INSERT INTO accounts (id, balance) VALUES (101, 600);
COMMIT;

-- 会话 1
SELECT * FROM accounts WHERE balance > 500;
-- 仍然返回 10 条记录!
-- 新插入的事务 ID=106 >= max_trx_id=105,不可见

当前读(加锁读)的幻读通过间隙锁解决:

-- 会话 1
SELECT * FROM accounts WHERE balance > 500 FOR UPDATE;
-- 加间隙锁,阻止其他事务插入

-- 会话 2
INSERT INTO accounts (id, balance) VALUES (101, 600);
-- 阻塞!等待会话 1 提交

MVCC 实战分析

案例 1:读写不冲突

-- 会话 1(读)
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;

-- 会话 2(写)
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
-- 不阻塞!

-- 会话 1
COMMIT;

-- 会话 2
COMMIT;
-- 读写并发,性能高

案例 2:写写冲突

-- 会话 1
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;

-- 会话 2
START TRANSACTION;
UPDATE accounts SET balance = 800 WHERE id = 1;
-- 阻塞!等待会话 1 释放锁

-- 会话 1
COMMIT;

-- 会话 2
-- 获得锁,继续执行
COMMIT;

案例 3:长事务问题

-- 长事务
START TRANSACTION;
SELECT * FROM large_table;
-- 长时间未提交

-- 问题:
-- 1. Read View 长期持有,无法清理旧版本
-- 2. Undo Log 无法回收,占用空间
-- 3. 其他事务看不到最新数据

-- 解决:
-- 1. 避免长事务
-- 2. 定期监控 INNODB_TRX
-- 3. 设置 innodb_max_undo_logs

注意事项

1. MVCC 的代价

空间开销:

性能开销:

2. Undo Log 清理

-- Purge 线程负责清理不再需要的 Undo Log
-- 当没有事务需要某个版本时,Purge 会删除它

-- 查看 Purge 状态
SHOW ENGINE INNODB STATUS;

-- 配置 Purge 线程数
innodb_purge_threads = 4  -- 默认 4 个线程

3. 版本链过长

-- 问题:版本链过长,查询性能下降
-- 原因:长事务未提交,旧版本无法清理

-- 监控:
SELECT 
    trx_id,
    trx_state,
    trx_started,
    TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) as duration_seconds
FROM information_schema.INNODB_TRX
ORDER BY trx_started;

-- 解决:
-- 1. 避免长事务
-- 2. 杀掉长事务 KILL

4. 隔离级别选择

-- 推荐配置:

-- 大多数场景(默认)
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 需要与 Oracle 兼容
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 统计报表(允许脏读)
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

-- 特殊场景(完全串行)
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

总结

MVCC 核心要点

  1. 三要素:隐藏列、Undo Log、Read View
  2. 版本链:通过回滚指针串联历史版本
  3. 可见性:通过 Read View 判断版本是否可见
  4. 读写不阻塞:读历史版本,写新版本

隔离级别与 MVCC

隔离级别Read View 生成时机并发问题
READ UNCOMMITTED不生成 Read View脏读、不可重复读、幻读
READ COMMITTED每次 SELECT不可重复读、幻读
REPEATABLE READ事务开始部分幻读
SERIALIZABLE加锁

最佳实践

  1. 使用默认隔离级别:REPEATABLE READ
  2. 避免长事务:及时提交或回滚
  3. 监控 Undo Log:防止版本链过长
  4. 理解快照读 vs 当前读:选择正确的查询方式

下一步

掌握 MVCC 后,下一章我们将学习:

参考资料


分享这篇文章到:

上一篇文章
Java 包装类型详解
下一篇文章
Java 日期时间 API 详解