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

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

Фантомное чтение возникает из-за того, что большинство СУБД на уровнях ниже Serializable читают снимок данных (snapshot), но не блокируют диапазоны ключей (key ranges).

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

Фантомное чтение — это аномалия транзакций, при которой одна и та же выборка данных в рамках одной транзакции возвращает разное количество строк, потому что другая транзакция вставила или удалила подходящие записи.

Простая аналогия: Вы пересчитываете людей в комнате (получили 10 человек). Пока вы отворачиваетесь, кто-то входит. Вы снова пересчитываете — теперь 12 человек. Эти “новые” люди — фантомы.

SQL-пример:

-- Транзакция 1
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- вернёт 5

-- Пока Транзакция 1 открыта, Транзакция 2 делает:
-- INSERT INTO orders (status) VALUES ('pending');
-- COMMIT;

-- Транзакция 1 повторяет тот же запрос:
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- вернёт 6 — фантом!
COMMIT;

Ключевое отличие от неповторяющегося чтения:

  • Неповторяющееся чтение — изменилось значение существующей строки (UPDATE/DELETE).
  • Фантомное чтение — появилось совершенно новая строка (INSERT).

Какие уровни изоляции защищают: | Уровень | Фантомы возможны? | | —————- | ——————- | | Read Uncommitted | Да | | Read Committed | Да | | Repeatable Read | Зависит от СУБД | | Serializable | Нет |

Когда важно: Подсчёт итогов, генерация отчётов, проверка уникальности перед вставкой.


🟡 Middle Level

Как это работает внутри

Фантомное чтение возникает из-за того, что большинство СУБД на уровнях ниже Serializable читают снимок данных (snapshot), но не блокируют диапазоны ключей (key ranges).

Когда вы выполняете SELECT WHERE status = 'pending', база возвращает строки, существующие на момент снимка. Но если другая транзакция вставляет новую строку с status = 'pending', ваш повторный запрос увидит её как “фантом”.

Реальные сценарии

Сценарий 1: Генерация отчёта

@Transactional
public Report generateDailyReport(LocalDate date) {
    int orderCount = orderRepo.countByDate(date);       // 100 заказов
    double totalAmount = orderRepo.sumByDate(date);     // Считаем по 100 заказам
    
    // Если в этот момент вставится новый заказ за ту же дату,
    // orderCount и totalAmount станут несогласованными
    return new Report(orderCount, totalAmount);
}

Сценарий 2: Проверка уникальности перед вставкой

@Transactional
public void registerUser(String email) {
    long count = userRepo.countByEmail(email);  // = 0
    // Другая транзакция вставляет пользователя с этим email и коммитит
    userRepo.save(new User(email));              // Дубликат! Или unique violation, если есть индекс
}

Типичные ошибки

Ошибка Последствие Решение
Повторный SELECT в длинной транзакции для проверки данных Получение фантомных строк, неверная бизнес-логика Укоротить транзакцию или использовать Serializable
COUNT() + последующая обработка результатов Пропущенные или лишние записи Использовать FOR UPDATE или Serializable
Предположение, что Repeatable Read защищает от фантомов в PostgreSQL Ожидание защиты не оправдано — в PostgreSQL RR защищает от фантомов, в MySQL — нет Знать особенности своей СУБД

Сравнение: как разные СУБД обрабатывают фантомы на Repeatable Read

СУБД Фантомы на RR? Механизм
PostgreSQL Нет (защищает через MVCC) Snapshot с первого чтения, не видит новые строки
MySQL/InnoDB Нет (защищает через Next-Key Locks) Gap Locks блокируют диапазоны
Oracle Нет (MVCC) Аналогично PostgreSQL
SQL Server Да (не защищает) На RR видит новые строки при повторном запросе

Когда НЕ стоит бояться фантомов

  • Read-only отчёты с допустимой погрешностью
  • Кэширование — если данные могут быть слегка устаревшими
  • Статистика — приблизительные подсчёты допустимы

🔴 Senior Level

Internal Implementation: MVCC и Predicate Locks

Термины:

  • Predicate lock — блокировка не на конкретную строку, а на УСЛОВИЕ запроса (WHERE status=’pending’).
  • RW-dependency — ситуация, когда T1 читает данные, которые T2 потом записывает.
  • Coarsening — объединение множества мелких блокировок в одну крупную для экономии памяти.

MVCC-подход (PostgreSQL)

В PostgreSQL фантомное чтение на уровне Repeatable Read предотвращается архитектурой MVCC:

Каждая строка содержит системные поля:
  - xmin: transaction ID, которая вставила строку
  - xmax: transaction ID, которая удалила строку (0 = живая)

При SELECT:
  1. Берётся snapshot с текущим xmin
  2. Видны только строки где xmin < snapshot.xmin
  3. Новая строка от другой транзакции имеет xmin > snapshot.xmin → НЕВИДНА

Это означает: PostgreSQL на RR фактически защищает от фантомов, хотя стандарт ANSI этого не требует. Это побочный эффект дизайна MVCC.

Predicate Locks (Serializable в PostgreSQL)

На уровне Serializable PostgreSQL использует Serializable Snapshot Isolation (SSI):

При выполнении: SELECT * FROM orders WHERE status = 'pending'

1. Регистрируется predicate lock на диапазон индекса (или full table scan)
2. Если другая транзакция INSERT INTO orders (status='pending')
   - Обнаруживается RW-dependency: T1 прочитала предикат, T2 записала в него
3. При commit проверяется dependency graph
4. Если обнаружен опасный цикл → одна транзакция получает:
   ERROR: could not serialize access due to read/write dependencies
   SQLSTATE: 40001

Next-Key Locking (MySQL/InnoDB)

MySQL на Repeatable Read использует другой подход:

-- Запрос: SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;

InnoDB блокирует:
1. Record locks: сами строки, удовлетворяющие условию
2. Gap locks: промежутки МЕЖДУ найденными строками
3. Next-key locks: комбинация 1 + 2

Gap Lock на диапазон (10, 20) не даст INSERT INTO orders (id=15, ...)

Проблема: Gap Locks могут вызывать phantom-like deadlocks:

T1: SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;  -- lock gap (10,20)
T2: SELECT * FROM t WHERE id BETWEEN 15 AND 25 FOR UPDATE;  -- lock gap (15,25) — конфликт!
-- T1 и T2 ждут друг друга → DEADLOCK

Архитектурные Trade-offs

Подход A: Accept phantom reads (Read Committed)

  • ✅ Плюсы: Максимальная пропускная способность, нет блокировок диапазонов
  • ❌ Минусы: Приложение должно быть устойчиво к несогласованным повторным чтениям
  • Подходит для: высоконагруженных CRUD-систем, где точность повторных чтений не критична

Подход B: Prevent phantom reads (Repeatable Read / Serializable)

  • ✅ Плюсы: Строгая консистентность, детерминированное поведение
  • ❌ Минусы: Снижение throughput на 2-10x, риск serialization failures (5-30% abort rate)
  • Подходит для: финансовых систем, бронирования, инвентаризации

Подход C: Application-level prevention (Optimistic Locking)

  • ✅ Плюсы: Нет блокировок БД, масштабируется горизонтально
  • ❌ Минусы: Сложность реализации, нужны retries, eventual consistency
  • Подходит для: микросервисов, CQRS, event sourcing

Edge Cases и Corner Cases

1. Фантомы при UPDATE:

-- Транзакция 1:
BEGIN;
UPDATE orders SET status = 'processed' WHERE status = 'pending';  
-- Затронуто 5 строк

-- Транзакция 2 (между двумя SELECT в Т1):
INSERT INTO orders (status) VALUES ('pending');
COMMIT;

-- Транзакция 1:
SELECT COUNT(*) FROM orders WHERE status = 'pending';  
-- Вернёт 1 (только что вставленная строка, если уровень = Read Committed)

2. Фантомы с临时表 (Temporary Tables): Временные таблицы в PostgreSQL НЕ отслеживаются SSI. Фантомное чтение возможно даже на Serializable, если данные идут через temp tables.

3. Фантомы с sequence: nextval('seq') не откатывается при abort транзакции. Это создаёт “дырки” в нумерации, которые могут быть ошибочно приняты за фантомы при аудите.

4. Phantom DELETE:

// Транзакция 1 прочитала 100 записей
List<Order> orders = orderRepo.findAllPending();

// Транзакция 2 удалила 50 из них и закоммитила

// Транзакция 1 пытается обновить все 100
// 50 записей уже нет — UPDATE затронет 0 строк
// Молчаливая потеря данных

5. Index-only scan phantom: Если запрос использует covering index, он может увидеть другое количество строк, чем query с full table scan, из-за особенностей snapshot visibility в MVCC.

Performance Implications

Метод защиты Latency impact Throughput impact Memory
Serializable (SSI) +50-200ms 2-10x ниже Predicate locks: ~5-20MB shared memory
SELECT … FOR UPDATE +5-50ms 1.5-3x ниже Row locks: ~200 bytes/lock
Gap Locks (MySQL) +10-100ms 2-5x ниже Gap locks: ~100 bytes/lock
Optimistic Locking +0ms (happy path), +50ms (retry) Зависит от contention @Version column: 4 bytes/row
Application locking (Redis) +5-15ms 1.2-2x ниже Redis key: ~100 bytes

Цифры зависят от версии PG, железа и паттерна нагрузки (PostgreSQL 15, 8 cores, NVMe):

  • Read Committed: ~50,000 TPS. Используйте как порядок величины, а не абсолютные значения.
  • Repeatable Read: ~48,000 TPS (почти нет оверхеда при read-only)
  • Serializable: ~8,000 TPS при moderate write contention (84% снижение из-за aborts)

Memory Implications

  • Predicate locks (SSI): Хранятся в shared memory. При высокой нагрузке происходит coarsening (объединение) до уровня relation lock. Размер: ~128 bytes per predicate lock.
  • Gap locks (MySQL): Хранятся в lock table InnoDB. При большом количестве gap locks может произойти innodb_table_locks exhaustion.
  • MVCC snapshots: Каждая активная транзакция держит snapshot. При long-running транзакциях старые версии строк (dead tuples) не могут быть удалены → table bloat.

Concurrency Aspects

Serialization failure pattern:

T1: reads range [A, B]
T2: reads range [B, C]
T3: inserts into [A, B] and commits
T4: inserts into [B, C] and commits

T1 commit → проверяет: T3 записала в диапазон T1 → RW-dependency
T2 commit → проверяет: T4 записала в диапазон T2 → RW-dependency

Если образуется цикл → одна транзакция abort

Real Production Scenario

Ситуация: Система обработки платежей в финтех-компании (2024).

Проблема: На уровне Read Committed при генерации ежедневного отчёта возникали фантомы. Отчёт показывал 10,000 транзакций на сумму $5M, но при детальном разборе оказалось, что 50 транзакций ($250K) были вставлены ПОСЛЕ начального COUNT, но попали в финальную сумму.

Что произошло:

// BatchJob, запускается ежедневно
@Transactional
public DailyReport generateReport() {
    int count = txRepo.countByDate(today);         // snapshot 1
    BigDecimal sum = txRepo.sumByDate(today);       // snapshot 2
    
    // В этот момент платежный шлюз подтвердил 50 транзакций
    // и они были вставлены в БД (Read Committed)
    
    List<Transaction> txs = txRepo.findAllByDate(today); // snapshot 3 — новые строки видны!
    // count = 10,000, но txs.size() = 10,050
    // sum посчитан по 10,050 транзакциям, а count = 10,000
}

Ошибка: три разных snapshot в одной транзакции. Snapshot 1 (count=10000), Snapshot 3 (txs.size=10050). Sum посчитан по 10050 записям, а count=10000. На Repeatable Read все три запроса использовали бы один snapshot.

Решение: Переключение на Repeatable Read (PostgreSQL) решило проблему без изменения кода, так как MVCC snapshot гарантирует консистентность всех SELECT в рамках одной транзакции.

Impact:

  • До: 0.5% отчётов имели расхождения
  • После: 0% расхождений
  • Производительность: без изменений (read-only workload на RR бесплатный в PG)

Monitoring и Диагностика

PostgreSQL — отслеживание serialization failures:

-- Проверка конфликтов
SELECT datname, conflicts, deadlocks 
FROM pg_stat_database 
WHERE datname = 'your_db';

-- Активные predicate locks
SELECT * FROM pg_locks WHERE mode LIKE '%Serializable%';

MySQL — отслеживание gap lock конфликтов:

-- Deadlock лог
SHOW ENGINE INNODB STATUS\G

-- Текущие блокировки
SELECT * FROM information_schema.innodb_locks;

Application-level (Micrometer):

// Track phantom-related retries
Counter.builder("transaction.phantom.retries")
    .tag("operation", "report_generation")
    .register(meterRegistry);

Best Practices для Highload

  1. Избегайте Serializable для read-heavy workloads — используйте Repeatable Read (PostgreSQL) или Read Committed с application-level consistency checks.
  2. Укорачивайте транзакции — чем короче транзакция, тем меньше window для фантомов.
  3. Используйте FOR UPDATE точечно — только для критических запросов, не для всего отчёта.
  4. Материализуйте snapshot — для больших отчётов создавайте temporary table с данными snapshot и работайте с ней.
  5. Event Sourcing для audit — вместо повторных SELECT используйте event stream, где порядок гарантирован.
  6. Версионирование данных — добавьте version колонку для detection phantom-related changes.

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

Обязательно знать:

  • Phantom Read — повторный SELECT возвращает другой набор строк (INSERT/DELETE другой транзакции)
  • Отличие от Non-Repeatable Read: Phantom = новые строки, NRR = изменённые существующие
  • На Repeatable Read: PostgreSQL предотвращает фантомы через MVCC, MySQL — через Gap Locks
  • На Serializable: полностью предотвращается через SSI (PostgreSQL) или 2PL
  • Predicate locks в SSI блокируют не строки, а УСЛОВИЕ запроса (WHERE status=’pending’)
  • Реальный кейс: отчёт показывал 10,000 транзакций, но детальный разбор нашёл 10,050 — фантомы

Частые уточняющие вопросы:

  • Почему PG предотвращает фантомы на RR, хотя стандарт ANSI не требует? — Побочный эффект MVCC: snapshot не видит новые строки
  • Что такое Next-Key Locking в MySQL? — Record Lock + Gap Lock, блокирует и строки, и промежутки
  • Когда фантомы не опасны? — Read-only отчёты с допустимой погрешностью, кэширование, статистика
  • Как мониторить фантомы в production? — Track serialization failures, monitor long-running transactions

Красные флаги (НЕ говорить):

  • “Repeatable Read во всех СУБД предотвращает фантомы” — SQL Server на RR допускает фантомы
  • “Phantom Read = баг СУБД” — это ожидаемое поведение на уровнях ниже Serializable
  • “FOR UPDATE решает все проблемы с фантомами” — FOR UPDATE блокирует конкретные строки, не диапазоны

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

  • [[5. Что такое Repeatable Read]]
  • [[6. Что такое Serializable]]
  • [[8. Что такое неповторяющееся чтение (Non-Repeatable Read)]]
  • [[10. Что такое потерянное обновление (Lost Update)]]