Что такое неповторяющееся чтение (Non-Repeatable Read)?
Один и тот же запрос, один и тот же момент внутри транзакции — но результат разный.
🟢 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прошла, а перед покупкой цена уже выше - Многошаговые расчёты: Промежуточные результаты основаны на разных версиях данных
Как предотвратить
- Повысить уровень до Repeatable Read: Снимок делается один раз на всю транзакцию
- 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
- Eventual consistency systems: Where slight staleness within a transaction is acceptable
- Status checks: Reading a flag that might change (e.g.,
is_active) - Caching scenarios: Where you explicitly want the latest data
When It’s NOT Acceptable
- Multi-step financial calculations
- Report generation requiring self-consistency
- Invariant checking before critical operations
- 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)]]