为什么MySQL可重复读能避免大部分幻读:MVCC与间隙锁的配合
认识幻读的本质:结构性变化而非数据更新
许多开发者容易把“不可重复读”与“幻读”混淆。不可重复读针对的是已有记录的内容修改或删除,事务前后读取同一行记录,其字段值不一致;而幻读的核心是“集合结构的改变”,即事务在相同查询条件下两次检索,发现凭空多出了符合条件的行。
考虑这样一个场景:事务 A 查询表内所有 age > 18 的用户,返回了 3 条记录。随后事务 B 插入了一条 age = 20 的新记录并提交。如果事务 A 再次执行相同查询并查到了 4 条记录,这种“像见鬼一样凭空冒出来的整行新增”才被称为幻读。
快照读与当前读:分工明确的两套机制
在深入理解技术实现之前,必须理清 InnoDB 中两类读取行为的差别:
- 快照读(Snapshot Read):指最常见的非阻塞纯查询,例如不加任何锁修饰的
SELECT * FROM t WHERE id > 10;。快照读不加锁,完全依靠 MVCC 读取历史版本数据。 - 当前读(Current Read):指获取目标行最新提交数据的读取操作,同时对读取的记录或间隙加锁。例如
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE(或 MySQL 8.0 的FOR SHARE),以及所有的UPDATE、DELETE、INSERT操作。
由于这两种读操作的机制不同,InnoDB 采取了“分而治之”的防线策略:快照读依靠 MVCC 的一致性视图化解幻读;当前读依靠 Next-Key Lock 锁住范围区间拦截幻读。
第一道防线:MVCC 如何阻断快照读的幻读
MVCC(Multi-Version Concurrency Control)的核心思想是利用数据的多版本链条,使读写操作无需互斥。在 InnoDB 中,每行数据除业务列外,还隐藏了两个重要系统列:trx_id(记录最近修改本行的事务 ID)和 roll_pointer(回滚指针,指向 undo log 中的前一个历史版本)。
Read View 的判定逻辑
在可重复读隔离级别下,一个事务只在执行第一次 SELECT 语句时生成一个 Read View(一致性读视图),且该视图在事务提交前始终保持不变。Read View 包含四个核心属性:
m_ids:创建该视图时,系统中当前正在活跃且未提交的事务 ID 列表。min_trx_id:创建视图时活跃事务的最小事务 ID。max_trx_id:创建视图时系统准备分配给下一个事务的 ID(非当前已分配的最大 ID)。creator_trx_id:创建该 Read View 的事务自身 ID。
当查询遍历某行记录的版本链时,InnoDB 按照以下四个推导步骤判断版本可见性:
在快照读机制下,即便其他事务中途插入了新行并提交,由于该新行的 trx_id 必然大于事务 A 生成 Read View 时的 max_trx_id(或属于未提交的活跃事务),判定规则会判定该行版本对事务 A 不可见。因此在整个事务期间,无论重复执行多少次普通 SELECT,查询到的数据集行数保持恒定,快照读下的幻读被完全避免。
第二道防线:间隙锁与临键锁如何阻断当前读的幻读
快照读依靠历史切片解决问题,但当业务需要执行“当前读”(如 SELECT ... FOR UPDATE 或 UPDATE)时,系统不能再读历史快照,而必须读取最新的现实数据。这时,仅靠行锁(Record Lock)是防不住幻读的。
假设表中存在两行主键记录:id = 5 与 id = 10。事务 A 执行:
SELECT * FROM t WHERE id > 4 AND id < 11 FOR UPDATE;
如果数据库仅对已存在的 id = 5 和 id = 10 施加行锁,那么其他事务仍然可以成功插入 id = 7 的记录。当事务 A 再次执行相同范围加锁读时,就会发现多出 id = 7 的新行,出现当前读幻读。此外,在主从复制基于 Binlog Statement 格式同步时,并发写入还会导致主从数据状态失衡。
Next-Key Lock 的构成
为解决当前读的数据缝隙问题,InnoDB 引入了间隙锁(Gap Lock)。间隙锁锁定的是两个索引记录之间的“空隙”,防止其他事务在间隙中插入数据。而临键锁(Next-Key Lock)则是行锁与前侧间隙锁的组合体,呈现前开后闭的区间状态 (a, b]。
当事务 A 在可重复读级别下针对条件 id > 4 AND id < 11 FOR UPDATE 加锁检索时,InnoDB 会在满足条件的区间加上 (5, 10] 的 Next-Key Lock,以及 (10, supremum] 的间隙锁定。此时若事务 B 尝试执行 INSERT INTO t VALUES (7, ...),存储引擎会检测到目标写入点落入已被占用的间隙区间内,自动将事务 B 挂起等待锁释放。间隙内无法产生新记录,当前读的幻读现象自然被有效杜绝。
为什么说是“避免大部分”,而非“彻底杜绝”?
官方文档与实际工程中之所以严谨地表述为“避免大部分幻读”,是因为在某些快照读与当前读混用的特殊时序下,用户依然可以观察到幻读。了解这些边界情况有助于规避线上隐蔽的逻辑缺陷。
典型边界场景:更新了其他事务新增的行
假设数据表初始包含两行数据:id = 1 与 id = 5。两个事务按如下时序交替执行:
| 时序 | 事务 A(隔离级别:RR) | 事务 B |
|---|---|---|
| 1 | BEGIN;SELECT * FROM t WHERE id = 3;(快照读,结果返回空) |
|
| 2 | BEGIN;INSERT INTO t VALUES (3, 'Bob');COMMIT; |
|
| 3 | UPDATE t SET name = 'Alice' WHERE id = 3;(当前读:Affects 1 row!) |
|
| 4 | SELECT * FROM t WHERE id = 3;(快照读,查到了 id=3 的记录!) |
在第 1 步时,事务 A 生成了 Read View,当时数据表中没有 id = 3。事务 B 在第 2 步插入并提交。关键在第 3 步:事务 A 执行了 UPDATE。由于 UPDATE 属于当前读,它会寻找物理存储上的最新记录,成功把 id = 3 的行修改为了 name = 'Alice'。
此时,该行的系统隐藏列 trx_id 被重写为了事务 A 自己的事务 ID。当事务 A 在第 4 步再次执行快照读时,命中 MVCC 规则第一条——“由本事务自身产生或修改的记录对本事务可见”。于是,原本查不到的 id = 3 重新出现在结果集中,形成了幻读。
架构全貌与协同防线总结
通过组合软件设计的轻量化快照与底层排他锁,MySQL 在并发性能与数据一致性之间取得了均衡:
- 避免在长事务中混用快照读与当前读:如果一个事务内先用普通
SELECT做条件校验,随后执行UPDATE,很容易撞上不可见数据的锁冲突或产生意料之外的数据修改。 - 保持查询条件走索引:无论是快照读还是加锁读,都应尽可能利用索引。若当前读执行全表扫描(无合适索引),Next-Key Lock 会退化为锁定整张表的所有间隙与行,直接导致全表无法写入,大幅增加死锁概率。
- 需要强绝对一致性时的选择:如果业务逻辑要求完全与真实物理存储零偏差,优先在事务开始时即使用
SELECT ... FOR UPDATE锁定目标范围,或直接调整隔离级别为串行化。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









