主题
事务与 MVCC:隔离级别的实现原理
本文是 MySQL 系统学习系列的核心篇。前置:MySQL 架构与一条 SQL 的执行过程。 相关阅读:事务隔离级别与 MVCC、ReadView 生成与可见性判断、Next-Key Lock 加锁规则
事务解决了什么问题
把银行转账拆开看:A 账户减 100,B 账户加 100。如果第二步执行时机器断电,钱减了却没到账,数据就坏了。事务把这两步绑成一个"要么都成功、要么都当没发生"的执行单元,这是并发数据库存在的前提。
InnoDB 是 MySQL 里唯一支持完整 ACID 事务的存储引擎(NDB 也支持,但很少在 OLTP 场景见到)。四个特性各有一套实现机制,ACID 分别靠什么保证,直接对应到存储组件:
- A(原子性):undo log。事务每改一行就留一条反向操作记录,回滚时逆序重放,把数据恢复原样。
- D(持久性):redo log + 双写缓冲。修改先写顺序 IO 的 redo log 并落盘,宕机后重放 redo 恢复;双写解决页断裂(写一半宕机导致页损坏)。
- I(隔离性):锁 + MVCC。写写冲突靠锁串行化,读写并发靠 MVCC 让读不加锁。
- C(一致性):是前三个特性加上约束(主键、唯一索引、外键、CHECK)共同的结果,没有独立的"一致性日志"。
并发事务会互相搅出什么问题
多个事务同时读写同一批数据,会出现三类读异常。用两个终端开事务就能复现,先建表插数据:
sql
CREATE TABLE account (
id INT PRIMARY KEY,
balance INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;
INSERT INTO account VALUES (1, 1000), (2, 2000);脏读:读到了别人没提交的数据。
text
时刻 事务A 事务B
t1 BEGIN;
t2 UPDATE account SET balance=0 WHERE id=1;
t3 SELECT balance FROM account WHERE id=1; -- 0
t4 ROLLBACK; -- A 反悔了t3 时刻事务 B 读到 0,但那是 A 尚未提交、随后又回滚的值。基于这个"从未存在过的 0"做业务判断(比如放贷),后果可想而知。
不可重复读:同一事务内两次读同一行,值变了。
text
时刻 事务A 事务B
t1 BEGIN;
t2 SELECT balance WHERE id=1; -- 1000
t3 BEGIN;
t4 UPDATE balance=500 WHERE id=1; COMMIT;
t5 SELECT balance WHERE id=1; -- 500,和 t2 不一致A 什么都没干,两次读的结果却不一样,破坏了"一个事务内数据视角稳定"的预期。
幻读:同一事务内两次范围查询,行数变了。
text
时刻 事务A 事务B
t1 BEGIN;
t2 SELECT COUNT(*) WHERE balance<100; -- 0 行
t3 INSERT balance=50 的一行; COMMIT;
t4 SELECT COUNT(*) WHERE balance<100; -- 1 行,凭空多出一条和不可重复读的区别:前者是"值变了"(针对已存在的行),后者是"行数变了"(针对结果集整体)。
四个隔离级别与 InnoDB 的默认选择
SQL 标准按"允许哪些异常"划出四个隔离级别:
- 读未提交(RU):什么都不防,脏读、不可重复读、幻读全可能发生。基本没有生产价值。
- 读已提交(RC):防脏读。事务内每条 SELECT 都能读到别人已提交的最新数据。
- 可重复读(RR):再防不可重复读。事务内多次读同一行结果一致。
- 串行化(SERIALIZABLE):读也加锁,完全串行,什么都防,并发能力大打折扣。
InnoDB 默认 RR。查看与修改:
sql
SELECT @@transaction_isolation; -- REPEATABLE-READ
SET SESSION transaction_isolation = 'READ-COMMITTED'; -- 只影响当前会话RC 和 RR 在 InnoDB 里的差别,最后可以归结为一件事:ReadView 的生成时机。RC 下每条 SELECT 生成一个新 ReadView(所以能读到别人新提交的),RR 下只在第一条 SELECT 时生成一次、整个事务复用(所以视角冻结)。这个结论的推导过程在下一节。
MVCC 三件套:隐藏列、undo 版本链、ReadView
MVCC 的目标是让读操作不加锁:写归写(靠锁串行),读靠快照走历史版本,互不阻塞。实现拆成三块。
第一块,隐藏列。 InnoDB 每行数据自带两个系统列:DB_TRX_ID 记录最后一次修改这行的事务 id,DB_ROLL_PTR 指向该行在 undo log 里的上一版本。手查方法:
sql
SELECT id, balance,
TRX_ID, ROLL_PTR -- 概念演示;实际需查 information_schema 或 gdb,
FROM account WHERE id=1; -- 用户 SQL 看不到隐藏列,但行格式里确实存在第二块,undo 版本链。 一行被改过多次,每次的旧值都在 undo log 里,通过 ROLL_PTR 串成链表,链头是当前值,越往回越旧:
mermaid
flowchart LR
subgraph 当前行
A["id=1, balance=500\ntrx_id=103\nroll_ptr→"]
end
A --> B["版本2: balance=800\ntrx_id=102"]
B --> C["版本1: balance=1000\ntrx_id=99"]第三块,ReadView。 事务发起快照读时生成的一份"名单",核心字段:
m_ids:生成 ReadView 时所有活跃(未提交)事务 id 列表min_trx_id:m_ids 里最小的next_trx_id:生成时系统将要分配给下一个事务的 id(即已见过的最大 id + 1)creator_trx_id:当前事务自己的 id
对版本链上某个版本的 trx_id,可见性判断规则按顺序走:
trx_id == creator_trx_id:自己改的,可见trx_id < min_trx_id:生成 ReadView 前已提交,可见trx_id >= next_trx_id:生成 ReadView 之后才开启的事务,不可见min_trx_id <= trx_id < next_trx_id:看它是否在 m_ids 里。在,说明当时还活跃,不可见;不在,说明已提交,可见
不可见就顺着 roll_ptr 找上一版本,重复判断,直到找到可见版本或链尾(返回的其实是"不存在")。
一个多事务例子逐步推演。 假设当前行 id=1, balance=1000, trx_id=99。事务 T100、T101 并发进行中,T102 是我们自己:
text
t1 T100: UPDATE balance=800 (trx_id=100 入链)
t2 T101: UPDATE balance=500 (trx_id=101 入链)
t3 T102: BEGIN; SELECT ... WHERE id=1; -- 生成 ReadView
此时 m_ids=[100,101], min_trx_id=100, next_trx_id=103, creator=102
t4 T100: COMMIT
t5 T102: SELECT ... WHERE id=1; -- RR,复用 t3 的 ReadViewt5 的查找过程:链头 trx_id=101,落在 [100,103) 且在 m_ids 里 → 不可见,回退;版本2 trx_id=100,也在 m_ids 里 → 不可见,回退;版本1 trx_id=99 < 100 → 可见,返回 balance=1000。注意 t4 时 T100 已经提交,但 RR 下 ReadView 是 t3 生成的老名单,T100 在名单里就永远不可见。同一套数据,RC 下 t5 会生成新 ReadView(m_ids=[101]),T100 已提交即不在名单 → trx_id=100 的版本可见,返回 800。RC 与 RR 的全部区别就在这一刻的名单是新的还是老的。
RR 下的幻读:MVCC 管快照读,间隙锁管当前读
有了 MVCC,RR 下普通的 SELECT(快照读)既不会不可重复读,也不会幻读——两次范围查走同一个 ReadView,结果集自然一致。但两类"当前读"不走 MVCC,直接读最新已提交版本并加锁:
sql
SELECT * FROM account WHERE balance < 100 FOR UPDATE; -- 当前读,加 Next-Key Lock
UPDATE account SET balance = balance - 10 WHERE id = 1; -- UPDATE 隐含当前读RR 防幻读靠双保险:快照读靠 MVCC,当前读靠 Next-Key Lock(记录锁+间隙锁)锁住范围、挡住 INSERT。但双保险各管一边,混用就露馅。经典场景:
text
t1 事务A: BEGIN;
t2 事务A: SELECT * WHERE balance<100; -- 快照读,0 行
t3 事务B: INSERT balance=50; COMMIT;
t4 事务A: SELECT * WHERE balance<100; -- 快照读,仍 0 行(MVCC 视角冻结)
t5 事务A: UPDATE account SET balance=0 WHERE balance<100; -- 当前读!作用到 B 插的那行
t6 事务A: SELECT * WHERE balance<100; -- 0 行,但行还在——update 改的是它自己的版本t5 之后事务 A 内部出现了矛盾:快照读看不见这行,自己却改过它。更直接的复现是 t5 换成 SELECT ... FOR UPDATE,能查出 B 插入的行——这就是"RR 不能完全防幻读"的准确含义。规避办法要么开启就是 SERIALIZABLE,要么事务一开始就用 FOR UPDATE 当前读锁定范围,让视角从头到尾一致。
动手实操:两个终端复现不可重复读与可重复读
环境:MySQL 8.x,两张表准备工作已在前文完成。开两个终端(mysql 客户端各起一个),按时刻交错执行。
实验一:RC 下复现不可重复读。
sql
-- 终端1(事务A)
SET SESSION transaction_isolation = 'READ-COMMITTED';
BEGIN;
SELECT balance FROM account WHERE id=1;
-- 返回 1000
-- 终端2(事务B)
BEGIN;
UPDATE account SET balance = 500 WHERE id=1;
COMMIT;
-- 回终端1
SELECT balance FROM account WHERE id=1;
-- 返回 500!同一事务内两次结果不同 → 不可重复读
COMMIT;实验二:RR 下验证可重复读。
sql
-- 终端1(事务A)
SET SESSION transaction_isolation = 'REPEATABLE-READ'; -- 8.0 默认就是它,显式设一遍便于观察
BEGIN;
SELECT balance FROM account WHERE id=1;
-- 返回 500(上一实验提交后的值)
-- 终端2(事务B)
BEGIN;
UPDATE account SET balance = 100 WHERE id=1;
COMMIT;
-- 回终端1
SELECT balance FROM account WHERE id=1;
-- 仍返回 500,视角被 ReadView 冻结 → 可重复读成立
SELECT balance FROM account WHERE id=1 FOR UPDATE;
-- 返回 100!当前读绕过 MVCC,读最新已提交值
COMMIT;两个实验对照着跑一遍,"RC/RR 只差 ReadView 生成时机"和"快照读/当前读是两条路径"就不再是背诵的两句话,而是亲手翻车过的两个坑。
常见误区与小结
- "MVCC 靠锁实现"——反了。MVCC 的意义正是让快照读不加锁,写写冲突才靠锁。
- "RR 完全防住幻读"——只对纯快照读成立。快照读与当前读混用(先 SELECT 后 FOR UPDATE/UPDATE)仍可能读到事务开始后别人插入的行。
- "不可重复读和幻读是一回事"——前者是已存在行的值变化,后者是结果集行数变化,InnoDB 防后者的手段也不同(MVCC/间隙锁各管一边)。
- "ReadView 是事务 BEGIN 时生成的"——RR 下是第一条快照读(或当前读之前的第一次一致性读)时生成,"BEGIN 后先睡觉再查"和"查完再睡"拿到的名单可能不同。
- "undo log 只用于回滚"——它还是 MVCC 版本链的存储载体;长事务会拖着旧版本不能清理,是 undo 膨胀和大查询变慢的常见原因。
本文覆盖事务 ACID 实现机制、并发异常谱系与 MVCC 完整链路,是 MySQL 学习路径里承上启下的一环:往上接架构与日志体系,往下接锁机制。下一篇《InnoDB 锁全景:行锁、间隙锁与死锁》展开"隔离性的另一半"——当前读路径上的加锁规则与死锁排查。
参考
参考:MySQL 8.0 官方文档 InnoDB Multi-Versioning 一节(dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html);《MySQL 技术内幕:InnoDB 存储引擎》第 7 章事务;MySQL 8.0 源码 storage/innobase/read/read0read.cc(ReadView 结构与可见性判断函数 check_visible)。