
1. 先把幻读这件事说清楚1.1 什么是幻读一个具体场景MySQL 系列写到第十四章事务、索引、锁这些基础都铺垫得差不多了今天专门聊一个让新手绕不清、让面试官百问不厌的话题幻读。很多人第一次听到“幻读”是在 MySQL 事务隔离级别的表格里课本上往往只有一句话“在可重复读下InnoDB 通过 MVCC 和间隙锁解决了幻读。”这句话听着简单但真到生产环境报出数据不一致或者面试官追问“到底怎么解决的”不少人就卡住了。我先给幻读下个最直白的定义一个事务内用同一个查询条件连续查两次第二次却多出了一些第一次没有的行或者少了一些第一次有的行。为什么叫“幻”因为这些行就像幻觉一样明明第一次不存在第二次却出现了。举个例子。假设订单表里有一批金额大于 100 元的订单事务 A 开始查询SELECT * FROM orders WHERE amount 100查出 3 条。事务 A 还没结束事务 B 插入了一条金额 200 元的新订单并提交。事务 A 再次执行同样的查询发现结果变成了 4 条。多出来的第 4 条就是“幻影行”这个现象就是幻读。注意这里的关键是两次查询是在同一个事务里执行的而且查询条件完全一样。如果不满足“同一个事务”“相同条件”“结果集合发生变化”这三个要素那就不叫幻读可能只是普通的并发读写。1.2 幻读和不可重复读的区别为什么容易搞混不可重复读和幻读经常被混为一谈因为它们的表现都是“两次读的结果不一样”。但本质上一个动的是“值”一个动的是“集合”。我习惯用一张表把它们拆开看对比项不可重复读幻读针对的数据已存在的某一行整个查询结果集触发操作其他事务 UPDATE 或 DELETE其他事务 INSERT具体现象同一条记录两次读到的内容不同同样条件下两次读到的行数不同解决手段行锁、MVCC 版本链间隙锁、Next-Key Lock、快照隔离标准 SQL 隔离级别中的位置READ COMMITTED 下会出现REPEATABLE READ 下理论上仍会出现举例更清楚事务 A 读id5的记录第一次读到的价格是 100事务 B 把这条记录的价格改成 200 并提交事务 A 再读id5发现价格变成了 200。这叫不可重复读因为行还是那行值变了。幻读则是事务 A 查price 100第一次查到 5 条事务 B 插入一条价格 150 的新记录并提交事务 A 再查一次发现变成 6 条。行还是那 5 行但多出了一行以前不存在的东西。为什么容易混因为在绝大多数业务场景里我们不会刻意区分“值变了”和“行数变了”反正都是“结果对不上”。但面试和实际排查时必须分开。因为 MySQL 对这两种问题的处理机制完全不同不可重复读靠行锁和 MVCC 的版本链解决幻读靠间隙锁和 Next-Key Lock 解决。分不清根源就选不对手段。1.3 幻读在实际项目中的危害幻读不只是理论问题它会在真实业务里制造麻烦。最典型的是“检查后插入”场景。比如注册系统里要保证用户名唯一通常流程是事务 A 查询用户名是否存在如果不存在则插入。假设有两个事务同时进入这个逻辑事务 A 查名字“zhangsan”发现不存在事务 B 也查“zhangsan”发现不存在然后 A 插入成功并提交B 接着插入——如果没有唯一索引兜底B 也会插入成功于是产生两条同名数据。从数据库角度看A 和 B 的“查询不存在”这一步就是各自读到了一个“空结果集”随后另一个事务插入了数据导致后续操作被误导。这就是幻读引发的典型竞态。另一个高发场景是统计报表。一个事务里先 SELECT 统计总数再基于这个总数做后续决策期间其他事务插入了符合条件的记录第二次统计就会比第一次多。做对账、做分页、做数据导出时结果集不稳定会直接导致数据错乱。还有一个和超卖相关的场景虽然超卖主要涉及行锁但“先查库存再插入订单”的流程同样可能撞上幻读。比如库存表里不存在某商品的记录两个事务同时判断“库存记录不存在需要初始化”然后同时插入初始化记录照样可能重复初始化。这些都是幻读的真实杀伤力。2. InnoDB 解决幻读的核心机制MVCC 锁2.1 快照读与当前读先搞清楚两条路线想理解 InnoDB 怎么解决幻读只盯着锁是不够的。InnoDB 下的查询分成两种快照读和当前读。两者的行为逻辑完全不一样。快照读就是普通的SELECT语句。在 REPEATABLE READ 隔离级别下事务第一次执行快照读时会生成一个一致性快照之后整个事务内所有普通 SELECT 都读这个快照而不是读磁盘上的最新数据。其他事务即使插入了新行并且提交了这个快照里也不包含那些新行。所以一个 RR 事务内连续执行同样的SELECT无论其他事务怎么插入读到的行集合都保持不变。从快照读的角度看幻读已经被 MVCC 从机制上消除了。当前读就不一样了。SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE甚至INSERT都属于当前读。当前读必须读取最新已提交版本因为它要基于最新数据做修改否则就会覆盖别人的更新。正因为要读最新当前读没法靠快照规避其他事务插入的新行它必须借助锁来阻止别人插入。所以 InnoDB 的思路是双轨并行快照读走 MVCC当前读走锁。两者互相配合才把 RR 下的幻读压住。很多人只讲间隙锁不讲 MVCC或者只讲 MVCC 不讲锁都是只看到了一半。2.2 当前读的锁记录锁、间隙锁、Next-Key LockInnoDB 的行锁其实有三种形态记录锁、间隙锁、Next-Key Lock。记录锁最简单就是锁住某条索引记录本身防止其他事务修改或删除这条记录。它只针对已经存在的行。间隙锁锁的是一个开区间也就是两条索引记录之间的空隙。比如一张表的索引列值分布是 18、20、25那么就有(18,20)、(20,25)、(25,∞)这些间隙。间隙锁的作用是禁止其他事务在这些间隙里插入新记录。注意间隙锁之间不互相冲突两个事务可以同时持有同一个间隙的间隙锁间隙锁只和“插入意向锁”冲突。插入一条记录之前InnoDB 会先检查目标位置有没有间隙锁有就要等。Next-Key Lock 是记录锁和间隙锁的组合它锁住的是一个左开右闭的区间比如(18,20]既包含 20 这条记录本身的锁也包含 18 到 20 之前的间隙锁。InnoDB 在 RR 隔离级别下做当前读时默认使用的就是 Next-Key Lock。这样一来既锁住了已有记录又阻止了在范围内插入新记录。可以这样理解你要查age BETWEEN 20 AND 30InnoDB 不只是把查到的那几行锁住它连“这些行周围可能插入新行的空隙”也一起锁了。就像你在电影院座位区坐好了还把周围几个空座也占了别人想挤进来就不行。2.3 REPEATABLE READ 下消除幻读的完整逻辑现在把快照读和当前读拼起来看RR 下消除幻读的完整逻辑是这样的对于普通SELECTInnoDB 使用一致性快照整个事务内所有普通查询都基于同一个历史版本。其他事务插入的数据即使提交了也不会出现在快照里。这是 MVCC 层面的保障。对于SELECT ... FOR UPDATE、UPDATE、DELETE这类当前读InnoDB 会按照索引范围加 Next-Key Lock锁住命中的记录以及它们之间的间隙。其他事务想向这个范围内插入新行会被间隙锁挡住只能等待当前事务提交或回滚。这就保证了当前读在同一个事务内反复执行时能读到的行集合不会多出新行。简单说快照读负责让普通查询看不到幻影行当前读负责让幻影行根本插不进来。两条路都堵死RR 下的幻读基本就没了。2.4 一个容易被忽略的边界唯一索引下的间隙锁优化间隙锁不是一成不变的InnoDB 会根据查询条件做优化这也是很多人在实际排查时看锁记录一脸懵的原因。当当前读的条件命中唯一索引并且等值查询的记录存在时InnoDB 会把 Next-Key Lock 退化成记录锁。比如SELECT * FROM user WHERE id 5 FOR UPDATE如果 id5 这条记录存在那就只在 id5 这一行上加记录锁不会锁住周围的间隙。因为唯一索引已经保证了不可能再插入另一条 id5 的记录结果集最多只有一行不存在幻读风险没必要锁间隙。这个优化能避免不必要的锁范围减少并发冲突。如果等值查询唯一索引但记录不存在InnoDB 就会退化成间隙锁锁住目标值应该存在的那个间隙防止其他事务插入这条记录。比如WHERE id 5但 id5 不存在它就可能锁住(4,5)或(5,6)之间的空隙这样别的事务想插入 id5 就会被挡住。这个细节特别重要因为如果你误以为 InnoDB 的锁范围总是“全范围锁”在分析死锁和性能问题时就会看错方向。3. 实操演示在 MySQL 中复现并解决幻读3.1 准备测试表和数据光讲理论容易飘我现在带你把场景亲手复现一遍。我用 MySQL 8.0 测试InnoDB 引擎默认隔离级别 REPEATABLE READ。先建一张简单的用户表CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, age INT NOT NULL, KEY idx_age (age) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO t_user (username, age) VALUES (zhangsan, 18), (lisi, 20), (wangwu, 25);这里给 age 建了普通索引因为后面要在 age 字段上做范围查询和加锁。没有索引的情况下间隙锁范围可能扩大到整个表测试时容易误解。测试用两个会话会话 A 模拟业务事务会话 B 模拟并发写入。为了观察锁和阻塞情况可以在会话 B 插入前把innodb_lock_wait_timeout调小一点比如SET SESSION innodb_lock_wait_timeout 10;免得一直卡住。3.2 复现“普通 SELECT 不幻读”的快照读效果先在会话 A 开启事务并执行一次普通查询-- 会话 A START TRANSACTION; SELECT * FROM t_user WHERE age BETWEEN 20 AND 30;此时结果集为 lisi20、wangwu25两行。然后在会话 B 插入一条年龄 24 的新用户并提交-- 会话 B INSERT INTO t_user (username, age) VALUES (zhaoliu, 24); COMMIT;这条插入操作在会话 A 还没提交的情况下能够正常完成因为普通的快照读没有加任何锁。现在回到会话 A再执行一次一模一样的查询-- 会话 A SELECT * FROM t_user WHERE age BETWEEN 20 AND 30;结果依然只有 lisi 和 wangwu 两行看不到 zhaoliu。这就是快照读的表现整个事务内普通 SELECT 读取的是第一次查询时生成的一致性快照其他事务后来插入并提交的数据不会出现在快照里。这个实验同时说明了为什么很多人说“RR 下普通查询没有幻读”——严格说是“快照读没有幻读”。3.3 复现当前读下幻读被间隙锁拦截的场景快照读能挡住是因为它不看新数据。但如果改用当前读不加间隙锁就会出问题。现在把会话 A 的事务回滚重新开始执行一个当前读-- 会话 A START TRANSACTION; SELECT * FROM t_user WHERE age BETWEEN 20 AND 30 FOR UPDATE;这条语句会读取最新数据并对 age 在 20 到 30 之间以及相邻间隙加 Next-Key Lock。此时在会话 B 尝试插入一条 age24 的记录-- 会话 B INSERT INTO t_user (username, age) VALUES (sunqi, 24);你会看到会话 B 一直处于阻塞状态直到 10 秒超时报错或者等会话 A 提交。因为插入目标落在会话 A 持有的间隙锁范围内InnoDB 不允许它插入。要验证锁范围可以在会话 B 阻塞时用另一个会话查询锁信息SELECT * FROM performance_schema.data_locks;或者在 MySQL 命令行执行SHOW ENGINE INNODB STATUS\G看TRANSACTIONS部分通常能看到锁等待记录。这些手段在实际排查死锁和阻塞时非常有用。然后让会话 A 提交-- 会话 A COMMIT;会话 B 的插入立即成功。这个演示说明当前读加的是 Next-Key Lock不光锁住了已有的 20 和 25 两条记录还锁住了它们之间的空隙以及 30 之后的空隙所以age24的新行插不进去。这就在源头堵住了幻读。3.4 调整隔离级别到 READ COMMITTED看幻读再次出现为了形成对比我们把会话 A 的隔离级别改成 READ COMMITTED再看同样的操作会怎样。-- 会话 A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT * FROM t_user WHERE age BETWEEN 20 AND 30;这次先用普通查询查到的还是 lisi 和 wangwu。然后在会话 B 插入 age24 并提交-- 会话 B INSERT INTO t_user (username, age) VALUES (sunqi, 24); COMMIT;回到会话 A再次执行同样的 SELECT-- 会话 A SELECT * FROM t_user WHERE age BETWEEN 20 AND 30;结果变成了三行多出了 sunqi。幻读在 READ COMMITTED 下原形毕露。再把刚才的流程改成当前读试试让会话 A 执行SELECT ... FOR UPDATE会话 B 插入你会发现这次插入通常不会阻塞因为 READ COMMITTED 下 InnoDB 不再使用间隙锁只使用记录锁锁住已存在的行。于是其他事务就能在范围内插入新记录当前读再次执行时会看到之前没见过的行。通过对比就能明显感受到MySQL 在 RR 下防幻读不是靠魔法而是靠“快照读 当前读加间隙锁”这一套组合拳一旦切到 RC组合拳少了一半幻读自然就回来了。4. 常见问题与排查技巧实录4.1 网上说 RR 下也有幻读到底是怎么回事这是 MySQL 面试里最容易引发争吵的问题。有人信誓旦旦说“RR 下也有幻读”有人反驳“InnoDB 在 RR 下已经解决了幻读”。两边都能拿出依据其实他们说的不是同一个东西。真相是这样的如果事务全程只用普通 SELECTInnoDB 的 RR 确实不会出现幻读因为快照读基于一致性快照。如果事务全程只用当前读同样不会出现幻读因为 Next-Key Lock 挡住了新插入。但如果一个事务里混合使用快照读和当前读可能出现一种“看起来像幻读”的现象。举例事务 A 先用普通 SELECT 查age BETWEEN 20 AND 30看到两行。事务 B 插入一条 age24 的记录并提交。接着事务 A 不执行普通 SELECT而是执行SELECT ... FROM t_user WHERE age BETWEEN 20 AND 30 FOR UPDATE。因为这是当前读它读的是最新数据于是看到了三行。这和前一次快照读的结果不一致有人说这算幻读有人说不算。更严谨地说这是“快照读与当前读之间的不一致”不是传统意义上的幻读。在实际场景里如果业务要求一个事务内所有读都必须看到一致的数据那就不能一会儿用快照读一会儿用当前读。要么全部走普通 SELECT要么全部走当前读并接受锁开销。这个坑我在业务代码里踩过不止一次。4.2 为什么 RC 级别下间隙锁不生效如何补救很多团队因为并发压力和死锁问题把全局隔离级别调成 READ COMMITTED。这种设置下InnoDB 默认不再使用间隙锁只保留记录锁因此幻读无法通过数据库锁机制解决。但 RC 又确实是很多互联网公司的标配因为 RC 下锁范围小、死锁概率低配合 binlog 的 row 格式也能保证主从一致。问题是业务真的需要防幻读时怎么办我的经验是三条腿走路。第一能加唯一约束的就加唯一约束。比如用户名防重直接在字段上建唯一索引让数据库在插入时兜底。这样即使应用层因为幻读判断错了插入也会因为唯一键冲突而失败。第二必须依赖“先查后插”逻辑时用分布式锁或应用层锁把检查-插入这段临界区串行化。注意应用锁只能保护单一应用实例如果服务是多实例部署要用 Redis 分布式锁或 ZooKeeper 之类的外部锁。第三实在不行对关键查询临时使用 SERIALIZABLE。SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE会让普通 SELECT 自动变成SELECT ... FOR SHARE加锁防止幻读但并发度会明显下降。所以只能在极短事务里用不能全局开启。4.3 间隙锁引发的死锁与性能问题排查间隙锁能防幻读但也会引入两个新麻烦锁范围变大导致的性能下降以及间隙锁相互等待导致的死锁。锁范围变大的典型场景是范围条件没有命中索引或者直接全表扫描。InnoDB 找不到合适的索引来界定间隙只能退化为锁全表的所有间隙。这时并发插入全部被阻塞数据库像卡死一样。排查方法很简单看EXPLAIN的执行计划确认 where 条件是否用上了索引如果 type 是 ALL就要考虑加索引或改写 SQL。死锁的排查思路则要靠错误日志。当 MySQL 检测到死锁会在SHOW ENGINE INNODB STATUS里输出LATEST DETECTED DEADLOCK部分里面有涉及的事务、持有的锁、等待的锁、回滚的语句。我曾经遇到一个深夜定时任务的死锁最后定位到是两张表互相插入且插入位置落在对方持有的间隙锁里。解决办法是统一了事务访问表的顺序并把大事务拆成小事务让锁的持有时间缩短。另外可以关注innodb_lock_wait_timeout这个参数默认 50 秒有点长。生产环境建议根据业务响应时间调到 5 到 10 秒避免一个 SQL 堵住数据库连接池。4.4 分布式事务场景下的幻读思考本章从头讲的是单机 MySQL 事务但现在的系统很少只有一个数据库实例。当订单、库存、账户分别处在不同库甚至不同服务时单库隔离级别解决不了跨库的幻读问题。因为幻读的基础是“同一个数据库事务内的一致性”跨库跨服务时没有全局事务快照也没有全局的间隙锁。举个实际例子下单服务先查询订单表是否已有相同订单号再调用库存服务扣减库存。如果两个客户端同时发起相同订单号的下单请求每个服务都可能在各自的库里查不到记录然后同时执行插入。单看订单库只要订单号唯一索引能兜住可能没事但库存扣减与订单创建的先后顺序必须靠分布式事务协调。真正的解决方案也不在 MySQL 的隔离级别里而在分布式事务框架。常见思路包括用分布式锁让关键操作的检查-执行成为原子操作用版本号或状态机做乐观锁用 Seata AT 模式或 TCC 方案协调多个数据库事务。这些框架在底层仍然依赖数据库的锁或 undo log但跨库的“幻影数据”只能靠业务约束和幂等设计解决。我在实际项目里的通用原则是能用唯一索引解决的问题绝不上锁能用一个库解决的问题绝不拆到两个库实在要拆先在接口设计上保证幂等再谈分布式事务。5. 最后再说点压箱底的经验MySQL 的幻读专题写到这里该讲的概念、机制、实验和坑都过了。最后分享几个我自己的习惯算是压箱底的东西。第一面试聊幻读时不要张口就是“next-key lock”而是先分清楚快照读和当前读。这个框架一说出来对方就知道你是真懂还是背的。第二实际写代码时我会给所有关键的唯一性约束加上数据库唯一索引而不是只依赖应用层的“先查再插”。因为再严谨的判断逻辑在极端并发下都可能因为时序问题漏掉唯一索引是最后一道防线。第三排查锁相关的问题时第一件事不是看代码而是看SHOW ENGINE INNODB STATUS和performance_schema.data_locks。数据不会骗人锁在哪里、等待了多久一眼就清楚。第四不要把 REPEATABLE READ 当成银弹。它解决了幻读但也带来了更宽的锁范围和更高的死锁概率。对高并发、短事务的互联网业务READ COMMITTED 加上应用层补偿往往比死守 RR 更舒服。技术选型永远是取舍不是越高级越好。幻读这个问题说难也难说简单也简单。把 MVCC 和锁这两条线理清楚再上手跑几个实验它就没那么玄了。下一章有时间的话我打算写 InnoDB 锁的底层实现把记录锁、间隙锁、自增锁的实现细节再掰开揉碎聊一聊。