Lesson 61 · 数据库与中间件实战

MySQL 事务与 MVCC:四种隔离级别与 ReadView

深度·⭐ 必问·#MySQL·#事务·#MVCC

深度 · 面试必问

MySQL 事务与 MVCC:四种隔离级别与 ReadView

Read Uncommitted → Read Committed → Repeatable Read → Serializable。MVCC 的 undo log + ReadView 机制。RR 级别下怎么防幻读?

ACID隔离级别MVCCReadViewNext-Key Lock
第 1 站

ACID 四大特性

事务是数据库的核心抽象,它保证一组操作要么全部成功,要么全部回滚。ACID 是事务的四大基石:

特性含义实现机制被谁破坏
Atomicity 原子性事务中的操作不可分割,全部成功或全部回滚undo log(回滚日志)
Consistency 一致性事务前后数据库从一个一致状态到另一个一致状态A + I + D 共同保证
Isolation 隔离性并发事务之间互不干扰锁 + MVCC隔离级别不够时出现脏读/不可重复读/幻读
Durability 持久性事务提交后数据永久保存,即使宕机也不丢失redo log(重做日志)

为什么 undo log 保证原子性,redo log 保证持久性?

undo log 记录的是"反向操作"(INSERT 的 undo 是 DELETE),用于事务回滚时恢复原状 → 原子性。redo log 记录的是"修改后的值",在 crash 后用 redo log 恢复已提交但未刷盘的数据 → 持久性。这就是"先写日志"(WAL)的核心思想。

关键结论

Atomicity 靠 undo log,Durability 靠 redo log,Isolation 靠锁 + MVCC,Consistency 是最终目标由前三者共同保证。

第 2 站

四种隔离级别与并发问题

SQL 标准定义了四种隔离级别,从低到高,并发问题越来越少,但性能开销越来越大:

Read Uncommitted 读未提交 Read Committed 读已提交 Repeatable Read 可重复读(默认) Serializable 串行化 脏读 ✗ 不可重复读 ✗ 幻读 ✗ 脏读 ✓ 解决 不可重复读 ✗ 幻读 ✗ 脏读 ✓ 不可重复读 ✓ 幻读 部分解决 脏读 ✓ 不可重复读 ✓ 幻读 ✓ 隔离级别越高 → 并发能力越低 → 锁等待/死锁概率越高 InnoDB 默认 Repeatable Read,通过 MVCC + Next-Key Lock 解决大部分问题
图 2-1 四种隔离级别与并发问题的关系

三种并发问题的精确定义:

问题定义场景
脏读事务 A 读到了事务 B 尚未提交的数据B 修改 x=100 但没提交,A 读到 x=100,B 回滚后 A 的数据就"脏"了
不可重复读事务 A 两次读同一行,结果不同(被别的事务 UPDATE 了)A 读 x=50,B 提交 UPDATE x=100,A 再读 x=100
幻读事务 A 两次按相同条件查,行数不同(别的事务 INSERT/DELETE 了)A 查 age=25 有 3 行,B 插入 age=25 的新行并提交,A 再查有 4 行
设置隔离级别
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- 返回: REPEATABLE-READ

-- 修改当前会话隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 修改全局(新连接生效)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 在 my.cnf 中配置
[mysqld]
transaction-isolation = READ-COMMITTED
脏读场景演示
-- 隔离级别: READ UNCOMMITTED

-- 事务 A:
BEGIN;
SELECT balance FROM account WHERE id=1; -- 返回 100

-- 事务 B(此时执行):
BEGIN;
UPDATE account SET balance=200 WHERE id=1; -- 未提交!

-- 事务 A(再次查询):
SELECT balance FROM account WHERE id=1; -- 返回 200(脏读!)

-- 事务 B 回滚:
ROLLBACK;
-- A 看到的 200 是"脏"的,B 根本没提交

为什么 InnoDB 默认用 Repeatable Read 而不是 Read Committed?

历史原因:MySQL 早期(5.0 之前)binlog 格式只有 STATEMENT,在 RC 级别下主从复制可能出现数据不一致。RR 级别配合 Gap Lock 能保证 STATEMENT 格式 binlog 的安全性。虽然 MySQL 5.1+ 引入了 ROW 格式 binlog 已经解决了这个问题,但默认值保持不变。很多互联网公司实际用的是 RC + ROW binlog。

第 3 站

MVCC 实现机制——undo log 版本链

MVCC(Multi-Version Concurrency Control)多版本并发控制,是 InnoDB 在 RC 和 RR 级别下实现"读写不冲突"的核心机制。它的本质是:同一行数据保存多个历史版本,每个事务看到的是自己"应该看到的"版本。

undo log 版本链 当前行(聚簇索引) name='Alice', age=30 trx_id=100, roll_pointer → undo log v1 name='Alice', age=28 trx_id=80, roll_pointer → undo log v2 name='Alice', age=25 trx_id=50, roll_pointer → NULL 隐藏列 DB_TRX_ID (6字节): 最近修改该行的事务 ID DB_ROLL_PTR (7字节): 回滚指针,指向 undo log 中的上一个版本 DB_ROW_ID (6字节): 没有主键时 InnoDB 自动生成的行 ID 时间线: trx50(age=25) → trx80(age=28) → trx100(age=30) 每个版本通过 roll_pointer 串成链表,MVCC 根据 ReadView 决定事务看到哪个版本
图 3-1 undo log 版本链:每次 UPDATE 产生旧版本的 undo 记录

undo log 什么时候可以清理?

当没有任何活跃事务需要访问某个旧版本时,该 undo log 就可以被 purge 线程清理。具体来说,当最老的活跃事务的 trx_id 大于该版本的 trx_id 时,说明没有人需要它了。这就是为什么长事务会阻止 undo log 清理,导致磁盘空间膨胀。

长事务的危害与排查
-- 查看当前所有活跃事务
SELECT * FROM information_schema.INNODB_TRX;

-- 关键字段:
-- trx_id:          事务 ID
-- trx_state:       RUNNING / LOCK WAIT
-- trx_started:     事务开始时间(关注运行很久的事务)
-- trx_rows_locked: 锁住的行数
-- trx_query:       当前正在执行的 SQL

-- 长事务的危害:
-- 1. undo log 无法清理 → 磁盘空间膨胀(undo tablespace)
-- 2. 长时间持锁 → 阻塞其他事务 → 死锁概率增大
-- 3. 回滚时间长 → 影响数据库整体性能

-- 解决方案:
-- 1. 设置事务超时:innodb_lock_wait_timeout
-- 2. 应用端控制事务长度(不要在事务中做 RPC 调用)
-- 3. 定期监控并 kill 长时间运行的事务
关键结论

undo log 版本链是 MVCC 的数据基础。每次 UPDATE 产生旧版本,通过 roll_pointer 串联。purge 线程在安全时清理旧版本。长事务会阻止清理并导致各种问题。

第 4 站

ReadView 可见性判断

ReadView 是 MVCC 的核心控制器,它在事务执行快照读(普通 SELECT)时生成,决定了当前事务能看到哪些版本的数据。

ReadView 结构 ReadView creator_trx_id: 创建此 ReadView 的事务 ID(如 100) m_ids: 创建时所有活跃(未提交)事务 ID 列表 [80, 95] min_trx_id: m_ids 中的最小值 = 80 max_trx_id: 系统即将分配的下一个事务 ID = 101 可见性判断规则(针对某行版本的 trx_id) ① trx_id < min_trx_id → 可见(该版本在 ReadView 创建前已提交) ② trx_id >= max_trx_id → 不可见(该版本在 ReadView 创建后才出现) ③ min_trx_id <= trx_id < max_trx_id: → 如果 trx_id 在 m_ids 中 → 不可见(创建时还没提交) → 如果 trx_id 不在 m_ids 中 → 可见(创建时已提交)
图 4-1 ReadView 的四个关键字段与可见性判断规则
可见性判断伪代码
function isVisible(row_trx_id, readView):
    if row_trx_id == readView.creator_trx_id:
        return true   // 自己修改的,当然可见
    if row_trx_id < readView.min_trx_id:
        return true   // 在 ReadView 创建前就已提交
    if row_trx_id >= readView.max_trx_id:
        return false  // 在 ReadView 创建后才开始的事务
    if row_trx_id in readView.m_ids:
        return false  // 创建 ReadView 时该事务还没提交
    return true       // 创建 ReadView 时该事务已提交
关键结论

ReadView 的可见性判断本质是:在创建 ReadView 那一刻,哪些事务已经提交了。已提交的可见,未提交的不可见,之后开始的更不可见。

第 5 站

RR vs RC——ReadView 的创建时机不同

Repeatable Read 和 Read Committed 的核心区别,就在于 ReadView 的创建时机

RC vs RR 的 ReadView 创建时机 Read Committed 每次 SELECT 都创建新的 ReadView SELECT 1 → 创建 ReadView_A → 看到 x=50 (别的线程提交 UPDATE x=100) SELECT 2 → 创建 ReadView_B → 看到 x=100 两次读取结果不同 → 不可重复读 Repeatable Read 只在第一次 SELECT 时创建 ReadView SELECT 1 → 创建 ReadView_A → 看到 x=50 (别的线程提交 UPDATE x=100) SELECT 2 → 复用 ReadView_A → 看到 x=50 两次读取结果相同 → 可重复读 ✓
图 5-1 RC 每次 SELECT 创建 ReadView,RR 只在第一次 SELECT 时创建

RR 级别下,事务 A 中先 UPDATE 再 SELECT,能看到自己改的值吗?

能。MVCC 的可见性判断有一条特殊规则:row_trx_id == creator_trx_id 时直接可见。自己的修改永远对自己可见,不受 ReadView 限制。

关键结论

RC = 每次快照读创建新 ReadView → 能读到别人刚提交的修改 → 不可重复读。
RR = 只在第一次快照读创建 ReadView,后续复用 → 看到的始终是同一快照 → 可重复读。

第 6 站

当前读 vs 快照读

MVCC 只作用于快照读(Snapshot Read),即普通的 SELECT ... FROM ...。而当前读(Current Read)则直接读取最新已提交的版本,并加锁。

读类型SQL 示例行为是否受 MVCC 控制
快照读SELECT * FROM t WHERE id=1读取 MVCC 版本链中 ReadView 可见的版本✅ 是
当前读SELECT ... FOR UPDATE读取最新已提交数据,加 X 锁❌ 否
当前读SELECT ... LOCK IN SHARE MODE读取最新已提交数据,加 S 锁❌ 否
当前读INSERT / UPDATE / DELETE读取最新数据并修改,加 X 锁❌ 否
快照读与当前读的行为差异
-- 事务 A(RR 级别)
BEGIN;
SELECT balance FROM account WHERE id=1;   -- 快照读 → 100(创建 ReadView)
  -- 此时事务 B 执行: UPDATE account SET balance=200 WHERE id=1; COMMIT;
SELECT balance FROM account WHERE id=1;   -- 快照读 → 100(复用 ReadView)
SELECT balance FROM account WHERE id=1 FOR UPDATE; -- 当前读 → 200(读最新 + 加锁)
面试关键点

MVCC 解决了"读写冲突"——读不阻塞写,写不阻塞读(快照读)。但当前读会加锁,会阻塞其他写操作。所以 MVCC 提升的是快照读的并发性能

InnoDB 锁类型总览 共享锁 (S Lock) LOCK IN SHARE MODE 读读共享,读写互斥 排他锁 (X Lock) FOR UPDATE / INSERT / UPDATE / DELETE 读写互斥,写写互斥 意向锁 (IS/IX) 表级锁,自动加 加速表锁冲突检测 锁兼容性矩阵 S + S = ✅ 兼容  |  S + X = ❌ 冲突  |  X + X = ❌ 冲突 快照读不加锁 → 与任何锁都兼容 → 这就是 MVCC 的威力
图 6-1 InnoDB 三种行锁:共享锁、排他锁、意向锁
死锁排查
-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS;

-- 开启死锁日志(记录每次死锁到 error log)
SET GLOBAL innodb_print_all_deadlocks = ON;

-- 设置锁等待超时
SET innodb_lock_wait_timeout = 10; -- 默认 50 秒

-- 常见死锁场景:
-- 事务 A: LOCK row1 → 等待 row2
-- 事务 B: LOCK row2 → 等待 row1
-- 解决方案: 统一加锁顺序 / 缩短事务 / 降低隔离级别
第 7 站

RR 级别下的幻读防护——Next-Key Lock

很多人说"MySQL RR 级别解决了幻读",这个说法不完全准确。准确地说:InnoDB 在 RR 级别下通过 MVCC 解决了快照读的幻读,通过 Next-Key Lock 解决了当前读的幻读

Next-Key Lock = Record Lock + Gap Lock 10 20 30 40 (-∞,10) (10,20) (20,30) (30,40) (40,+∞) Record Lock Gap Lock (10,20) Next-Key Lock 锁住了 (10, 20] SELECT * FROM t WHERE age = 20 FOR UPDATE; Record Lock: 锁住 age=20 这一行 Gap Lock: 锁住 (10, 20) 这个间隙 → 阻止别的事务在这个范围插入新行 → 防止幻读
图 7-1 Next-Key Lock = 记录锁 + 间隙锁,同时锁住行和间隙
幻读防护示例
-- 事务 A(RR 级别)
BEGIN;
-- 当前读:加 Next-Key Lock
SELECT * FROM user WHERE age = 25 FOR UPDATE;
-- 锁住 age=25 的行 + (前一个索引值, 25) 的间隙

-- 事务 B 尝试插入
INSERT INTO user (name, age) VALUES ('New', 25);
-- ❌ 被阻塞!因为 age=25 在 Gap Lock 范围内

-- 事务 A 再次查
SELECT * FROM user WHERE age = 25 FOR UPDATE;
-- ✅ 结果不变 → 当前读幻读被解决

RR 级别下有没有幻读"漏网"的场景?

有!如果事务 A 先做快照读,再做当前读(如 UPDATE),就可能"看到"幻行。因为 UPDATE 是当前读,读最新数据。这是一个经典的面试追问点:MVCC 和锁各自解决一半,合起来才完整。

面试一招鲜

回答幻读问题时,一定要分两层说:快照读靠 MVCC 解决,当前读靠 Next-Key Lock 解决。不要简单说"RR 解决了幻读"——这是不够精确的,面试官可能追问具体机制。

全篇回顾

  1. ACID:原子性靠 undo log,持久性靠 redo log,隔离性靠锁 + MVCC
  2. 四种隔离级别:RU < RC < RR(默认)< Serializable,逐级解决脏读/不可重复读/幻读
  3. MVCC 版本链:每次 UPDATE 产生 undo log 旧版本,通过 roll_pointer 串成链表
  4. ReadView:根据 m_ids、min/max_trx_id 判断版本可见性
  5. RC vs RR:RC 每次 SELECT 创建 ReadView,RR 只在第一次 SELECT 创建并复用
  6. 快照读 vs 当前读:普通 SELECT 是快照读走 MVCC,FOR UPDATE/INSERT/UPDATE/DELETE 是当前读加锁
  7. Next-Key Lock:Record Lock + Gap Lock,在 RR 级别下防止当前读的幻读