专栏文章
专栏文章
MySQL 系列
1. MySQL 系列 #01:MySQL 简介 2. MySQL 系列 #02:MySQL 数据类型与 Java 类型映射 3. MySQL 系列 #03:MySQL 基础架构与执行流程 4. MySQL 系列 #04:MySQL InnoDB 日志系统 5. MySQL 系列 #05:MySQL 事务与 MVCC 6. MySQL 系列 #06:MySQL 索引原理与优化 7. MySQL 系列 #07:MySQL 锁机制 8. MySQL 系列 #08:MySQL 性能问题排查 9. MySQL 系列 #09:MySQL 主备复制与高可用 10. MySQL 系列 #10:MySQL 实战技巧与常见陷阱 11. MySQL 系列 #11:MySQL 数据库设计规范 12. MySQL 系列 #12:MySQL SQL 函数与查询技巧 13. MySQL 系列 #13:MySQL InnoDB Buffer Pool 原理 14. MySQL 系列 #14:MySQL 排序与聚合原理

MySQL 系列 #05:MySQL 事务与 MVCC

发布于 2026-05-26 10:33 👁 7 次阅读
#mysql#innodb#transaction#mvcc#isolation

从事务隔离级别的现象出发,深入 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
T1 BEGIN;
T2 SELECT c FROM T; → ? BEGIN;
T3 UPDATE T SET c=2;
T4 SELECT c FROM T; → ?
T5 COMMIT;
T6 SELECT c FROM T; → ?
隔离级别 T2(V1) T4(V2) T6(V3) 说明
读未提交 1 2 2 V2 读到 B 未提交的数据(脏读)
读提交 1 1 2 每条 SQL 重建 read-view,T6 能看到 B 已提交
可重复读 1 1 1 read-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 B session C
t1 START TRANSACTION WITH CONSISTENT SNAPSHOT;
read-view 立即建立
t2 SELECT k FROM t WHERE id=1; ← 快照读
结果:k=1
t3 UPDATE t SET k=k+1 WHERE id=1;(k→2
COMMIT;
t4 UPDATE t SET k=k+1 WHERE id=2;(id=2 的 k→3
COMMIT;
t5 SELECT k FROM t WHERE id=1; ← 快照读
结果:k=1(MVCC 屏蔽,看不到 B 的修改)
t6 SELECT k FROM t WHERE id=1 FOR UPDATE; ← 当前读
结果:k=2(穿透 MVCC,看到 B 已提交的修改)
t7 UPDATE t SET k=k+1 WHERE id=1; ← 当前读
在 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)
t1 BEGIN;
t2 SELECT * FROM t WHERE id=5; ← 快照读
结果:空
t3 BEGIN;
INSERT INTO t VALUES(5,5,5);
COMMIT;
t4 UPDATE t SET d=100 WHERE id=5; ← 当前读
影响行数:1(命中 T2 插入的行,trx_id 改写为 T1)
t5 SELECT * FROM t WHERE id=5; ← 快照读
结果:(5, 5, 100) ⚠️ 幻读!
t6 COMMIT;

已在 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)

暂无评论,来留下第一条吧。

发表评论