💳 Раздел 11 · Вопрос #9

Что такое фантомное чтение (Phantom Read)

Вновь возникшие строки, которых не было при первом чтении, называют «строками-фантомами».


🟢 Junior Level

Фантомное чтение (Phantom Read) — это аномалия параллельного доступа к данным, при которой одна транзакция дважды выполняет поиск по диапазону строк с одним и тем же предикатным условием (например, WHERE status = 'NEW'), но второй запрос возвращает другой набор строк (появились новые строки или исчезли старые), потому что параллельная транзакция успела вставить (INSERT) или удалить (DELETE) записи и зафиксировать коммит (COMMIT).

Вновь возникшие строки, которых не было при первом чтении, называют «строками-фантомами».

Пример сценария фантомного чтения

Время    Транзакция А (Генерация отчета)         Транзакция Б (Новый заказ клиента)
  |      -------------------------------         ----------------------------------
  |  T1: BEGIN;
  |  T2: SELECT COUNT(*) FROM orders             -- Результат: 10 заказов
  |      WHERE status = 'PENDING';
  |  T3:                                         BEGIN;
  |  T4:                                         INSERT INTO orders (id, status, amount)
  |                                              VALUES (11, 'PENDING', 5000);
  |                                              COMMIT; -- Зафиксировано!
  |  T5: SELECT SUM(amount) FROM orders          -- Считает сумму уже по 11 заказам!
  |      WHERE status = 'PENDING';               -- Итоговый отчет: 10 заказов на сумму 11 заказов!
  v  T6: COMMIT;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderAuditService {

    // Защита от фантомов на уровне базы данных:
    @Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
    public AuditReport calculateDailySummary() {
        long count = orderRepository.countByStatus("PENDING");
        BigDecimal total = orderRepository.sumByStatus("PENDING");
        // На уровне Repeatable Read в PostgreSQL/MySQL оба запроса
        // увидят строго одинаковый набор строк.
        return new AuditReport(count, total);
    }
}

Аналогия: Экскурсовод пересчитывает туристов в автобусе: «Всего 20 человек». Пока он делает пометку в журнале, в заднюю дверь тихо заходят еще двое опоздавших. Экскурсовод считает снова — теперь туристов 22. Двое новых туристов — это фантомы.


🟡 Middle Level

Различие: Неповторяющееся чтение vs Фантомное чтение

Критерий Неповторяющееся чтение (Non-Repeatable Read) Фантомное чтение (Phantom Read)
Что меняется Значения в уже существующих строках Количество строк в результирующей выборке
SQL-команда конкурента UPDATE (или точечный DELETE) INSERT (или массовый DELETE по диапазону)
Тип предиката Точечный поиск строки: WHERE id = 42 Предикатный диапазон: WHERE price > 100
Как защититься Строчные блокировки SELECT FOR UPDATE Блокировка диапазонов (Gap Locks) или MVCC Snapshot

Поведение Repeatable Read в различных СУБД

По формальному стандарту ANSI SQL-92 уровень Repeatable Read обязан защищать от Dirty Read и Non-Repeatable Read, но имеет право допускать фантомное чтение:

СУБД Фантомы на Repeatable Read? Механизм защиты
PostgreSQL ❌ Защищено (для чистых SELECT) MVCC Snapshot с момента первого запроса
MySQL (InnoDB) ❌ Защищено (для SELECT и FOR UPDATE) Next-Key Locks (Record Lock + Gap Lock)
Oracle ❌ Защищено MVCC Snapshot (аналог PostgreSQL)
MS SQL Server ⚠️ Возможны! Ставит S-блокировки только на найденные строки, зазоры свободны

🔴 Senior Level

Механика Next-Key Locking в MySQL (InnoDB)

В MySQL InnoDB на уровне Repeatable Read для предотвращения фантомов используется гибридный механизм:

\[\text{Next-Key Lock} = \text{Record Lock (блокировка строки)} + \text{Gap Lock (блокировка промежутка)}\]
-- Таблица с записями id = 5, 10, 15
SELECT * FROM orders WHERE id BETWEEN 8 AND 12 FOR UPDATE;

Что именно блокирует InnoDB:

  1. Record Lock: Блокирует саму запись id = 10.
  2. Gap Lock (слева): Блокирует интервал $(5, 10)$ — ни одна другая транзакция не может выполнить INSERT со значением $id \in [6, 9]$.
  3. Gap Lock (справа): Блокирует интервал $(10, 15)$ — ни одна транзакция не может выполнить INSERT со значением $id \in [11, 14]$.

Побочный эффект Gap Locks: Блокировки зазоров не конфликтуют между собой на чтение, но конфликтуют со вставками. Если две параллельные транзакции накладывают пересекающиеся Gap-блокировки, последующие операторы INSERT обеих транзакций гарантированно приведут к взаимной блокировке (Deadlock).

Механика Predicate Locks в PostgreSQL (SSI)

PostgreSQL на уровне Repeatable Read не использует блокировки зазоров, полагаясь на неизменяемость MVCC-снимка. Однако на уровне Serializable для полной изоляции задействуется алгоритм SSI (Serializable Snapshot Isolation):

  • При предикатном запросе SELECT * FROM orders WHERE status = 'PENDING' регистрируется Predicate Lock (предикатный замок в оперативной памяти).
  • Предикатный замок связывается со страницами индекса B-Tree, покрывающими условие status = 'PENDING'.
  • Если параллельная транзакция вставляет новую запись, попадающую в эту же страницу индекса, ядро PostgreSQL мгновенно фиксирует возникновение rw-антизависимости ($T_{reader} \xrightarrow{rw} T_{inserter}$).
  • При обнаружении циклов зависимостей одна из транзакций прерывается с ошибкой сериализации 40001.

4 Tricky Questions

1. Почему в стандарте ANSI SQL уровень Repeatable Read допускает появление фантомов, а в PostgreSQL они не воспроизводятся при обычных SELECT?

Ответ: Стандарт ANSI SQL-92 создавался в эпоху доминирования классических блокировочников (Lock-Based Concurrency Control / 2PL). В блокировочных СУБД для обеспечения Repeatable Read на прочитанные строки вешались разделяемые блокировки (S-locks). Поскольку блокировались только уже существующие строки, новая строка свободно вставлялась в таблицу параллельной транзакцией, порождая фантом. В PostgreSQL изоляция реализована на базе MVCC. Снимок данных замораживает транзакционный горизонт (xmin/xmax). Любая новая строка, вставленная параллельной транзакцией, получает xmin, который больше горизонта текущего снимка, и аппаратно отсекается ядром СУБД при любых последующих SELECT.


2. Может ли SELECT ... FOR UPDATE на уровне Read Committed защитить от появления фантомов при вставке новых записей?

Ответ: Нет, в общем случае не может! Запрос SELECT ... FOR UPDATE на уровне Read Committed блокирует монопольными блокировками (X-locks) исключительно те строки, которые уже физически существуют в таблице на момент выполнения запроса. Если параллельная транзакция выполняет INSERT новой строки, удовлетворяющей условию предиката:

  • Новой строки в таблице еще не было, заблокировать ее было невозможно.
  • На уровне Read Committed блокировки зазоров (Gap Locks) не накладываются.
  • Вставка успешно выполнится и закоммитится. Повторный запрос SELECT ... FOR UPDATE на уровне Read Committed увидит новую строку и наложит на нее замок, зафиксировав фантом. Предотвратить фантомы можно только на уровне Serializable или Repeatable Read в MySQL.

3. В чем заключается аномалия «Фантомного обновления» (Phantom Update) в PostgreSQL на уровне Repeatable Read?

Ответ: Хотя обычный SELECT в PostgreSQL на Repeatable Read защищен от фантомов, операция UPDATE работает по текущим закоммиченным данным (Current Read). Рассмотрим сценарий:

-- Транзакция T1 (Repeatable Read):
SELECT count(*) FROM orders WHERE status = 'PENDING'; -- Видит 5 строк

-- Транзакция T2:
INSERT INTO orders (status) VALUES ('PENDING'); -- Вставляет 6-ю строку!
COMMIT;

-- Транзакция T1 выполняет массовый UPDATE:
UPDATE orders SET status = 'PROCESSED' WHERE status = 'PENDING';
-- Результат: 6 rows updated! (T1 обновила строку, которую сама не видела!)

-- Повторный SELECT в T1:
SELECT count(*) FROM orders WHERE status = 'PROCESSED'; -- Вернет 6 строк!

Транзакция $T1$ обновила скрытую от нее строку-фантом, а согласно правилам MVCC транзакция всегда видит строки, модифицированные ею самой.


4. Почему использование временных таблиц (Temporary Tables) может нарушить защиту от фантомов на уровне Serializable в PostgreSQL?

Ответ: В PostgreSQL алгоритм Serializable Snapshot Isolation (SSI) отслеживает предикатные блокировки (SIREAD locks) только для постоянных таблиц базы данных. Временные таблицы (CREATE TEMP TABLE) сессионно изолированы и не регистрируются в общем менеджере предикатных блокировок shared memory. Если бизнес-логика копирует данные во временную таблицу, выполняет над ней агрегатные расчеты и принимает решения для основной базы, алгоритм SSI не сможет обнаружить межтранзакционные конфликты зависимостей (rw-antidependencies), что приведет к пропуску фантомов и нарушению сериализуемости.


🎯 Шпаргалка для интервью

  • Суть Phantom Read: Повторный предикатный запрос (WHERE condition) возвращает другое количество строк из-за параллельного INSERT или DELETE и COMMIT.
  • Разница с Non-Repeatable Read:
    • Non-Repeatable Read — меняются поля уже прочитанной строки (UPDATE).
    • Phantom Read — меняется состав строк в диапазоне выборки (INSERT/DELETE).
  • Поведение в СУБД:
    • PostgreSQL: MVCC-снимок защищает от фантомов при обычных SELECT на уровне Repeatable Read.
    • MySQL InnoDB: Next-Key Locks (Record + Gap Locks) защищают от фантомов на Repeatable Read.
    • MS SQL Server: На Repeatable Read фантомы возможны.
  • Next-Key Lock: Комбинация блокировки записи и блокировки интервалов между индексами (предотвращает вставки в зазоры, но провоцирует Deadlocks).
  • Абсолютная защита: Гарантируется только на уровне Serializable.

Связанные темы