前阵子有朋友问我一个问题线上 MySQL 用的是默认的 REPEATABLE READ可重复读隔离级别但最近业务里出现了一种“灵异现象”——同一个事务里两次统计订单数量结果不一样多了几行数据。他说网上查了一圈答案都是“RR 已经解决了幻读”可现实中明明出了类似幻读的问题。 这里先给结论MySQL 的 InnoDB 引擎在可重复读隔离级别下确实通过 MVCC 和临键锁Next-Key Lock把标准 SQL 定义的幻读问题解决掉了。但如果你的 SQL 写法比较“花”比如先做了一次普通 SELECT快照读后面又做了 UPDATE 或者 SELECT ... FOR UPDATE当前读照样能观测到和幻读一模一样的结果。这篇就把幻读的前因后果、解决方案以及实操中的坑讲透适合正在学事务的初学者也适合准备面试、或者线上排查数据异常的同学。 ## 1. 先搞懂幻读是什么别被面试答案忽悠了 ### 1.1 从库存扣减场景说起 幻读简单说就是一个事务内执行了两次相同的范围查询第二次返回的结果里多出了第一次没见过的“幻影行”。这些行不是被自己事务改出来的而是由其他事务提交的新数据。 我举个例子你就明白了。假设有一张商品库存表现在库存只有 1 件 sql CREATE TABLE inventory ( id INT PRIMARY KEY, product_name VARCHAR(50), stock INT, sale_price DECIMAL(10,2) ) ENGINEInnoDB; INSERT INTO inventory VALUES (1, 手机, 1, 4999.00);事务 A 想统计一下“售价大于 1000 的商品库存总数”于是执行BEGIN; SELECT SUM(stock) FROM inventory WHERE sale_price 1000;这时候返回 1。事务 B 在那头插入了一件“售价 2999 的平板”然后提交INSERT INTO inventory VALUES (2, 平板, 1, 2999.00); COMMIT;事务 A 再次执行一模一样的统计SELECT SUM(stock) FROM inventory WHERE sale_price 1000;如果返回 2那这就是幻读同一个事务内两次一样的查询行集合发生了变化多出来一行“凭空出现”的数据。现实业务里幻读可不是什么“灵异事件”这么简单。报表统计会多算金额分页查询会突然丢行库存扣减可能把别人的单子一起扣掉。所以要理解一个事务隔离机制不能光背概念得先记住它会怎样“搞乱”你的业务数据。1.2 脏读、不可重复读、幻读别傻傻分不清很多初学者把不可重复读和幻读混在一起。我直接给你做一张对比表把三者按“读到的数据到底哪里不对”拆开看异常现象本质问题通俗理解脏读读到了其他事务未提交的数据别人改了还没交活你先拿去找老板汇报了不可重复读同一行数据内容变了同一本书前一次看是初版后一次看成了修订版幻读查询结果的行数/行集合变了前一次书架上有 20 本书后一次变成了 21 本为什么说锁和隔离级别能解决这些异常记住一条主线读未提交READ UNCOMMITTED什么都不防脏读、不可重复读、幻读全都可能发生。读已提交READ COMMITTED解决了脏读但同一行会在两次读之间被 commit所以不可重复读、幻读仍可能发生。可重复读REPEATABLE READ解决了脏读和不可重复读。InnoDB 在 MySQL 的 RR 下还额外用锁解决了幻读。串行化SERIALIZABLE全部事务串行执行三种异常全防住代价是并发能力急剧下降。判断一次“数据异常”到底属于哪一类关键是看“什么东西变了”是读到未提交数据是同一行内容变了还是行的集合变了。这个判断习惯线上排查的时候比背定义有用得多。2. RR 隔离级别下为什么仍会“幻读”快照读和当前读的分野2.1 当前读与快照读要彻底搞懂幻读在 MySQL 里到底什么时候会出现必须先分清两类读操作。这是网上很多文章含糊其辞的地方也是面试官最爱追问的细节。一类是快照读Snapshot Read也就是不带 FOR UPDATE、不带 LOCK IN SHARE MODE 的普通 SELECT。在 InnoDB 里这种读走的是 MVCC多版本并发控制它会基于事务开始时的某个版本号生成一个一致性视图read view。整个事务期间快照读都从这份视图里取数据所以理论上它永远看不到其他事务后来插入的新行。另一类是当前读Current Read包括SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETEINSERT部分场景下当前读读取的是记录的最新版本并且会对读取到的记录加锁。它不走一致性视图所以其他事务一旦提交了新数据当前读就能看到。一句话总结普通 SELECT 是一条“时光冻结”的读取路径当前读是一条“锁定最新数据”的读取路径。幻读能不能被你观测到关键就看事务里先后使用了哪条路径。2.2 一个让人后背发凉的实操示例我直接带你复现一次。假设有张表 t结构如下CREATE TABLE t ( id INT PRIMARY KEY, val VARCHAR(20) ) ENGINEInnoDB; INSERT INTO t VALUES (1, a), (2, b);现在开两个会话。会话 A 开启事务先做一次快照读-- 会话A BEGIN; SELECT * FROM t WHERE id 1; -- 结果id2 的一行会话 B 插入一条新记录并提交-- 会话B BEGIN; INSERT INTO t VALUES (3, c); COMMIT;会话 A 再执行一次当前读-- 会话A SELECT * FROM t WHERE id 1 FOR UPDATE; -- 结果id2、id3 两行看到没有同一个事务里第一次快照读看不到 id3第二次当前读看到了 id3。如果你只看第二次查询的结果这就是活生生的“幻读”。为什么会这样因为第一次普通 SELECT 走的是事务开始时的 read view而FOR UPDATE走的是最新版本数据读取路径。严格从 InnoDB 的事务一致视角看快照读和快照读之间是一致的、可重复的当前读和当前读之间只要加锁范围一致也是可重复的。但快照读和当前读“混搭”起来就会打破事务内的一致性。这就是网上“MySQL 的 RR 解决了幻读”和“RR 还有幻读”两种说法都成立的原因——他们说的根本不是同一条读取路径。实际操作中我最常碰到的线上问题就是这种混搭导致的先 SELECT 判断再 UPDATE 修改中间夹着其他事务插入的数据。下一小节讲的锁机制解决的就是这类场景。3. 真正能挡住幻读的锁间隙锁与临键锁3.1 三兄弟锁记录锁、间隙锁、临键锁InnoDB 有三种锁名字像兄弟作用天差地别记录锁Record Lock只锁索引记录本身。比如WHERE id 5就只在 id5 这条记录上加锁相邻的 id4、id6 完全不受影响。间隙锁Gap Lock锁的是索引记录之间的“空隙”或者第一条记录之前的区间、最后一条记录之后的区间。它锁的是“不允许往这个空隙里插入新记录”的权限。比如表里 id 有 1、5、10间隙锁可以锁 (1,5) 这个区间别的会话想往这个区间插入 id3 就会被阻塞。临键锁Next-Key Lock记录锁 间隙锁的组合锁的是“某个区间 右边界那条记录”。它锁定的范围是一个左开右闭区间比如 (5, 10]既管 5 和 10 之间不能插入又管 id10 这条记录本身不能改。三者对比如下锁类型锁定的范围主要作用典型触发场景记录锁单条索引记录保护某一行主键/唯一索引等值查询命中记录间隙锁两个记录之间的空隙防止插入等值查询未命中记录、范围查询临键锁区间 右边界记录防止插入 保护边界行范围查询、当前读扫描到的区间为什么需要间隙锁和临键锁因为记录锁只能管住“已有的行”管不住“未来要插入的行”。幻读的本质就是新行插入所以要专治插入光靠记录锁是没用的。3.2 手动加锁实操演示用临键锁治住幻读上小节我们用了“快照读 当前读”复现了幻读。现在换一种方式一上来就用当前读加锁看看效果。还是用前面那张表 tid 有 1 和 2。会话 A 执行-- 会话A BEGIN; SELECT * FROM t WHERE id 1 FOR UPDATE; -- 结果id2 一行InnoDB 会在扫描 id 1 的过程中对命中的记录区间加临键锁。这里的效果是其他会话无法在 id 1 的范围内插入新记录。会话 B 尝试插入-- 会话B INSERT INTO t VALUES (3, c);这时候你会看到 INSERT 语句一直卡住直到会话 A 提交或者回滚才会继续。因为会话 B 插入的目标位置正好落在会话 A 持有的临键锁范围内。会话 A 此时再次查询SELECT * FROM t WHERE id 1; -- 结果仍然只有 id2 一行整个事务期间第二次查询结果和第一次完全一致——没有幻读。这就是临键锁的真正价值它不只是锁住已经存在的记录还把“未来可能出现的记录位置”提前占住了。注意SELECT ... FOR UPDATE不是唯一能触发锁的方式。UPDATE 和 DELETE 在执行时同样会走当前读并加临键锁。这也是为什么很多“先查后改”的业务逻辑只要在查询阶段就锁住范围后面就不会被新插入的数据干扰。3.3 锁的代价与边界别把所有范围都锁上临键锁能防幻读但这不是免费的午餐。锁的范围越大并发度就越低死锁的概率也越高。实操中我见过最典型的坑是这样的表里数据量不大但业务 SQL 写了一个没有索引的范围条件比如SELECT * FROM orders WHERE user_id 1000 FOR UPDATE;如果 user_id 上没有索引InnoDB 只能走主键聚簇索引全表扫描。结果就是扫描到的每一个间隙全被锁住很可能整张表的所有插入都被阻塞。一个本该只锁少量行的语句硬生生变成了表级锁的效果。所以用临键锁防幻读有两条必须守住的底线锁定范围要精准。查询条件尽量走索引尤其是唯一索引。索引能帮助 InnoDB 把锁的范围缩小到目标记录附近避免锁扩散到整个表。事务要短小。事务里持锁时间越短阻塞其他事务的概率越低。不要在持锁过程中做外部接口调用、等待用户输入这类耗时操作。另外提醒一句间隙锁不是在所有隔离级别下都会生效。在 READ COMMITTED 级别下InnoDB 有相当大的概率不启用间隙锁只保留记录锁MySQL 官方还专门为此做了参数innodb_locks_unsafe_for_binlog的历史遗留设计。所以你做锁实验的时候先把SELECT transaction_isolation;查一下确认当前会话隔离级别是 REPEATABLE READ否则结果会“对不上”。4. 隔离级别的取舍别一提幻读就切串行化4.1 四种隔离级别速览学习事务的时候很多人会走极端既然串行化能解决一切那就全上串行化吧。实际上没人这么干因为代价是性能雪崩。先把四种隔离级别放在一起看隔离级别脏读不可重复读幻读并发性能读未提交 READ UNCOMMITTED可能可能可能最高读已提交 READ COMMITTED不会可能可能较高可重复读 REPEATABLE READ不会不会InnoDB 下通常不会中等串行化 SERIALIZABLE不会不会不会最低MySQL 默认的隔离级别是可重复读Oracle 和 PostgreSQL 默认是读已提交。这里有一条很重要的认知RR 因为要同时保证“可重复读”和“防幻读”InnoDB 不得不使用间隙锁和临键锁这就意味着某些范围锁的粒度天然比 RC 更大。RC 呢它只锁记录不加间隙锁所以并发插入能力更好。但它允许不可重复读和幻读遇到需要一致性读的业务就得在应用层想办法。很多互联网高并发场景下团队会把隔离级别主动切成 RC然后用乐观锁、唯一索引、版本号等手段在应用层兜底换更低的锁竞争和更高的吞吐。4.2 生产环境怎么选没有银弹只有权衡分享一下我做过的几个真实业务选择订单金额统计类报表。对数据一致性要求极高同一事务内多次聚合查询必须结果一致。这种我建议保留 RR并且关键查询用FOR UPDATE或LOCK IN SHARE MODE锁好范围宁可牺牲一点并发也不能让金额算错。高并发秒杀、抢购类。核心是“防超卖”幻读在这个场景里主要体现在库存扣减。我一般不会把整个事务切到串行化而是利用“库存行”这一条记录上的乐观锁或行锁来做UPDATE stock SET count count - 1 WHERE product_id ? AND count 0。这种单行更新走的是记录锁并发度远高于串行化也天然避免了“范围插入”带来的幻读。异步任务、批量导入。多线程往同一张表插数据如果业务上允许“各自处理各自的范围”那用 RC 反而更稳间隙锁少了插入冲突和死锁自然少。总结成一句话如果业务对“范围一致性”的依赖不强只是为了防行内容变化RC 完全够用而且并发性能更好如果业务必须保证“同一个事务里查几次结果都一样”且不允许新数据插入“坑位”那就留在 RR 并用索引条件精准加临键锁。千万不要为了省事直接开串行化那等于把所有事务排队执行业务稍微并发一高就会被打爆。5. 实战复盘一次订单金额重复统计的排查5.1 问题现象与初步定位早两年我维护过一个电商订单系统订单表结构大致这样CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, KEY idx_user (user_id), KEY idx_created (created_at) ) ENGINEInnoDB;某天运营反馈对账系统里跑出来的当日成交金额比订单库直接 SUM 出来的金额多了两笔。对账系统和订单库不是同一个库问题出在哪边看不出来于是先在订单库里排查。我先看事务日志发现对账任务用的存储过程大概是这样的逻辑BEGIN; SELECT SUM(amount) INTO total FROM orders WHERE created_at 2024-06-01 00:00:00 AND created_at 2024-06-02 00:00:00 AND status 1; -- 接下来又做了一些其他表的统计和状态更新 COMMIT;看起来是典型的“先统计、后处理”的长事务。问题暴露在这个事务执行期间订单表本身还在接收新的订单写入而且对账任务在统计完之后还做了若干 UPDATE 操作这些 UPDATE 会触发当前读导致原来快照读没看到的行在当前读阶段被“捞”了进来。最终 SUM 的结果和运营手动查库核对的口径不一致。5.2 揪出幻读用临键锁修复排查思路分三步走第一步确认隔离级别。生产库配置是默认的REPEATABLE READ理论上不应该有幻读。这反而让问题更有迷惑性。第二步复现。我开了一个事务先做同样的SELECT SUM(...)再开另一个会话往 orders 表插入一条符合时间范围的新订单并提交回到第一个事务执行UPDATE orders SET status 1 WHERE created_at ...触发当前读结果这条新订单确实被更新了。这说明混搭读取路径让快照和当前读之间产生了“结果缝隙”。第三步修复。修法不是把整个表锁死而是在下游数据要求“统计结果不受插入影响”的时间窗口内直接对统计范围加临键锁让其他事务在这段时间内不能往该范围内插入新订单。核心 SQL 改成BEGIN; -- 先抢占统计范围内的“插入坑位” SELECT id FROM orders WHERE created_at 2024-06-01 00:00:00 AND created_at 2024-06-02 00:00:00 FOR UPDATE; -- 再执行聚合统计 SELECT SUM(amount) FROM orders WHERE created_at 2024-06-01 00:00:00 AND created_at 2024-06-02 00:00:00 AND status 1; -- 后续业务处理 COMMIT;有人会问为什么加锁查询要写成SELECT id FROM ... FOR UPDATE而不是SELECT SUM(...) FOR UPDATE因为聚合函数会把结果集压实成一行InnoDB 加锁的时候可能只对“聚合结果”所在的行加锁未必会完整覆盖所有源记录对应的间隙。先查出id列表再锁才能在扫描过程中对范围加足临键锁。改完之后对账任务在统计期间如果有新订单进来插入操作会阻塞在锁上直到事务提交后才会被写入。这保证了统计结果的稳定。代价是统计窗口内订单写入有一定等待但对账任务本身是凌晨低峰期跑的影响可以忽略。6. 高频问题速查与避坑建议6.1 常见问题速查表问题可能原因解决方案RR 级别下SELECT两次结果不一样第一次是快照读第二次是当前读路径混搭统一读取路径要么全用快照读、要么全用当前读一个事务里SELECT没看到新行UPDATE却把新行改了UPDATE 触发当前读并加锁读到其他事务已提交的插入行在 UPDATE 前先用FOR UPDATE锁范围或业务上用唯一索引兜底插入语句一直等待没有死锁报错事务持有了目标间隙上的间隙锁/临键锁检查事务是否对同一范围做过当前读缩短事务持锁时间SELECT ... FOR UPDATE锁住了整张表查询条件没走索引InnoDB 扫描大量间隙给条件字段建索引缩小锁范围同一段代码在 RC 下不复现异常RR 下复现隔离级别和锁行为不同导致加锁范围变化确认测试和生产隔离级别一致再调整 SQL 或级别聚合函数SUM后加锁结果依然不稳聚合查询可能没有对源记录全部加锁先锁id列表再执行聚合统计6.2 我踩过的几个坑分享出来帮你省时间先说说间隙锁误伤这个坑。有一次我对一个cate_id字段做范围更新以为只锁了符合条件那几行。结果因为cate_id索引区分度低扫描范围覆盖了很多不相关的间隙连带着把其他分类的插入也挡住了。后来我改用“先查主键列表再按主键逐条更新”锁从范围锁变成了记录锁问题解决。凡是遇到“只想锁几行结果锁了一片”的情况优先考虑让条件走唯一索引或者主键。再说死锁的复现与处理。临键锁让锁范围变大两个事务如果分别持有不同间隙的锁再互相索要对方间隙的锁很容易死锁。遇到死锁别慌MySQL 会自动回滚代价较小的事务并抛Deadlock found when trying to get lock错误。处理思路一般是保证多个事务访问同一批数据的顺序一致比如都按主键从小到大处理或者把长事务拆短减少同时持有多个锁的窗口。我在订单对账系统里就统一规定处理一批订单前先按 id 升序执行一次加锁查询再逐个更新死锁率基本归零。最后是测试环境里“复现不出来”的坑。很多同学在本地 Navicat 里建了个小表怎么测都复现不了幻读以为是教程骗人。实际上大概率是隔离级别没对上。MySQL 5.7 之后可以直接运行SELECT transaction_isolation;如果返回的是READ-COMMITTED那你做的实验当然全都不“灵”。另外 Navicat 默认会自动开启 autocommit你的事务还没等你执行完就悄悄提交了也会让锁的表现“不一致”。建议做实验时全程手动执行BEGIN并且把工具的自动提交关掉。还有一个小细节SELECT ... FOR UPDATE在事务外执行是没有任何意义的因为语句一结束事务就提交了锁马上释放。想观察锁的行为务必在BEGIN和COMMIT之间执行所有语句并且打开两个独立的数据库连接。我个人在实际操作中的体会是幻读这个问题理论背得再熟不如自己用两个客户端开两个会话亲手插入、加锁、提交、观察阻塞跑通一遍之后印象会非常深。以后线上再遇到“范围统计不一致”“并发插入堵住”之类的问题你脑子里会直接浮现出那条加锁 SQL 和临键锁的范围图排查效率会提升一大截。如果这篇文章帮你搞懂了幻读我建议你下一步把SHOW ENGINE INNODB STATUS里的锁信息输出读一读那里能看到实际持有的锁范围是进入 InnoDB 底层调试最好的入口。