💳 Раздел 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.

Связанные темы