Питання 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, він може побачити іншу кількість рядків, ніж запит з full table scan, через особливості snapshot visibility в MVCC.

Performance Implications

Метод захисту Вплив на latency Вплив на throughput Пам’ять
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 (майже немає overhead при 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)]]