Что такое неповторяющееся чтение (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.