← Назад к вопросам

В чем заключается аномалия фантомное чтение

3.0 Senior🔥 61 комментариев
#Базы данных и SQL

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

# Аномалия "Фантомное чтение" (Phantom Read)

Краткий ответ

"Фантомное чтение" (Phantom Read) — это аномалия изолированности транзакций, когда одна транзакция читает набор строк, удовлетворяющих условию WHERE, а другая транзакция в это время добавляет новые строки, которые тоже соответствуют этому условию. При повторном чтении первая транзакция видит "фантомные" новые строки.

Определение и различие от других аномалий

Три аномалии изолированности

АномалияОписаниеКогда происходит
Dirty ReadОдна транзакция читает несохранённые данные другойIsolation < READ_COMMITTED
Non-repeatable ReadПри повторном чтении одной СТРОКИ видны разные значенияIsolation < REPEATABLE_READ
Phantom ReadПри повторном чтении WHERE условия видны новые СТРОКИIsolation < SERIALIZABLE

Ключевое отличие

Non-repeatable Read: Одна СТРОКА изменилась (UPDATE)
Phantom Read:        Количество СТРОК изменилось (INSERT/DELETE)

Пример Phantom Read

Сценарий

Табль accounts:
┌──────┬──────────┬─────────┐
│ id   │ name     │ balance │
├──────┼──────────┼─────────┤
│ 1    │ Alice    │ 1000    │
│ 2    │ Bob      │ 500     │
└──────┴──────────┴─────────┘

Временная шкала

Время │ Транзакция 1              │ Транзакция 2
──────┼───────────────────────────┼─────────────────────────────
      │ BEGIN TRANSACTION          │
T1    │ SELECT COUNT(*) WHERE      │ BEGIN TRANSACTION
      │ balance > 700             │
      │ Результат: 1 (только Alice)│
T2    │                           │ INSERT INTO accounts
      │                           │ (Charlie, 800)
T3    │                           │ COMMIT
T4    │ SELECT COUNT(*) WHERE      │
      │ balance > 700             │
      │ Результат: 2 (Alice + Charlie!) │ ← Это Phantom Read!
T5    │ COMMIT                    │

Код примера на Java + Hibernate

Пример 1: Простая демонстрация

@Service
@Transactional(isolation = Isolation.READ_COMMITTED)
public class PhantomReadExample {
    
    @Autowired
    private AccountRepository accountRepository;
    
    public void demonstratePhantomRead() throws InterruptedException {
        // Первый запрос
        List<Account> firstRead = accountRepository.findAllWithBalance(700);
        System.out.println("Первое чтение: " + firstRead.size());  // 1
        
        // В это время другой поток добавляет новый аккаунт
        Thread.sleep(1000);  // Даём время другой транзакции
        
        // Второй запрос
        List<Account> secondRead = accountRepository.findAllWithBalance(700);
        System.out.println("Второе чтение: " + secondRead.size());  // 2 (фантомное!)
    }
}

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
    @Query("SELECT a FROM Account a WHERE a.balance > :minBalance")
    List<Account> findAllWithBalance(@Param("minBalance") BigDecimal minBalance);
}

Пример 2: Phantom Read в многопоточной среде

@Service
public class TransactionService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Autowired
    private ApplicationContext context;
    
    public void testPhantomRead() throws InterruptedException {
        // Стартуем две транзакции в параллели
        Thread t1 = new Thread(() -> transactionT1());
        Thread t2 = new Thread(() -> transactionT2());
        
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();
    }
    
    @Transactional(isolation = Isolation.READ_COMMITTED)
    private void transactionT1() {
        System.out.println("[T1] Начало транзакции");
        
        List<Account> accounts = accountRepository.findAllWithBalance(700);
        System.out.println("[T1] Первое чтение: " + accounts.size() + " аккаунтов");
        
        try {
            Thread.sleep(2000);  // Ждём, пока T2 добавит запись
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        
        List<Account> accountsAgain = accountRepository.findAllWithBalance(700);
        System.out.println("[T1] Второе чтение: " + accountsAgain.size() + " аккаунтов");
        
        if (accounts.size() != accountsAgain.size()) {
            System.out.println("⚠️ PHANTOM READ ОБНАРУЖЕНО!");
        }
    }
    
    @Transactional(isolation = Isolation.READ_COMMITTED)
    private void transactionT2() {
        try {
            Thread.sleep(500);  // Даём T1 время на первый запрос
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        
        System.out.println("[T2] Вставляю новый аккаунт");
        Account newAccount = new Account("Charlie", new BigDecimal("800"));
        accountRepository.save(newAccount);
        System.out.println("[T2] Новый аккаунт добавлен");
    }
}

Уровни изолированности и защита от Phantom Read

SQL уровни изолированности

Уровень                │ Dirty Read │ Non-Repeatable │ Phantom Read
─────────────────────┼────────────┼────────────────┼──────────────
READ_UNCOMMITTED      │ ✓ (есть)   │ ✓              │ ✓
READ_COMMITTED        │ ✗          │ ✓ (есть)       │ ✓
REPEATABLE_READ       │ ✗          │ ✗              │ ✓ (есть)
SERIALIZABLE          │ ✗          │ ✗              │ ✗

В Java/Spring

// Уязвим к Phantom Read
@Transactional(isolation = Isolation.READ_COMMITTED)
public void unsafeMethod() { }

// Защищён от Phantom Read
@Transactional(isolation = Isolation.SERIALIZABLE)
public void safeMethod() { }

Решение 1: SERIALIZABLE изолированность

@Service
public class AccountTransferService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public BigDecimal calculateAverageBalance() {
        List<Account> accounts = accountRepository.findAllWithBalance(700);
        // Теперь никто не может добавить/удалить аккаунты
        // пока выполняется эта транзакция
        return calculateAverage(accounts);
    }
}

Недостаток: SERIALIZABLE может быть медленным из-за блокировок.

Решение 2: Явная блокировка строк

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
    
    // Используем FOR UPDATE для блокировки
    @Query(value = 
        "SELECT a FROM Account a WHERE a.balance > :minBalance FOR UPDATE",
        nativeQuery = false)
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    List<Account> findAllWithBalanceForUpdate(@Param("minBalance") BigDecimal minBalance);
}

@Service
public class LockedAccountService {
    
    @Transactional
    public BigDecimal getAccountCount() {
        // Эта строка будет заблокирована для других транзакций
        List<Account> accounts = accountRepository.findAllWithBalanceForUpdate(new BigDecimal("700"));
        return new BigDecimal(accounts.size());
    }
}

Решение 3: Оптимистическая блокировка

@Entity
public class Account {
    @Id
    private Long id;
    
    private String name;
    private BigDecimal balance;
    
    @Version  // Версия для оптимистической блокировки
    private Long version;
}

@Service
public class OptimisticLockService {
    
    @Transactional
    public void transferMoney() {
        Account account = accountRepository.findById(1L).orElseThrow();
        account.setBalance(account.getBalance().subtract(new BigDecimal("100")));
        accountRepository.save(account);
        // Если версия изменилась — выбросится OptimisticLockException
    }
}

Решение 4: Event Sourcing / CQRS

Для систем с высокой нагрузкой:

@Service
public class EventSourcingService {
    
    @Autowired
    private EventStore eventStore;
    
    @Transactional
    public void processMoneyTransfer(MoneyTransferCommand cmd) {
        // Записываем событие (основной источник правды)
        AccountCreatedEvent event = new AccountCreatedEvent(
            cmd.getAccountId(),
            cmd.getAmount()
        );
        eventStore.append(event);
        
        // Фантомные строки невозможны, так как:
        // 1. События идемпотентны
        // 2. Порядок гарантирован
        // 3. Нет конкурирующего доступа
    }
}

Практический совет

Когда использовать SERIALIZABLE?

// ✓ Критичные финансовые операции
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transferMoney(Long from, Long to, BigDecimal amount) { }

// ✓ Отчёты с точными цифрами
@Transactional(isolation = Isolation.SERIALIZABLE)
public ReportData generateReport(Date startDate) { }

Когда хватит READ_COMMITTED?

// ✓ Чтение кеш-данных
@Transactional(isolation = Isolation.READ_COMMITTED)
public User getUserById(Long id) { }

// ✓ Обновление счётчиков (с небольшой погрешностью)
@Transactional(isolation = Isolation.READ_COMMITTED)
public void incrementViewCount(Long postId) { }

Итоги

  1. Phantom Read — это появление новых строк при повторном SELECT с WHERE
  2. Отличается от Non-Repeatable Read — там изменяется значение одной строки
  3. Защита:
    • SERIALIZABLE изолированность (медленно)
    • Явная блокировка WITH (SELECT FOR UPDATE)
    • Оптимистическая блокировка @Version
    • Event Sourcing для высоконагруженных систем
  4. На практике: используй READ_COMMITTED по умолчанию, SERIALIZABLE только для критичных операций