幻读(Phantom Read)

什么是幻读?

定义

幻读是指在同一事务内,多次执行同一查询,结果集不一致(因为其他事务插入或删除了符合条件的记录)。

问题本质

幻读的核心问题是:同一查询返回的记录数发生了变化

  • 事务A第一次执行查询,得到 N 条记录
  • 事务B在事务A查询后,插入了符合查询条件的新记录并提交
  • 事务A再次执行同一查询,得到 N+1 条记录(多了”幻影”记录)
  • 或者事务A再次执行查询,得到 N-1 条记录(少了记录)

幻读 vs 不可重复读

重要区别

  • 不可重复读:同一数据内容发生变化(UPDATE)
  • 幻读:同一查询结果集发生变化(INSERT/DELETE)
不可重复读:同一数据内容变化
     ↓
     读取的是同一条记录,但值变了

幻读:查询结果集记录数变化
     ↓
     读取的是多个记录,多了或少了几条记录

瑞幸场景示例

场景:查询库存不足的咖啡列表

业务背景: 后台管理系统查询所有库存不足10杯的咖啡,准备补货。同时,运营人员可能在上架新品。

时间线:

T1: 事务A查询"库存<10的咖啡列表"
    → 第一次查询:返回 5 条记录(5款咖啡库存不足)

T2: 事务B上架新品,库存设为 8 杯,提交
    → INSERT INTO coffee (name, stock) VALUES ('新品咖啡', 8);
    → 事务B提交

T3: 事务A再次查询"库存<10的咖啡列表"
    → 第二次查询:返回 6 条记录(多了1条!像幻觉一样)

问题:事务A以为只有5款咖啡库存不足
     但实际上有6款(多了个"幻影")

场景:统计符合条件的订单数量

初始:订单表中有 100 条符合条件的订单

T1: 事务A统计符合条件的订单数量 = 100

T2: 事务B批量插入 10 条新订单,提交

T3: 事务A再次统计 = 110

T4: 事务A准备处理这 100 条订单
    → 但实际上有 110 条,多处理了 10 条"幻影"订单

场景:删除操作中的幻读

T1: 事务A查询所有未支付的订单(10条)

T2: 事务B插入 2 条新订单(未支付),提交

T3: 事务A再次查询未支付订单(12条)

T4: 事务A执行批量删除未支付订单
    → 删除了 12 条订单(包括新插入的 2 条)
    
问题:事务A原计划删除 10 条订单
     但实际删除了 12 条,多删了 2 条

幻读的危害

危害类型说明示例
数据不一致结果集记录数不一致查询结果”变多”或”变少”
业务逻辑错误基于不准确的记录数做决策补货数量错误、处理错误数量的订单
重复操作处理了不应该处理的记录重复发放优惠券、重复扣款
遗漏操作漏处理了应该处理的记录漏发通知、漏处理订单

如何防止幻读?

1. 提高隔离级别

解决方案

将数据库隔离级别设置为 SERIALIZABLE,可以有效防止幻读。

隔离级别幻读说明
READ_UNCOMMITTED✅ 可能发生无隔离
READ_COMMITTED✅ 可能发生每次读取生成新快照
REPEATABLE_READ✅ 可能发生(部分)快照读防止,但当前读可能发生
SERIALIZABLE❌ 防止完全串行化,锁定范围

2. 使用锁机制(范围锁)

范围锁原理

通过锁定查询涉及的数据范围(包括间隙),防止其他事务在范围内插入或删除数据。

-- 使用范围锁防止幻读
BEGIN;
 
-- 锁定库存<10的咖啡(锁定这个范围,防止插入/删除)
SELECT * FROM coffee WHERE stock < 10 FOR UPDATE;
-- 其他事务无法在这个范围内插入新记录
 
COMMIT;

3. 快照读 vs 当前读

重要区别

REPEATABLE_READ 隔离级别下,MVCC 可以防止快照读的幻读,但无法防止当前读的幻读。

读取类型说明是否受MVCC保护是否防止幻读
快照读普通 SELECT 语句✅ 是✅ 防止(RR级别)
当前读SELECT … FOR UPDATE❌ 否❌ 可能幻读
当前读INSERT/UPDATE/DELETE❌ 否❌ 可能幻读
-- 快照读(不会幻读,RR级别)
SELECT * FROM coffee WHERE stock < 10;
-- 在整个事务期间,看到的都是事务开始时的数据快照
 
-- 当前读(可能幻读)
SELECT * FROM coffee WHERE stock < 10 FOR UPDATE;
-- 读取最新数据,可能看到新插入的记录

4. MySQL InnoDB 的 Next-Key Lock

Next-Key Lock

InnoDB 使用 Next-Key Lock(记录锁 + 间隙锁)来防止幻读,锁定查询范围内的所有间隙。

-- 假设 coffee 表有 stock 值为:5, 10, 15, 20
 
-- 查询 stock < 10
SELECT * FROM coffee WHERE stock < 10 FOR UPDATE;
 
-- Next-Key Lock 会锁定:
-- 1. stock = 5 的记录(记录锁)
-- 2. (5, 10) 的间隙(间隙锁)
-- 3. 负无穷到 5 的间隙
 
-- 其他事务无法插入 stock = 6, 7, 8, 9 的记录

幻读 vs 不可重复读 vs 脏读

三种并发异常对比

异常类型问题描述变化内容隔离级别
脏读读取未提交数据数据可能不存在READ_UNCOMMITTED
不可重复读同一数据多次读取结果不同数据内容变化READ_COMMITTED
幻读同一查询结果集变化记录数变化REPEATABLE_READ

详细对比

flowchart TD
    A[并发异常] --> B[脏读 Dirty Read]
    A --> C[不可重复读 Non-repeatable Read]
    A --> D[幻读 Phantom Read]
    
    B --> B1[读取未提交数据]
    B1 --> B2[数据可能回滚消失]
    
    C --> C1[同一数据内容变化]
    C1 --> C2[UPDATE操作导致]
    
    D --> D1[结果集记录数变化]
    D1 --> D2[INSERT/DELETE操作导致]
    
    style B fill:#FFB6C1
    style C fill:#FFD700
    style D fill:#87CEEB

隔离级别与并发异常

隔离级别脏读不可重复读幻读说明
READ_UNCOMMITTED无隔离
READ_COMMITTED防止脏读
REPEATABLE_READ✅(快照读防止)MySQL默认级别
SERIALIZABLE完全串行化

MVCC 中的幻读

快照读防止幻读

MVCC 原理

MVCC 通过快照读可以防止幻读,因为快照是基于事务开始时的数据状态。

REPEATABLE_READ 隔离级别下的快照读:

T1: 事务A开启,生成 Read View

T2: 事务A查询"库存<10的咖啡"
    → 快照结果:5条记录

T3: 事务B插入1条库存=8的咖啡,提交

T4: 事务A再次查询"库存<10的咖啡"(快照读)
    → 仍然返回5条记录(快照未变)
    → 没有幻读!

当前读无法防止幻读

REPEATABLE_READ 隔离级别下的当前读:

T1: 事务A开启

T2: 事务A使用 FOR UPDATE 查询"库存<10的咖啡"
    → 返回5条记录(当前读)

T3: 事务B插入1条库存=8的咖啡,提交

T4: 事务A再次使用 FOR UPDATE 查询"库存<10的咖啡"
    → 返回6条记录(多了1条!)
    → 发生幻读!

实际开发建议

1. 选择合适的隔离级别

选择建议

  • READ_COMMITTED:大多数场景,防止脏读即可
  • REPEATABLE_READ:需要防止不可重复读,但性能较好(MySQL默认)
  • SERIALIZABLE:对数据一致性要求极高,性能较差

2. 使用事务避免幻读

// ❌ 可能幻读的场景
@Transactional
public void processLowStockCoffee() {
    List<Coffee> lowStockList = coffeeRepository.findByStockLessThan(10);
    // 第一次查询:5条
    
    // 业务逻辑...
    
    List<Coffee> lowStockList2 = coffeeRepository.findByStockLessThan(10);
    // 第二次查询:可能变成6条!
    
    // 处理结果不一致
}
 
// ✅ 使用当前读避免幻读(配合锁)
@Transactional
public void processLowStockCoffee() {
    @Query("SELECT c FROM Coffee c WHERE c.stock < 10 FOR UPDATE")
    List<Coffee> findLowStockWithLock();
    
    // 使用 FOR UPDATE 锁定范围,防止幻读
    List<Coffee> lowStockList = findLowStockWithLock();
    
    // 整个事务期间,查询结果一致
    // 不会发生幻读
}

3. 合理使用索引

重要提醒

幻读主要发生在范围查询中,合理使用索引可以提高锁的精确度,减少锁的范围。

-- 好的索引设计
CREATE INDEX idx_stock ON coffee(stock);
 
-- 查询时会锁定 stock < 10 的范围
SELECT * FROM coffee WHERE stock < 10 FOR UPDATE;
-- 锁定的间隙较小
 
-- 缺少索引
-- 可能导致全表扫描,锁定更大的范围

总结

幻读的核心要点

幻读 = 同一查询结果集记录数发生变化

特征:
├── 记录数增加或减少
├── 由 INSERT/DELETE 操作导致
├── 发生在范围查询中
└── 可能导致业务逻辑错误

解决方案:
├── 提高隔离级别(SERIALIZABLE)
├── 使用范围锁(FOR UPDATE)
├── 使用 Next-Key Lock(InnoDB)
└── 合理设计索引

发生场景:
├── REPEATABLE_READ 隔离级别(当前读)
├── 范围查询(WHERE ... <, >, BETWEEN)
├── 插入/删除操作
└── 并发事务

与不可重复读的区别

幻读:结果集记录数变化(INSERT/DELETE)
     ├── 第一次查询:5条记录
     └── 第二次查询:6条记录

不可重复读:同一数据内容变化(UPDATE)
     ├── 第一次查询:某条记录 stock=5
     └── 第二次查询:同一条记录 stock=6

与脏读、不可重复读的关系

关联阅读


文档信息

  • 创建日期:2026-05-23
  • 适用读者:后端开发工程师、数据库开发者
  • 难度级别:入门