目录
正在加载目录…
专栏文章
专栏文章
MySQL 系列
1. MySQL:核心概念、架构与使用场景 2. MySQL 数据类型与 Java 类型映射 3. MySQL 基础架构与执行流程 4. MySQL InnoDB 日志系统 5. MySQL 事务与 MVCC 6. MySQL 索引原理与优化 7. MySQL 锁机制 8. MySQL 性能问题排查 9. MySQL 主备复制与高可用 10. MySQL 实战技巧与常见陷阱 11. MySQL 数据库设计规范 12. MySQL SQL 函数与查询技巧 13. MySQL InnoDB Buffer Pool 原理 14. MySQL 排序与聚合原理

MySQL 事务与 MVCC

发布于 2026-05-26 10:33 · 最后编辑于 2026-07-31 15:52 · 字数 3,309 👁 97 次阅读

从事务隔离级别的现象出发,深入 InnoDB MVCC 的实现原理,解释"为什么可重复读下同一查询结果不变,但当前读(for update)却能看到最新数据"这个核心疑惑。

目录

章节说明
四种隔离级别现象对比与适用场景
MVCC 实现原理版本链、read-view、可见性判断
快照读 vs 当前读两种读的本质区别
长事务的危害回滚段膨胀、锁资源占用
事务启动的正确方式避免意外长事务
幻读定义、MVCC 解决快照读幻读、Gap Lock 解决当前读幻读、未完全解决的场景

四种隔离级别

定义

隔离级别脏读不可重复读幻读说明
读未提交(Read Uncommitted)✅ 可能✅ 可能✅ 可能直接读最新值,无视图
读提交(Read Committed,RC)❌ 不会✅ 可能✅ 可能每条 SQL 创建新视图
可重复读(Repeatable Read,RR)❌ 不会❌ 不会⚠️ 部分事务启动时创建视图,InnoDB 用间隙锁解决幻读
串行化(Serializable)❌ 不会❌ 不会❌ 不会读写锁,性能最差

InnoDB 默认隔离级别为可重复读(RR),与 SQL 标准的默认值(读提交)不同。Oracle 默认 RC。从 Oracle 迁移到 MySQL 时需注意。

现象示例

-- 初始数据
CREATE TABLE T(c INT) ENGINE=InnoDB;
INSERT INTO T(c) VALUES(1);
时刻事务 A事务 B
T1BEGIN;
T2SELECT c FROM T; → ?BEGIN;
T3UPDATE T SET c=2;
T4SELECT c FROM T; → ?
T5COMMIT;
T6SELECT c FROM T; → ?
隔离级别T2(V1)T4(V2)T6(V3)说明
读未提交122V2 读到 B 未提交的数据(脏读)
读提交112每条 SQL 重建 read-view,T6 能看到 B 已提交
可重复读111read-view 在 T2 冻结,B 的提交全程不可见

已在 MySQL 9.6.0 本地实测验证,三种隔离级别结果与上表完全一致。

⚠️ 串行化隔离级别下,A 的 SELECT 会加共享锁,B 的 UPDATE 在 T3 会被阻塞,该时序根本无法按此顺序执行,不适用于本示例。

MVCC 实现原理

mysql mvcc

版本链

每行数据更新时,会同时记录一条回滚日志(undo log),形成版本链:

当前值:4
    ↑ 回滚到 3(undo log)
    ↑ 回滚到 2(undo log)
    ↑ 回滚到 1(undo log)

不同时刻的事务拥有不同的 read-view,通过回滚日志,每个事务看到"属于自己时刻的"版本。

read-view(一致性视图)

隔离级别read-view 创建时机
可重复读(RR)事务第一条快照读语句执行时创建(或 START TRANSACTION WITH CONSISTENT SNAPSHOT 时立即创建)
读提交(RC)每条 SQL 语句执行时创建新的 read-view

BEGIN / START TRANSACTION 并不会立即创建 read-view,真正的事务起点是第一个操作 InnoDB 表的语句。

可见性判断规则

读一行数据时,根据该行的版本号和 read-view 中的活跃事务列表判断:

row_trx_id(这行数据最后被哪个事务修改)

if row_trx_id < 当前事务启动时最小活跃事务 ID:
    → 该版本在本事务启动前已提交,可见
elif row_trx_id == 当前事务 ID:
    → 本事务自己的修改,可见
elif row_trx_id in 活跃事务列表:
    → 该版本在本事务启动时还未提交,不可见,沿版本链回溯
else:
    → 该版本在本事务启动后才提交,不可见,沿版本链回溯

快照读 vs 当前读

类型含义典型语句
快照读读取 read-view 中"可见版本"的数据,不加锁普通 SELECT
当前读读取数据的最新提交版本,加锁SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETEINSERT

核心疑惑解答:可重复读下,普通 SELECT 是快照读,始终看到事务启动时的版本。但 SELECT ... FOR UPDATE 是当前读,能看到已提交的最新值。两者在同一事务中都是合理的,但读到的数据可能不同。

经典场景

初始状态:t 表中 id=1 的行 k=1id=2 的行 k=1

时刻session A(RR)session Bsession C
t1START TRANSACTION WITH CONSISTENT SNAPSHOT;<br>read-view 立即建立
t2SELECT k FROM t WHERE id=1; ← 快照读<br>结果:k=1
t3UPDATE t SET k=k+1 WHERE id=1;(k→2)<br>COMMIT;
t4UPDATE t SET k=k+1 WHERE id=2;(id=2 的 k→3)<br>COMMIT;
t5SELECT k FROM t WHERE id=1; ← 快照读<br>结果:k=1(MVCC 屏蔽,看不到 B 的修改)
t6SELECT k FROM t WHERE id=1 FOR UPDATE; ← 当前读<br>结果:k=2(穿透 MVCC,看到 B 已提交的修改)
t7UPDATE t SET k=k+1 WHERE id=1; ← 当前读<br>在 k=2 基础上 +1,k=3

长事务的危害

危害 1:回滚段(undo log)膨胀

长事务存在时,系统必须保留该事务启动时间点之后的所有版本链,因为任何时刻长事务都可能用到这些回滚记录。

极端案例:数据只有 20 GB,但回滚段占用 200 GB

MySQL 5.5 及以前,回滚段存在 ibdata 文件中,即使事务提交后也无法收缩文件,只能重建整个库。

危害 2:锁资源长期占用

长事务持有的锁无法提前释放,可能导致其他事务长时间等待,拖垮整个库。

查找长事务

-- 查找持续超过 60 秒的事务
SELECT *
FROM information_schema.innodb_trx
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60;

事务启动的正确方式

方式问题
SET autocommit=0执行任意 SELECT 后事务就启动,框架默认设置后导致意外长事务
BEGIN + 显式 COMMIT✅ 推荐,清晰明确
commit work and chain✅ 提交当前事务并自动开始下一个,减少交互次数
-- 推荐:显式事务管理
SET autocommit=1;  -- 确保自动提交开启
BEGIN;
-- 执行业务 SQL
COMMIT;

-- 或:提交并立即开始下一个事务(减少 BEGIN 调用)
COMMIT WORK AND CHAIN;

幻读

定义

幻读:在可重复读隔离级别下,当前读(for update)两次查询同一范围,后一次看到了前一次没有的新插入行。

注意:普通快照读看不到其他事务插入的行,不是幻读。幻读仅在当前读场景下发生,且特指新插入的行(不包括其他事务修改导致满足条件的行)。

InnoDB 如何解决幻读

间隙锁(Gap Lock) + Next-Key Lock

-- 这条语句会锁住 d=5 这一行,以及行两侧的"间隙"
SELECT * FROM t WHERE d=5 FOR UPDATE;

-- 锁住的范围(Next-Key Lock = 行锁 + 前置间隙锁):
-- (-∞, 0], (0, 5], (5, 10], (10, +∞)
-- 其他事务无法在这些间隙中插入新行
锁类型含义
行锁(Record Lock)锁住索引上的某一行
间隙锁(Gap Lock)锁住两个索引值之间的空隙,阻止插入
Next-Key Lock行锁 + 前置间隙锁(InnoDB RR 默认)

⚠️ 间隙锁只在**可重复读(RR)隔离级别下存在。降为读提交(RC)**后间隙锁消失,但会出现幻读问题。

MVCC 如何解决快照读中的幻读

普通 SELECT(快照读)通过 MVCC 的 read-view 天然屏蔽了其他事务插入的新行:

-- T1
BEGIN;
SELECT * FROM t WHERE d=5;  -- 快照读,结果为空

-- T2(并发): INSERT INTO t VALUES(1, 5, 5); COMMIT;

SELECT * FROM t WHERE d=5;  -- 仍为空,MVCC 屏蔽了 T2 的插入 ✅
COMMIT;

read-view 的可见性规则:T2 提交时间晚于 T1 的 read-view 创建时间,row_trx_id 对 T1 不可见,沿版本链回溯后该行根本不存在。

InnoDB 未完全解决的场景

快照读 + 当前读混用时,幻读仍会出现。

初始状态:t(id,c,d) 表有行 (0,0,0) (10,10,10) (20,20,20)无 id=5 的行

时刻session A(T1)session B(T2)
t1BEGIN;
t2SELECT * FROM t WHERE id=5; ← 快照读<br>结果:空
t3BEGIN;<br>INSERT INTO t VALUES(5,5,5);<br>COMMIT;
t4UPDATE t SET d=100 WHERE id=5; ← 当前读<br>影响行数:1(命中 T2 插入的行,trx_id 改写为 T1)
t5SELECT * FROM t WHERE id=5; ← 快照读<br>结果:(5, 5, 100) ⚠️ 幻读!
t6COMMIT;

已在 MySQL 9.6.0 本地实测复现,结果与时序表完全一致。

根因UPDATE 是当前读,会命中其他事务已提交的新插入行并修改它,修改后该行的 trx_id 变为当前事务 ID。根据 MVCC 可见性规则(row_trx_id == 当前事务 ID → 可见),后续快照读也能看到这行,打破了"可重复读"的承诺。

解决方案对比

方案原理代价
事务开始时用 SELECT ... FOR UPDATE 锁住范围Gap Lock 阻止 T2 插入并发性降低
改用 SERIALIZABLE 隔离级别所有读都变成当前读并加锁并发性大幅降低
业务层幂等设计,允许该现象存在不依赖数据库保证业务复杂度提高

总结:InnoDB RR 隔离级别通过 MVCC 解决了纯快照读的幻读,通过 Gap Lock 解决了纯当前读的幻读,但无法解决同一事务内快照读与当前读混用时的幻读。这是 InnoDB 在"性能"与"严格隔离"之间做的权衡,彻底消除需升级到 SERIALIZABLE

参考资料

  • 《MySQL 实战 45 讲》— 第 03 讲:事务隔离:为什么你改了我还看不见?
  • 《MySQL 实战 45 讲》— 第 08 讲:事务到底是隔离的还是不隔离的?
  • 《MySQL 实战 45 讲》— 第 20 讲:幻读是什么,幻读有什么问题?
  • InnoDB Transaction Model
← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论