Что такое фантомное чтение (Phantom Read)
Фантомное чтение возникает из-за того, что большинство СУБД на уровнях ниже Serializable читают снимок данных (snapshot), но не блокируют диапазоны ключей (key ranges).
🟢 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_locksexhaustion. - 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
- Избегайте Serializable для read-heavy workloads — используйте Repeatable Read (PostgreSQL) или Read Committed с application-level consistency checks.
- Укорачивайте транзакции — чем короче транзакция, тем меньше window для фантомов.
- Используйте
FOR UPDATEточечно — только для критических запросов, не для всего отчёта. - Материализуйте snapshot — для больших отчётов создавайте temporary table с данными snapshot и работайте с ней.
- Event Sourcing для audit — вместо повторных SELECT используйте event stream, где порядок гарантирован.
- Версионирование данных — добавьте
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)]]