← 返回 MYSQL 列表

事务与 MVCC

事务与 MVCC

一、四大隔离级别的底层实现

1.1 隔离级别实现原理

隔离级别 实现方式 解决问题
READ UNCOMMITTED 直接读最新版本
READ COMMITTED MVCC + 每次语句生成新 Read View 脏读
REPEATABLE READ MVCC + 事务开始后第一个语句生成 Read View 并复用 脏读、不可重复读
SERIALIZABLE 所有读取都加锁(全部串行化) 脏读、不可重复读、幻读
READ COMMITTED:
  语句1 → 创建 Read View → 读取快照
  语句2 → 创建新的 Read View → 读取最新快照
  (每次语句独立快照)

REPEATABLE READ:
  第一个语句 → 创建 Read View → 复用这个快照
  语句1 → 复用 Read View → 读取同一快照
  语句2 → 复用 Read View → 读取同一快照
  (整个事务使用同一个快照)

1.2 READ COMMITTED vs REPEATABLE READ 演示

sql
-- 准备数据
CREATE TABLE account (id INT PRIMARY KEY, balance INT);
INSERT INTO account VALUES (1, 1000);

-- RC 下不可重复读演示
-- 事务A:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM account WHERE id = 1;  -- 读为 1000

-- 事务B(此时更新并提交):
UPDATE account SET balance = 500 WHERE id = 1;
COMMIT;

-- 事务A 再次查询:
SELECT balance FROM account WHERE id = 1;  -- 读为 500(两次不同!)
COMMIT;

-- RR 下不会出现不可重复读
-- 事务A:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM account WHERE id = 1;  -- 读为 1000

-- 事务B(此时更新并提交):
UPDATE account SET balance = 500 WHERE id = 1;
COMMIT;

-- 事务A 再次查询:
SELECT balance FROM account WHERE id = 1;  -- 依然是 1000(快照读)
COMMIT;

二、MVCC 多版本并发控制

2.1 MVCC 核心概念

MVCC(Multi-Version Concurrency Control)通过保存数据在某个时间点的多个版本来实现并发控制,让读操作不阻塞写操作、写操作不阻塞读操作。

MVCC 解决了

  • 读-写并发冲突(读不阻塞写,写不阻塞读)
  • 读写可以并发执行

2.2 核心组件:三个隐藏字段

InnoDB 每行数据都有三个隐藏列:

字段 说明
DB_TRX_ID 最近修改该行的事务ID(6字节)
DB_ROLL_PTR 回滚指针,指向 undo log 中的上一个版本(7字节)
DB_ROW_ID 行ID,无主键时用作聚簇索引(6字节)
sql
-- 查看隐藏字段(需先开启)
SET SESSION innodb_monitor_enable = all;
-- 但隐藏字段不能直接 SELECT,要通过特定方式查看

-- 可以查看事务ID
SELECT TRX_ID FROM information_schema.INNODB_TRX;

2.3 Undo Log 版本链

每行数据的多个版本通过 undo log 形成版本链:

第一次插入:(id=1, balance=1000)
            trx_id=100, roll_ptr=null

第一次更新:undo log 记录旧版本
当前行:    (id=1, balance=800,  trx_id=200, roll_ptr → 旧版本)
旧版本:    (id=1, balance=1000, trx_id=100, roll_ptr=null)

第二次更新:
当前行:    (id=1, balance=500,  trx_id=300, roll_ptr → 旧版本1)
旧版本1:   (id=1, balance=800,  trx_id=200, roll_ptr → 旧版本2)
旧版本2:   (id=1, balance=1000, trx_id=100, roll_ptr=null)
版本链图示:

[当前行] balance=500, trx_id=300
    ↓ roll_ptr
[旧版本1] balance=800, trx_id=200
    ↓ roll_ptr
[旧版本2] balance=1000, trx_id=100
    ↓ roll_ptr
null

2.4 Read View(读视图)

Read View 是 MVCC 实现的关键,它决定了当前事务能看到哪些版本

Read View 包含

字段 说明
creator_trx_id 创建该 Read View 的事务ID
low_limit_id 下一个待分配的事务ID(当前最大事务ID+1)
up_limit_id 当前活跃(未提交)事务中最小的ID
trx_ids 当前所有活跃(未提交)的事务ID列表

2.5 可见性判断规则

当前事务是否需要看某个版本?
         ↓
  版本的事务ID = creator_trx_id?
  ├─ YES → 自己是自己修改的,可见
  └─ NO  → 继续判断
         ↓
  版本的事务ID < up_limit_id?
  ├─ YES → 事务已提交,可见
  └─ NO  → 继续判断
         ↓
  版本的事务ID >= low_limit_id?
  ├─ YES → 是创建 Read View 之后才开始的事务,不可见
  └─ NO  → 继续判断
         ↓
  版本的事务ID 在 trx_ids 中?
  ├─ YES → 未提交,不可见
  └─ NO  → 已提交,可见

2.6 MVCC 工作流程实战

sql
-- 初始数据:account(id=1, balance=1000)

-- 时间线 T0:
-- 当前活跃事务:trx_id=101(未提交), trx_id=102(未提交)
-- account 版本链:
--   当前: balance=1000, trx_id=100

-- 事务A(trx_id=103)开始:
BEGIN;
-- 此时创建 Read View:
--   creator_trx_id=103, up_limit_id=101, low_limit_id=104
--   trx_ids=[101, 102]

-- 查询 account 版本链:
--   当前: balance=1000, trx_id=100
--   trx_id=100 < up_limit_id=101 → 可见!
-- 读到 balance=1000

-- 事务B(trx_id=101)提交:
-- 事务C(trx_id=104,新开启)更新 account:
UPDATE account SET balance = 500 WHERE id = 1;
-- 版本链变为:
--   当前: balance=500, trx_id=104, roll_ptr → 旧版本
--   旧版本: balance=1000, trx_id=100, roll_ptr=null

-- 事务A 再次查询(RR 级别复用之前的 Read View):
-- 遍历版本链:
--   当前: trx_id=104 >= low_limit_id=104 → 不可见
--   旧版本: trx_id=100 < up_limit_id=101 → 可见!
-- 仍然读到 balance=1000(一致性快照)

-- 如果是 RC 级别:
-- 会创建新的 Read View: up_limit_id=102, low_limit_id=105, trx_ids=[102]
-- 当前: trx_id=104 不在 trx_ids 且 <105 → 可见
-- 读到 balance=500

三、事务日志

3.1 Redo Log(重做日志)

作用:保证事务的持久性(Durability)。事务提交时,将修改记录到 redo log,即使宕机也能通过 redo log 恢复。

工作原理

事务提交顺序:
  1. 修改数据页(Buffer Pool)
  2. 写入 Redo Log Buffer
  3. COMMIT 时,将 Redo Log Buffer 刷盘(fsync)
  4. 返回提交成功
  5. (后台)将脏页刷入磁盘数据文件

Redo Log 配置

sql
-- 查看 redo log 相关配置
SHOW VARIABLES LIKE 'innodb_log_file_size';      -- 每个 redo log 文件大小(默认 48MB)
SHOW VARIABLES LIKE 'innodb_log_files_in_group';  -- 文件组数量(默认 2)
SHOW VARIABLES LIKE 'innodb_log_buffer_size';     -- 缓冲大小(默认 16MB)
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';

-- innodb_flush_log_at_trx_commit 取值:
-- 0:每秒刷盘(可能丢1秒数据)
-- 1:每次提交刷盘(最安全,性能最低)← 默认
-- 2:每次提交写入 OS cache,每秒刷盘(OS 崩溃丢1秒数据)

3.2 Undo Log(回滚日志)

作用

  • 保证事务的原子性(Atomicity):事务回滚时恢复到修改前的状态
  • 实现 MVCC:提供数据的历史版本

工作流程

sql
BEGIN;
UPDATE account SET balance = 800 WHERE id = 1;
-- InnoDB 将旧值(1000)写入 undo log

UPDATE account SET balance = 500 WHERE id = 1;
-- InnoDB 将旧值(800)写入 undo log

-- 如果 ROLLBACK:
-- 根据 undo log 逐步恢复:500 → 800 → 1000

-- 如果 COMMIT:
-- undo log 不会立即删除(可能有其他事务需要旧版本)
-- 当没有事务需要这些旧版本时,purge 线程清理

3.3 Redo Log vs Undo Log

对比 Redo Log Undo Log
作用 持久性,崩溃恢复 原子性,MVCC
内容 物理修改(页级别) 逻辑记录(反操作)
写入时机 事务提交时刷盘 事务执行时写入
清理时机 循环覆盖 无事务引用后 purge
存储 ib_logfile0/1 ibdata1 或独立表空间

3.4 事务提交完整流程

BEGIN;
  ↓
UPDATE account SET balance = 500 WHERE id = 1;
  ├── 在 Buffer Pool 中修改数据页(标记为脏页)
  ├── 写入 Undo Log(记录旧值 1000)
  ├── 写入 Redo Log Buffer(记录修改)
  ↓
SELECT ...  -- 读取 Buffer Pool 中的最新数据
  ↓
COMMIT;
  ├── Redo Log Buffer → fsync → Redo Log File(关键保证)
  ├── 写入 Binlog
  ├── 释放锁
  ├── 返回客户端"提交成功"
  ↓
(后台异步)
  ├── Buffer Pool 脏页 → 刷入磁盘数据文件
  └── Undo Log → 无引用后 purge

两阶段提交(Redo Log + Binlog 一致性):

1. Prepare 阶段:写入 Redo Log,状态为 PREPARE
2. 写入 Binlog
3. Commit 阶段:Redo Log 状态改为 COMMIT

崩溃恢复时:
  - 如果 Redo Log 是 PREPARE 且 Binlog 存在 → 提交
  - 如果 Redo Log 是 PREPARE 且 Binlog 不存在 → 回滚
  - 如果 Redo Log 是 COMMIT → 直接提交

四、实战案例

案例1:MVCC 解决并发读写冲突

sql
-- 场景:报表统计 + 实时更新
-- 事务A:统计所有用户余额总和(耗时查询)

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;

-- 事务A 读取快照(此时创建 Read View)
SELECT SUM(balance) FROM account;  -- 假设结果是 10000

-- 同时,事务B 在转账
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 事务A 再次统计(RR 级别下仍然是之前的快照)
SELECT SUM(balance) FROM account;  -- 结果仍然是 10000(一致性快照)

-- 如果不使用 MVCC,事务B 会阻塞事务A 的查询
COMMIT;

案例2:redo log 崩溃恢复

sql
-- 场景:事务执行过程中 MySQL 宕机

-- 已有数据:account(id=1, balance=1000)

-- 步骤1:执行事务
BEGIN;
UPDATE account SET balance = 500 WHERE id = 1;
-- Redo Log Buffer: page_5, offset_100, old=1000, new=500
-- Undo Log: id=1, balance=1000

COMMIT;
-- 步骤2:Redo Log Buffer 刷盘 → Redo Log File(成功)
-- 步骤3:返回客户端"提交成功"

-- 此时 MySQL 宕机(数据文件还没更新)
-- 重启时:

-- 步骤4:MySQL 恢复流程
-- 1. 读取 Redo Log,发现 page_5 需要重做
-- 2. 将 page_5 恢复为 balance=500
-- 3. 数据一致性得到保证

-- 如果是在 BEGIN 和 COMMIT 之间宕机:
-- 事务没有 COMMIT,Redo Log 中无该事务的记录
-- Undo Log 记录也不需要恢复(事务未提交)
-- 数据自动回滚到事务前的状态

案例3:MVCC 可见性实战

sql
-- 建表
CREATE TABLE t (id INT PRIMARY KEY, val INT);
INSERT INTO t VALUES (1, 100);

-- 时间线分析:

/* T1 */ BEGIN;  -- 事务A(trx_id=100)
/* T2 */ BEGIN;  -- 事务B(trx_id=101)
/* T3 */ UPDATE t SET val = 200 WHERE id = 1;  -- 事务B
/* T4 */ BEGIN;  -- 事务C(trx_id=102)
/* T5 */ UPDATE t SET val = 300 WHERE id = 1;  -- 事务C
/* T6 */ SELECT val FROM t WHERE id = 1;  -- 事务A,RR级别
-- 事务A 的 Read View:
--   creator_trx_id=100, up_limit_id=100, low_limit_id=103
--   trx_ids=[100, 101, 102]
-- 版本链:
--   val=300, trx_id=102 → trx_id=102 >= low_limit_id? NO, in trx_ids? YES → 不可见
--   val=200, trx_id=101 → trx_id=101 in trx_ids? YES → 不可见
--   val=100, trx_id=? (系统事务) → 小于 up_limit_id → 可见
-- 结果:val=100

/* T7 */ COMMIT;  -- 事务C
/* T8 */ SELECT val FROM t WHERE id = 1;  -- 事务A,RR级别(同一 Read View)
-- 结果:仍然是 val=100(RR 复用快照)

/* T9 */ COMMIT;  -- 事务B
/* T10 */ SELECT val FROM t WHERE id = 1;  -- 事务A,RR级别
-- 结果:仍然是 val=100

/* T11 */ COMMIT;  -- 事务A

-- 如果是 RC 级别,在 T8 和 T10 会创建新的 Read View:
-- T8: trx_ids=[101](事务B未提交),val=200 不可见 → val=100
-- T9: 事务B提交
-- T10: trx_ids=[](无活跃事务),val=300 和 val=200 都可见
-- 实际会取最新版本:val=300

总结

知识点 核心要点
RC 实现 每语句创建 Read View,不可重复读
RR 实现 事务开始后第一个查询创建 Read View 并复用
MVCC 三组件 隐藏字段(DB_TRX_ID, DB_ROLL_PTR)、Undo Log 版本链、Read View
可见性规则 比 up_limit_id 小可⻅、比 low_limit_id 大不可见、在 trx_ids 中不可见
Redo Log 保证持久性、WAL 技术、崩溃恢复
Undo Log 保证原子性、提供 MVCC 版本链
两阶段提交 Redo Log Prepare → Binlog → Redo Log Commit
Buffer Pool 缓存数据页、延迟刷盘、LRU 淘汰