Питання 8 · Розділ 11

Що таке неповторюване читання (Non-Repeatable Read)?

Один і той самий запит, один і той самий момент всередині транзакції — але результат різний.

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Неповторюване читання (Non-Repeatable Read) — це аномалія, коли повторний запит тих самих даних всередині однієї транзакції повертає інший результат, тому що інша транзакція встигла змінити та закомітити дані. Це проблема, тому що ваша бізнес-логіка може прийняти рішення на основі першого значення, а потім виявити, що реальне значення змінилося — і звіт не зійдеться.

Простий приклад

Транзакція А: SELECT price FROM products WHERE id = 1;  -- 100
-- Інша транзакція: UPDATE products SET price = 150 WHERE id = 1; COMMIT;
Транзакція А: SELECT price FROM products WHERE id = 1;  -- 150!

Один і той самий запит, один і той самий момент всередині транзакції — але результат різний.

На якому рівні виникає

Виникає на рівні Read Committed. На рівнях Repeatable Read та Serializable ця проблема неможлива.

Чим відрізняється від фантомного читання

  • Non-Repeatable Read: змінилися дані в наявних рядках (ціна 100 → 150)
  • Phantom Read: з’явилися нові рядки (було 5 записів, стало 6)

🟡 Middle Level

Сценарій виникнення

Час Транзакція А (Read Committed) Транзакція Б (Update)
T1 BEGIN; BEGIN;
T2 SELECT price FROM products WHERE id = 1;100  
T3   UPDATE products SET price = 150 WHERE id = 1; COMMIT;
T4 SELECT price FROM products WHERE id = 1;150  
T5 Логіка порушена — ціна змінилася mid-transaction  

Корінь проблеми: на Read Committed PostgreSQL створює новий snapshot для КОЖНОГО оператора, а не для всієї транзакції. До моменту T4 транзакція Б вже закомітила, і новий snapshot бачить її зміни.

Чому це відбувається

На рівні Read Committed СУБД (PostgreSQL) робить знімок даних перед кожним оператором, а не на всю транзакцію цілком. До моменту другого запиту транзакція Б вже зробила COMMIT, тому новий знімок бачить оновлені дані.

Наслідки

  • Звітність: Баланс на початку звіту не сходиться з балансом при розрахунку податку
  • Інваріанти: Перевірка price < limit пройшла, а перед покупкою ціна вже вища
  • Багатокрокові розрахунки: Проміжні результати базуються на різних версіях даних

Як запобігти

  1. Підвищити рівень до Repeatable Read: Знімок робиться один раз на всю транзакцію
  2. SELECT … FOR UPDATE: Явно заблокувати рядок від змін до кінця транзакції

Відмінність від Phantom Read

Non-Repeatable Read Phantom Read
Зміна наявних рядків Поява/зникнення рядків
UPDATE іншої транзакції INSERT/DELETE іншої транзакції
Ті самі рядки, інші значення Інший набір рядків

На практиці різниця критична: Non-repeatable read ламає read-modify-write (порахували ціну, порахували податок, ціна змінилася). Phantom read ламає агрегати (порахували 100 замовлень, обробили 100, а їх стало 105).


🔴 Senior Level

MVCC Snapshot Timing — The Root Cause

Read Committed: Per-Statement Snapshots

In PostgreSQL under Read Committed:

Statement 1: BEGIN;
Statement 2: SELECT ...  → Snapshot A created (sees committed state at T2)
Statement 3:             → Other transaction commits
Statement 4: SELECT ...  → Snapshot B created (sees committed state at T4, including other tx changes)
Statement 5: COMMIT;

Each statement gets a fresh snapshot. This is by design — it’s the trade-off for maximum concurrency.

Repeatable Read: Transaction-Level Snapshot

Statement 1: BEGIN;
Statement 2: SELECT ...  → Snapshot A created and FROZEN for entire transaction
Statement 3:             → Other transaction commits (NOT visible)
Statement 4: SELECT ...  → Still uses Snapshot A (same result as Statement 2)
Statement 5: COMMIT;

Business Impact Analysis

Financial Systems Example

// Non-Repeatable Read can cause inconsistent calculations
@Transactional // Read Committed by default
public Statement generateStatement(Long accountId) {
    BigDecimal balance = repo.getBalance(accountId);       // Read 1: 1000
    // Concurrent deposit commits: balance is now 1500
    BigDecimal fee = calculateFee(balance);                 // Uses 1000
    BigDecimal interest = calculateInterest(balance);       // Uses 1000
    // But total_assets query might see 1500
    BigDecimal total = repo.getTotalAssets(accountId);      // Read 2: 1500

    // Statement shows: balance=1000, total=1500 — inconsistent!
}

Solution Approaches

Approach 1: Repeatable Read

@Transactional(isolation = Isolation.REPEATABLE_READ)
public Statement generateStatement(Long accountId) {
    // All reads see same snapshot
}

Approach 2: Pessimistic Locking

@Query("SELECT a FROM Account a WHERE a.id = :id FOR UPDATE")
Account findByIdLocked(Long id);

@Transactional
public Statement generateStatement(Long accountId) {
    Account account = repo.findByIdLocked(accountId);
    // Locked — no one can change until we commit
}

Approach 3: Application-Level Consistency

@Transactional
public Statement generateStatement(Long accountId) {
    // Read all needed data at once into local variables
    var snapshot = repo.getAccountSnapshot(accountId);
    // Use only local variables, no more DB reads
}

PostgreSQL-Specific Behavior

UPDATE Visibility Rules

Under Read Committed, PostgreSQL has special behavior for UPDATE/DELETE:

-- Transaction A (Read Committed)
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;

If another transaction modified and committed the same row between A’s SELECT and UPDATE:

  • PostgreSQL re-evaluates the WHERE clause against the latest committed version
  • If the row still matches, the UPDATE proceeds on the new version
  • This is called “EvalPlanQual” rechecking

This prevents lost updates under Read Committed, but only for direct UPDATEs, not read-modify-write patterns in application code.

Monitoring Non-Repeatable Read Issues

-- Detect long-running transactions on Read Committed
SELECT pid, now() - xact_start AS duration, state, query
FROM pg_stat_activity
WHERE state != 'idle'
  AND xact_start < now() - interval '30 seconds'
ORDER BY xact_start;

-- Track transaction duration distribution
SELECT
    percentile_cont(0.5) WITHIN GROUP (ORDER BY duration) as p50,
    percentile_cont(0.95) WITHIN GROUP (ORDER BY duration) as p95,
    percentile_cont(0.99) WITHIN GROUP (ORDER BY duration) as p99
FROM (
    SELECT extract(epoch FROM (now() - xact_start)) AS duration
    FROM pg_stat_activity
    WHERE state != 'idle'
) sub;

When Non-Repeatable Read Is Acceptable

  1. Eventual consistency systems: Where slight staleness within a transaction is acceptable
  2. Status checks: Reading a flag that might change (e.g., is_active)
  3. Caching scenarios: Where you explicitly want the latest data

When It’s NOT Acceptable

  1. Multi-step financial calculations
  2. Report generation requiring self-consistency
  3. Invariant checking before critical operations
  4. Read-modify-write without proper locking

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • Non-Repeatable Read — повторний SELECT тих самих рядків повертає інші значення (UPDATE іншої транзакції)
  • Виникає на Read Committed, тому що кожен SELECT отримує новий snapshot
  • Запобігається на Repeatable Read (один snapshot на транзакцію) та Serializable
  • Відмінність від Phantom Read: NRR = змінилися наявні рядки, Phantom = з’явилися нові рядки
  • У PostgreSQL: пер-операторний snapshot на RC vs пер-транзакційний на RR
  • Рішення: RR, SELECT FOR UPDATE, application-level consistency (читати все в локальні змінні)

Часті уточнюючі питання:

  • Як запобігти NRR без підвищення рівня ізоляції? — SELECT … FOR UPDATE для конкретних рядків
  • Чому PostgreSQL створює новий snapshot на RC? — Максимальна конкуренція, кожен оператор бачить останні дані
  • Що таке EvalPlanQual? — Механізм PG: при конкурентному UPDATE перечитує рядок і перевіряє WHERE

Червоні прапорці (НЕ говорити):

  • “NRR і Phantom Read — одне й те саме” — NRR = UPDATE наявних, Phantom = INSERT нових
  • “На RC два SELECT повернуть однаковий результат” — на RC кожен SELECT бачить свій snapshot
  • “FOR UPDATE тільки для запису” — FOR UPDATE також запобігає NRR

Пов’язані теми:

  • [[4. Що таке Read Committed]]
  • [[5. Що таке Repeatable Read]]
  • [[9. Що таке фантомне читання (Phantom Read)]]
  • [[10. Що таке втрачене оновлення (Lost Update)]]