事务与 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 淘汰 |