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

Що таке неповторюване читання (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\]
  1. Транзакція $T_1$ зчитує рядок $x$ ($r_1[x]$).
  2. Транзакція $T_2$ оновлює рядок $x$ ($w_2[x]$) і успішно фіксує зміни ($c_2$).
  3. Транзакція $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?

Відповідь: З двох ключових причин:

  1. Запобігання відкатам (Serialization Failures): У PostgreSQL на рівні Repeatable Read паралельний апдейт зачепленого рядка призведе до краху транзакції з помилкою could not serialize access (40001). Застосунку доведеться заново перезапускати весь бізнес-ланцюжок (@Retryable). Запит SELECT FOR UPDATE на рівні Read Committed змушує другий потік просто короткочасно почекати на блокуванні рядка без падіння транзакції.
  2. Локальність накладних витрат: Блокуються лише поодинокі критичні рахунки клієнтів, а не вся таблиця, що зберігає високу загальну пропускну здатність системи.

4. Що відбувається з версіями рядків у PostgreSQL при виникненні Non-Repeatable Read на рівні ядра?

Відповідь: У PostgreSQL UPDATE рядка фактично є комбінацією DELETE старої версії та INSERT нової версії кортежу.

  1. Під час початкового читання $T_1$ знаходить кортеж $v_1$ (у якого xmin закомічений, а xmax не закомічений).
  2. Транзакція $T_2$ оновлює рядок: у заголовок $v_1$ проставляється xmax = T_2, і в сторінку дописується новий кортеж $v_2$ з xmin = T_2. Потім $T_2$ комітиться.
  3. Під час повторного читання $T_1$ отримує новий знімок, для якого транзакція $T_2$ вже зафіксована.
  4. Кортеж $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).

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