在并发事务场景中,同一个范围查询可能在不同时刻返回不同的行集合。数据库通过事务隔离级别来控制事务之间数据可见性的边界,其中 READ COMMITTED 与 REPEATABLE READ 在幻读表现上差异明显。

幻读并不是数据库错误,而是并发控制策略的体现。理解两种隔离级别在快照读和当前读下的行为,可以帮助开发者设计更可靠的事务逻辑。
一、事务隔离级别与幻读的定义边界
SQL 标准定义了四种事务隔离级别:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ 和 SERIALIZABLE。它们分别在不同程度上解决脏读、不可重复读和幻读这三类并发问题。脏读是指事务读取到另一个事务尚未提交的数据;不可重复读是指同一行数据在事务内两次读取时值发生变化;而幻读则是指同样的查询条件下,结果集的行数发生了变化,通常由其他事务插入或删除满足条件的行导致。
幻读与不可重复读的关键区别在于变化发生在行的集合而不是行本身。例如第一次查询客户 100 的订单返回 2 行,第二次查询返回 3 行,多出来的那一行就是幻影行。它并不影响已读取订单的金额或状态,却改变了整体统计结果。许多开发者容易把幻读与不可重复读混为一谈,但后者的典型场景是同一主键行的某个字段被其他事务更新。
在快照读层面,READ COMMITTED 和 REPEATABLE READ 采用不同的读视图生成策略。READ COMMITTED 每条语句都会创建新的读视图,因此后续语句能看到已经提交的插入;REPEATABLE READ 则在事务开始后的第一次读取时创建读视图,后续普通查询一直复用该视图,从而保持结果集稳定。不过当前读比如 SELECT FOR UPDATE、UPDATE、DELETE 等会读取最新已提交数据并加锁,其幻读行为要结合锁机制来看。
二、READ COMMITTED 下幻读的复现
先创建一张订单表用于演示。
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
amount DECIMAL(10,2),
KEY idx_customer (customer_id)
);
在 READ COMMITTED 隔离级别下启动事务 A,先执行一次范围查询,然后让事务 B 插入一行符合条件的数据并提交,事务 A 再执行同样的查询,结果集会发生变化。
-- 事务 A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT * FROM orders WHERE customer_id = 100; -- 结果:3 行 -- 事务 B -- 此时插入一行并提交 INSERT INTO orders (id, customer_id, amount) VALUES (4, 100, 99.00); COMMIT; -- 事务 A 继续 SELECT * FROM orders WHERE customer_id = 100; -- 结果:4 行,出现幻读 COMMIT;
上面的序列中,事务 A 两次查询之间没有对相关范围加锁,而 READ COMMITTED 每次执行 SELECT 都会生成新的一致性读视图。因此事务 B 提交的插入在第二次查询时已经可见。这种语句级快照的优点是能更及时地看到其他事务提交的结果,适合对实时性要求较高的业务,但它无法保证事务内同一查询的结果稳定。
如果事务 A 在第一次查询时使用了当前读,READ COMMITTED 同样不能阻止幻影行的出现。因为 MySQL InnoDB 在 READ COMMITTED 级别下对 UPDATE 或 DELETE 只锁定实际扫描到的行记录,不添加间隙锁,其他事务仍然可以在这些行旁边插入满足条件的新记录。假设事务 A 执行 SELECT * FROM orders WHERE customer_id = 100 FOR UPDATE,它只锁住已存在的订单行,而 customer_id = 100 的索引区间没有被锁定,事务 B 仍可插入新行并提交,事务 A 再次当前读时会看到新行。因此对该隔离级别来说,无论快照读还是当前读,幻读都可能发生。
三、REPEATABLE READ 下幻读为何被抑制
设置隔离级别为 REPEATABLE READ 后,事务 A 第一次查询会建立一个读视图。后续普通 SELECT 继续使用这个视图,而不是重新生成。即使事务 B 插入并提交了新行,事务 A 也看不到,因为新行的事务 ID 大于读视图的创建 ID,被认为尚未提交。
-- 事务 A SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM orders WHERE customer_id = 100; -- 结果:3 行 -- 事务 B INSERT INTO orders (id, customer_id, amount) VALUES (5, 100, 199.00); COMMIT; -- 事务 A 继续 SELECT * FROM orders WHERE customer_id = 100; -- 结果仍然是 3 行,没有幻读 COMMIT;
这种机制依赖 InnoDB 的 MVCC 实现。每行记录包含隐藏的事务 ID 和回滚指针,事务开始时的读视图记录了当时处于活跃状态的事务列表。查询时只会读取事务 ID 小于等于读视图创建者且不在活跃列表中的版本。因此事务 B 提交的插入即使已经落盘,对事务 A 的读视图来说仍然位于未来版本,不可见。
不过 REPEATABLE READ 抑制幻读主要针对快照读。若事务 A 使用当前读,比如 SELECT ... FOR UPDATE 或执行 UPDATE 语句,情况会变成依赖锁策略。MySQL InnoDB 在 REPEATABLE READ 级别下对范围条件会使用 next-key lock,也就是行锁加间隙锁的组合,把索引扫描范围内的已存在记录及其间隙都锁住。这样事务 B 无法插入符合条件的行,只有等事务 A 提交后才能继续,因此当前读也不会出现幻读。例如:
-- 事务 A SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM orders WHERE customer_id = 100 FOR UPDATE; -- 锁住 customer_id = 100 的索引记录和相邻间隙 -- 事务 B 此时尝试插入会被阻塞 INSERT INTO orders (id, customer_id, amount) VALUES (6, 100, 299.00); -- 等待事务 A 释放锁
如果索引键不存在或表没有合适索引,next-key lock 可能退化为较大范围的间隙锁或表锁,虽然能阻止插入,但也会降低并发性能。这正是 REPEATABLE READ 在 MySQL 中默认启用却可能带来死锁和锁等待的原因之一。
四、实际业务中的选择与避坑
从并发度来看,READ COMMITTED 由于不使用间隙锁,锁范围更小,死锁概率通常低于 REPEATABLE READ,适合以单行更新为主、要求高吞吐的业务。代价是事务内多次查询可能出现不一致的集合,统计类业务如果依赖固定快照,就不能使用 READ COMMITTED。例如需要在事务开始时读一次订单总数,然后根据这个总数做后续处理,期间其他事务插入新订单会导致统计失真。
REPEATABLE READ 适合需要事务内多次读取结果保持稳定、同时存在范围查询或批量更新场景的业务。MySQL 将 REPEATABLE READ 作为默认隔离级别,而 Oracle、PostgreSQL 等系统的默认级别是 READ COMMITTED。在跨数据库迁移或调整隔离级别时,必须重新验证幻读相关的业务假设,否则会出现此前被间隙锁保护的操作迁移后突然发生幻读的情况。
无论选择哪种隔离级别,都应避免长时间持有锁或启动长事务。在 REPEATABLE READ 下,长事务会阻止回滚段被回收,增加存储压力;在 READ COMMITTED 下,长事务虽然不阻止 purge,但如果逻辑上依赖一致快照,需要额外使用显式锁定或乐观锁来保护关键区间。理解快照读和当前读的区别,比单纯记忆隔离级别名称更有价值,它决定了事务边界内数据的一致性和并发行为。
最终结论是:READ COMMITTED 与 REPEATABLE READ 的幻读差异根源于读视图生成时机和间隙锁策略。前者每条语句建立新视图且当前读不加间隙锁,极易出现幻读;后者复用事务级读视图并配合 next-key lock,能有效抑制普通查询和当前读场景下的幻读。开发者在选择隔离级别时,应根据业务对结果集稳定性的要求、并发吞吐和锁冲突风险综合权衡。
READ COMMITTEDREPEATABLE READ幻读修改时间:2026-08-26 16:34:28