Lesson 61 · 数据库与中间件实战
MySQL 事务与 MVCC:四种隔离级别与 ReadView
MySQL 事务与 MVCC:四种隔离级别与 ReadView
Read Uncommitted → Read Committed → Repeatable Read → Serializable。MVCC 的 undo log + ReadView 机制。RR 级别下怎么防幻读?
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 是最终目标由前三者共同保证。
四种隔离级别与并发问题
SQL 标准定义了四种隔离级别,从低到高,并发问题越来越少,但性能开销越来越大:
三种并发问题的精确定义:
| 问题 | 定义 | 场景 |
|---|---|---|
| 脏读 | 事务 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。
MVCC 实现机制——undo log 版本链
MVCC(Multi-Version Concurrency Control)多版本并发控制,是 InnoDB 在 RC 和 RR 级别下实现"读写不冲突"的核心机制。它的本质是:同一行数据保存多个历史版本,每个事务看到的是自己"应该看到的"版本。
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 线程在安全时清理旧版本。长事务会阻止清理并导致各种问题。
ReadView 可见性判断
ReadView 是 MVCC 的核心控制器,它在事务执行快照读(普通 SELECT)时生成,决定了当前事务能看到哪些版本的数据。
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 那一刻,哪些事务已经提交了。已提交的可见,未提交的不可见,之后开始的更不可见。
RR vs RC——ReadView 的创建时机不同
Repeatable Read 和 Read Committed 的核心区别,就在于 ReadView 的创建时机:
RR 级别下,事务 A 中先 UPDATE 再 SELECT,能看到自己改的值吗?
能。MVCC 的可见性判断有一条特殊规则:row_trx_id == creator_trx_id 时直接可见。自己的修改永远对自己可见,不受 ReadView 限制。
RC = 每次快照读创建新 ReadView → 能读到别人刚提交的修改 → 不可重复读。
RR = 只在第一次快照读创建 ReadView,后续复用 → 看到的始终是同一快照 → 可重复读。
当前读 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 提升的是快照读的并发性能。
-- 查看最近一次死锁信息 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 -- 解决方案: 统一加锁顺序 / 缩短事务 / 降低隔离级别
RR 级别下的幻读防护——Next-Key Lock
很多人说"MySQL RR 级别解决了幻读",这个说法不完全准确。准确地说:InnoDB 在 RR 级别下通过 MVCC 解决了快照读的幻读,通过 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 解决了幻读"——这是不够精确的,面试官可能追问具体机制。
全篇回顾
- ACID:原子性靠 undo log,持久性靠 redo log,隔离性靠锁 + MVCC
- 四种隔离级别:RU < RC < RR(默认)< Serializable,逐级解决脏读/不可重复读/幻读
- MVCC 版本链:每次 UPDATE 产生 undo log 旧版本,通过 roll_pointer 串成链表
- ReadView:根据 m_ids、min/max_trx_id 判断版本可见性
- RC vs RR:RC 每次 SELECT 创建 ReadView,RR 只在第一次 SELECT 创建并复用
- 快照读 vs 当前读:普通 SELECT 是快照读走 MVCC,FOR UPDATE/INSERT/UPDATE/DELETE 是当前读加锁
- Next-Key Lock:Record Lock + Gap Lock,在 RR 级别下防止当前读的幻读