从事务隔离级别的现象出发,深入 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 实现原理
版本链
每行数据更新时,会同时记录一条回滚日志(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 UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT |
核心疑惑解答:可重复读下,普通
SELECT是快照读,始终看到事务启动时的版本。但SELECT ... FOR UPDATE是当前读,能看到已提交的最新值。两者在同一事务中都是合理的,但读到的数据可能不同。
经典场景
初始状态:
t表中id=1的行k=1,id=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)
发表评论