1. 幻读的本质与隔离级别的关系先来看一个实际开发中经常遇到的场景财务系统在做月度统计时事务A第一次查询账户流水表得到100条记录此时事务B新增了1条转账记录并提交事务A再次查询时突然变成了101条记录。这种幽灵记录现象就是典型的幻读问题。MySQL官方文档对幻读的定义是当同一个查询在不同时间点执行时返回不同结果集的现象。与不可重复读的区别在于幻读关注的是结果集数量的变化而不可重复读关注的是同一行数据内容的变化。四种隔离级别对并发问题的解决能力隔离级别脏读不可重复读幻读读未提交❌❌❌读已提交✅❌❌可重复读默认✅✅❌串行化✅✅✅有意思的是虽然SQL标准规定可重复读级别允许幻读但MySQL的InnoDB引擎通过两种创新机制MVCC和Next-Key Lock在大部分场景下避免了幻读。这也是为什么很多开发者会产生可重复读已经解决幻读的误解。2. MVCC如何部分解决幻读MVCC多版本并发控制是InnoDB实现快照读的关键。当执行普通SELECT语句时MySQL会创建一个ReadView这个视图就像给数据库拍了张快照。我做过一个实验-- 事务A BEGIN; SELECT * FROM accounts WHERE balance 1000; -- 第一次查询返回5条记录 -- 事务B插入新记录并提交 INSERT INTO accounts VALUES(6, 新用户, 2000); SELECT * FROM accounts WHERE balance 1000; -- 仍然返回5条记录这是因为MVCC通过undo日志版本链实现了数据的历史版本追溯。每个事务看到的都是自己启动时的数据快照其他事务新增的记录由于事务ID不满足可见性条件就像隐形了一样。但MVCC的防护是有漏洞的。假设事务A先查询id5的记录不存在事务B插入id5并提交事务A尝试更新这条不存在的记录-- 事务A UPDATE accounts SET balance0 WHERE id5; -- 神奇地更新成功 SELECT * FROM accounts WHERE id5; -- 居然能查到这条幽灵记录这是因为UPDATE操作会触发当前读将最新提交的数据纳入事务范围。这个案例说明MVCC不能完全阻断幻读。3. Next-Key Lock的防御机制对于当前读SELECT FOR UPDATE/INSERT/DELETE等InnoDB采用了更严格的Next-Key Lock机制。它本质上是记录锁锁定已有行和间隙锁锁定行间空隙的组合。我曾在支付系统中遇到一个典型场景需要锁定余额大于1000的所有账户进行批量处理。如果没有间隙锁-- 事务A SELECT * FROM accounts WHERE balance 1000 FOR UPDATE; -- 当前有id为3,5的记录 -- 事务B可以插入id4且balance1000的记录 INSERT INTO accounts VALUES(4, 漏洞账户, 2000);Next-Key Lock会锁定(-∞,3],(3,5],(5,∞]三个区间阻止任何可能满足条件的插入。具体加锁规则值得注意等值查询使用索引时Next-Key Lock会退化为记录锁范围查询会锁定整个扫描范围唯一索引的等值查询会退化为行锁4. 仍然存在的幻读场景即使有这两重防护幻读仍可能在以下场景出现场景一快照读转当前读-- 事务A BEGIN; SELECT * FROM accounts WHERE id5; -- 快照读返回空 -- 事务B插入id5并提交 UPDATE accounts SET nametest WHERE id5; -- 当前读发现记录 SELECT * FROM accounts WHERE id5; -- 出现幻读场景二混合读写操作-- 事务A SELECT COUNT(*) FROM orders; -- 返回100 -- 事务B插入新订单并提交 INSERT INTO orders SELECT * FROM temp_orders; -- 基于当前读操作 SELECT COUNT(*) FROM orders; -- 可能返回1005. 实战建议与面试应答在美团金融系统的开发中我们总结了几条最佳实践事务尽量简短避免长时间持有锁统一使用SELECT FOR UPDATE进行当前读为范围查询条件建立合适索引考虑使用串行化隔离级别处理财务操作当面试官追问MVCC和Next-Key Lock能否完全杜绝幻读时可以这样回答 MySQL通过MVCC解决快照读的幻读问题通过Next-Key Lock解决当前读的幻读问题。但在混合使用快照读和当前读或者先快照读后更新的场景下仍然可能出现幻读现象。因此严格来说可重复读隔离级别没有完全解决幻读只是覆盖了大部分常见场景。我曾经在分库分表的数据迁移中就踩过这个坑。当时以为可重复读已经足够安全结果在迁移过程中出现了数据重复导入的问题。后来通过统一使用SELECT FOR UPDATE并在事务开始时先锁定全表才彻底解决了这个问题。