Вопрос 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)]]