Що таке неповторюване читання (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)]]