Що таке неповторюване читання (Non-Repeatable Read)
На відміну від «брудного читання», тут прочитані дані є абсолютно реальними та підтвердженими. Проте вони порушують логічну узгодженість поточної бізнес-операції.
🟢 Junior Level
Неповторюване читання (Non-Repeatable Read, або Fuzzy Read) — це аномалія паралельного доступу до даних, за якої транзакція зчитує один і той самий рядок двічі в межах своєї роботи, але отримує різні значення, оскільки між цими читаннями інша транзакція встигла змінити (UPDATE) або видалити (DELETE) цей рядок і зафіксувати комміт (COMMIT).
На відміну від «брудного читання», тут прочитані дані є абсолютно реальними та підтвердженими. Проте вони порушують логічну узгодженість поточної бізнес-операції.
Приклад сценарію
Час Транзакція А (Розрахунок знижки) Транзакція Б (Зміна ціни менеджером)
| -------------------------------- ------------------------------------
| T1: BEGIN;
| T2: SELECT price FROM products WHERE id = 1; -- Ціна: 1 000 грн.
| T3: BEGIN;
| T4: UPDATE products SET price = 1 500 WHERE id = 1;
| COMMIT; -- Зафіксовано!
| T5: SELECT price FROM products WHERE id = 1; -- Ціна: 1 500 грн.! (ЗМІНИЛАСЯ)
v T6: -- Чек пробито за однією ціною, а списано за іншою!
На яких рівнях ізоляції проявляється
- Read Uncommitted, Read Committed: ⚠️ Аномалія допускається.
- Repeatable Read, Serializable: ❌ Гарантовано запобігається (транзакція завжди бачить знімок на момент старту).
Аналогія: Ви зайшли в інтернет-магазин, поклали в кошик товар за 1 000 гривень і перейшли до оплати. Поки ви вводили номер картки, продавець підняв ціну до 1 500 гривень. У момент натискання кнопки «Оплатити» банк списав з вас 1 500 гривень замість очікуваних 1 000.
🟡 Middle Level
Формалізація аномалії ($A_2$ за ANSI SQL-92)
У теорії транзакційних систем феномен неповторюваного читання $A_2$ формалізується послідовністю операцій:
\[r_1[x] \dots w_2[x] \dots c_2 \dots r_1[x] \dots c_1\]- Транзакція $T_1$ зчитує рядок $x$ ($r_1[x]$).
- Транзакція $T_2$ оновлює рядок $x$ ($w_2[x]$) і успішно фіксує зміни ($c_2$).
- Транзакція $T_1$ повторно зчитує рядок $x$ ($r_1[x]$) і бачить значення, записане $T_2$.
Різниця між Non-Repeatable Read та Phantom Read
Це одне з найчастіших запитань на технічних інтерв’ю:
| Критерій | Неповторюване читання (Non-Repeatable Read) | Фантомне читання (Phantom Read) |
|---|---|---|
| Характер змін | Зміна даних у вже наявних рядках | Поява нових рядків або зникнення діапазонів |
| SQL-операція конкурента | UPDATE або DELETE за конкретним ідентифікатором |
INSERT або DELETE у предикатному діапазоні |
| Тип запиту | Пошук конкретного запису: WHERE id = 10 |
Вибірка діапазону: WHERE age > 18 |
| Порушення логіки | Ламає патерн Read-Modify-Write | Ламає агрегатні обчислення (COUNT, SUM) |
Способи запобігання
Спосіб 1: Підвищення рівня ізоляції до Repeatable Read
На рівні Repeatable Read СУБД фіксує єдиний знімок даних (Snapshot) на всю транзакцію. Повторний SELECT прочитає стару версію рядка зі знімка:
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void generateInvoice(Long orderId) {
Order order1 = orderRepo.findById(orderId).orElseThrow();
// Будь-які повторні звернення до orderRepo побачать ті самі дані
}
Спосіб 2: Песимістичне блокування (SELECT FOR UPDATE)
Якщо підвищувати рівень усієї транзакції небажано (наприклад, через накладні витрати), можна заблокувати конкретний рядок на рівні Read Committed:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT o FROM Order o WHERE o.id = :id")
Optional<Order> findByIdForUpdate(@Param("id") Long id);
}
Рядок буде закрито монопольним блокуванням (X-lock), і паралельні транзакції не зможуть змінити ціну до завершення методу.
🔴 Senior Level
Причина на рівні рушія MVCC: Життєвий цикл знімків (Snapshot Scope)
У PostgreSQL на рівні Read Committed перед виконанням кожного оператора викликається внутрішня функція ядра GetTransactionSnapshot():
Транзакція T1 (Read Committed):
[Крок 1] SELECT price FROM products WHERE id = 1;
├── Створюється Snapshot A (бачить комміти до моменту T1)
└── Повертає price = 100
[Паралельно T2 робить UPDATE і COMMIT]
[Крок 2] SELECT price FROM products WHERE id = 1;
├── Створюється Snapshot B (бачить комміти до моменту T2, включно з T2!)
└── Повертає price = 150 (Non-Repeatable Read)
На рівні Repeatable Read функція GetTransactionSnapshot() викликається лише один раз під час першого звернення до даних. Посилання на знімок кешується в контексті транзакції (CurrentSnapshot), гарантуючи ідентичність видимості.
Взаємодія з First-Level Cache у Hibernate / JPA
Дуже небезпечний нюанс під час роботи зі Spring Data JPA:
@Transactional // За замовчуванням Read Committed
public void processOrder(Long id) {
Product p1 = productRepo.findById(id).get(); // Ціна 100 (SELECT в БД + об'єкт в L1 кеш)
// Паралельна транзакція змінює ціну в БД на 200 і робить COMMIT!
Product p2 = productRepo.findById(id).get(); // ЩО ВІДБУДЕТЬСЯ?
// Hibernate НЕ ЙДЕ в БД! Він повертає посилання на p1 з Persistence Context!
// p1 == p2 (true), p2.getPrice() == 100
// АЛЕ якщо викликати JPQL / Native Query:
BigDecimal currentPrice = productRepo.findPriceNative(id); // Запит летить у БД!
// currentPrice == 200!
}
Архітектурний ефект:
Кеш першого рівня Hibernate (Persistence Context) забезпечує повторюваність читання сутностей за їхнім ID навіть на рівні Read Committed бази даних. Але якщо в коді чергуються виклики findById() та кастомні репозиторні JPQL/Native запити, у пам’яті застосунку виникає стан розсинхронізації даних.
4 Tricky Questions
1. Якщо транзакція зчитує рядок, а паралельна транзакція повністю видаляє цей рядок (DELETE) і робить COMMIT, це Non-Repeatable Read чи Phantom Read?
Відповідь:
Це класичний Non-Repeatable Read (неповторюване читання).
Хоча рядок фізично зник із бази, операція зачепила конкретний наявний рядок, ідентифікований за первинним ключем. Повторний SELECT WHERE id = 1 поверне порожнечу (null), тобто стан уже відомого одиничного кортежу змінився.
Фантомним читанням вважається поява або зникнення записів під час пошуку за предикатним діапазоном (наприклад, WHERE category = 'Books'), коли змінюється загальна кількість рядків у результуючому наборі.
2. Чи захищає анотація @Transactional(readOnly = true) від неповторюваного читання в PostgreSQL за замовчуванням?
Відповідь:
Ні, не захищає.
За замовчуванням у Spring анотація @Transactional(readOnly = true) транслюється в базу даних як SET TRANSACTION READ ONLY, але зберігає дефолтний рівень ізоляції бази (Read Committed).
У режимі Read Committed статус READ ONLY лише забороняє поточній транзакції виконувати операції запису (INSERT, UPDATE, DELETE), але не змінює механізм знімків даних. Кожен SELECT усередині read-only транзакції продовжує отримувати свіжий знімок бази даних, тому два послідовні читання одного й того самого рядка можуть повернути різні значення. Щоб зафіксувати дані, необхідно явно вказати isolation = Isolation.REPEATABLE_READ.
3. Чому в банківських системах для запобігання Non-Repeatable Read частіше використовують SELECT FOR UPDATE, а не підвищення рівня всієї транзакції до Repeatable Read?
Відповідь: З двох ключових причин:
- Запобігання відкатам (Serialization Failures): У PostgreSQL на рівні
Repeatable Readпаралельний апдейт зачепленого рядка призведе до краху транзакції з помилкоюcould not serialize access (40001). Застосунку доведеться заново перезапускати весь бізнес-ланцюжок (@Retryable). ЗапитSELECT FOR UPDATEна рівніRead Committedзмушує другий потік просто короткочасно почекати на блокуванні рядка без падіння транзакції. - Локальність накладних витрат: Блокуються лише поодинокі критичні рахунки клієнтів, а не вся таблиця, що зберігає високу загальну пропускну здатність системи.
4. Що відбувається з версіями рядків у PostgreSQL при виникненні Non-Repeatable Read на рівні ядра?
Відповідь:
У PostgreSQL UPDATE рядка фактично є комбінацією DELETE старої версії та INSERT нової версії кортежу.
- Під час початкового читання $T_1$ знаходить кортеж $v_1$ (у якого
xminзакомічений, аxmaxне закомічений). - Транзакція $T_2$ оновлює рядок: у заголовок $v_1$ проставляється
xmax = T_2, і в сторінку дописується новий кортеж $v_2$ зxmin = T_2. Потім $T_2$ комітиться. - Під час повторного читання $T_1$ отримує новий знімок, для якого транзакція $T_2$ вже зафіксована.
- Кортеж $v_1$ визнається мертвим (оскільки його
xmaxзакомічений). Кортеж $v_2$ визнається живим. $T_1$ читає дані з $v_2$, спостерігаючи нові значення полів.
🎯 Шпаргалка для інтерв’ю
- Сутність Non-Repeatable Read: Повторний
SELECTконкретного рядка повертає змінені дані через паралельнийUPDATE/DELETEтаCOMMIT. - Рівні ізоляції: Допускається на
Read UncommittedтаRead Committed; запобігається наRepeatable ReadтаSerializable. - Причина в MVCC: На
Read Committedновий знімок (Snapshot) генерується перед кожним запитом. - Різниця з Phantom Read:
- Non-Repeatable Read — змінюються значення наявного рядка за ID.
- Phantom Read — з’являються/зникають нові рядки в діапазоні вибірки.
- Hibernate L1 Cache:
findById()кешує сутність у пам’ятіEntityManager, маскуючи Non-Repeatable Read для одиничних викликів, але нативні запити звертаються до БД напряму. - Як запобігти: Підвищити рівень до
Repeatable Readабо використовуватиSELECT ... FOR UPDATE.
Червоні прапорці (чого уникати)
- Плутання Non-Repeatable Read (зміна існуючих рядків) із Phantom Read (додавання нових рядків у предикат).
- Твердження, що
@Transactional(readOnly = true)гарантує Repeatable Read (за замовчуванням воно працює на Read Committed). - Незнання того, що видалення конкретного рядка за первинним ключем іншою транзакцією — це Non-Repeatable Read, а не фантом.
- Твердження, що кеш Hibernate рятує від Non-Repeatable Read у нативних запитах (він діє лише для викликів за ID через
findById/get).