
很多人会把“可重复读”直接等同于“没有幻读”再把原因归结为 MVCC。这个结论不够准确。在 InnoDB 的REPEATABLE READ下MVCC 让快照读在同一事务中保持一致视图所以两次普通SELECT往往看不到后来插入的新行但 MVCC 并没有阻止其他事务插入这行数据。只要切换到需要读取最新版本的当前读新行仍可能出现。要在当前读场景中阻止这类范围内的新插入需要的是锁典型机制是 Next-Key Lock。1. 先区分快照读和当前读读取方式常见 SQL是否使用 Read View读取内容快照读普通SELECT是符合一致性视图的历史版本当前读SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT否最新已提交记录或本事务自己修改的记录快照读解决的是“本事务这次查询应该看哪个版本”的问题它不是一把范围锁不能禁止其他事务往满足条件的范围中插入新记录。2. 快照读为什么看起来没有幻读假设 T1 已经开始事务并执行普通查询SELECTCOUNT(*)FROMordersWHEREamount100;此时 T1 创建 Read View查询到 3 行。T2 随后插入一行amount200并提交T1 再次执行同一条普通SELECT在REPEATABLE READ下仍按原 Read View 读取结果依旧是 3 行。这只是说明新行对T1 的快照读不可见并不表示新行不存在更不表示插入被阻止了。补充在READ COMMITTED下普通SELECT通常每次都会创建新的 Read View因此两次快照读本身就可能看到不同数量的行。本文后续讨论的重点是REPEATABLE READ。3. 为什么当前读仍然会出现幻读T2 已经提交后T1 执行下面的锁定读SELECT*FROMordersWHEREamount100FORUPDATE;这是当前读不沿用之前快照读的 Read View而要读取最新的已提交记录。因此它会返回 T2 新插入的amount200同一个范围从 3 行变为 4 行。这里的关键不是 MVCC “失效”而是当前读的语义本来就是读取最新版本。MVCC 只能控制快照读的可见性不能阻止幻影行被其他事务插入。4. 锁机制如何阻止当前读幻读要避免 T2 在范围内插入T1 必须在 T2 插入前先做锁定读例如SELECT*FROMordersWHEREamount100FORUPDATE;当amount上有合适的索引时InnoDB 会沿索引扫描该范围并在可重复读隔离级别下使用 Next-Key Lock。可以把它理解为Next-Key Lock 记录锁 记录前的间隙锁它不仅锁住已经存在、满足amount 100的索引记录也锁住相应的索引间隙。此时 T2 尝试插入amount200需要在该间隙上申请插入意向锁会与 T1 的间隙锁冲突只能等待 T1 提交或回滚。这样在 T1 持锁期间范围内就不会出现新的幻影行。5. 锁不能回到过去阻止已经提交的插入锁的生效时机非常重要。若 T1 先进行普通快照读没有加锁T2 已经插入并提交随后 T1 才执行SELECT ... FOR UPDATE那么 T1 会在当前读中看到这条已经存在的新记录。此时再获取 Next-Key Lock 只能阻止之后的插入不能撤销或隐藏之前已提交的记录。因此想用锁来防止当前读幻读必须在并发插入发生前就先执行锁定读。6. 实践中还要注意索引与锁范围Next-Key Lock 是基于索引实施的谓词是否命中索引会直接影响锁定范围和并发度。范围查询应尽量具备合适索引否则可能扩大扫描和加锁范围造成不必要的阻塞。此外不同谓词和索引形态的加锁细节并不完全相同唯一索引的等值命中通常可以退化为记录锁针对不存在唯一键的等值查找或范围条件、非唯一索引扫描则更容易涉及间隙锁或 Next-Key Lock。排查时应结合实际 SQL、索引和SHOW ENGINE INNODB STATUS/ 锁监控信息而不是只凭“RR 一定加间隙锁”下结论。结论MVCC 保证快照读的一致性在 RR 下同一事务的普通SELECT可以持续读取同一 Read View。MVCC 不会阻止别的事务插入满足条件的新行当前读为了读取最新记录仍可能看见这类新行。要防止当前读幻读需要在插入发生前使用锁定读并由索引上的 Next-Key Lock 封锁记录和间隙。锁只能约束未来的并发操作不能追溯已经提交的插入。