1. Конфликт и setter: dirty checking и отложенный SQL
Когда начинающий разработчик впервые сталкивается с optimistic locking, он часто ожидает примерно такого: «я вызвал item.setAvailableQuantity(...) — и сразу же в этой строке кода должен случиться конфликт». Но Hibernate устроен не как “SQL на каждый чих”. Он работает в модели unit of work: вы меняете объект в памяти, а SQL улетает в базу позже — обычно ближе к завершению транзакции. И это, внезапно, не баг, а базовый принцип ORM.
Вспомним, что такое managed-сущность. Когда вы делаете repository.findById(...) внутри @Transactional, вы получаете объект, который «прикреплён» к persistence context. Вы меняете его поля обычными сеттерами, а Hibernate позже решает: «ага, поле изменилось — значит надо сделать UPDATE». Этот механизм называется dirty checking. Он не обязан отправлять SQL прямо в момент сеттера. Более того, если бы он отправлял SQL каждый раз, любая бизнес-операция превратилась бы в пулемёт по базе данных.
Посмотрим на самый обычный код из нашего проекта shop-data-jpa. В нём нет ни save(), ни явного SQL — и это нормально:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryService {
@Transactional // Граница транзакции: commit/rollback произойдут после выхода из метода
public void reserve(Long stockItemId, int qty) {
// Здесь мы получаем managed-сущность (прикреплённую к persistence context)
StockItem item = repository.findById(stockItemId).orElseThrow();
// Меняем состояние Java-объекта в памяти (dirty checking запомнит изменения)
item.setAvailableQuantity(item.getAvailableQuantity() - qty);
// ВАЖНО: SQL ещё может не улететь в БД.
// Конфликт optimistic locking может проявиться позже — на flush/commit.
}
}
Ключевая мысль: setter меняет только Java-объект. А конфликт optimistic locking может быть обнаружен только тогда, когда Hibernate реально попробует сделать UPDATE в БД и увидит, что обновить «старую версию» уже нельзя.
Для механики flush здесь достаточно увидеть изменение одного поля availableQuantity. В полном StockItem.reserve(qty) из mini-shop меняются и availableQuantity, и reservedQuantity, но version check на flush работает по тем же правилам: Hibernate всё равно проверяет, что обновляет ту же версию строки.
2. @Version в persistence context: снимок
Важно почувствовать, что optimistic locking — это не «проверка в Java-коде» и не какая-то магическая синхронизация потоков. Основа механики лежит в очень скучной вещи: persistence context хранит снимок состояния сущности, который был прочитан из базы, и среди полей этого снимка есть version. Дальше всё происходит почти как в бухгалтерии: «мы обновляем ровно то, что у нас было записано в момент чтения».
Когда StockItem загружается, Hibernate читает колонку version (и вообще все нужные колонки) и запоминает: «у сущности с id = 10 версия = 5». Если внутри транзакции вы поменяли availableQuantity, Hibernate всё равно помнит, с какой версией вы стартовали. И именно эта старая версия станет частью WHERE в UPDATE.
Можно сделать маленький «психологический тест» для мозга: вывести версию до изменения и после. В нормальном случае вы увидите, что Hibernate сам увеличивает версию (вы руками не трогаете).
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void changeAvailableQuantity(Long id, int newQty) {
// Загружаем managed-сущность вместе с текущей версией
StockItem item = repository.findById(id).orElseThrow();
// Версия до изменений (значение из БД на момент чтения)
System.out.println("version(before) = " + item.getVersion()); // например: 5
// Для механики version check здесь достаточно одного поля:
// в полном reserve-flow менялись бы и availableQuantity, и reservedQuantity
item.setAvailableQuantity(newQty);
// Принудительно отправляем SQL сейчас, чтобы увидеть момент version check
entityManager.flush();
// После успешного flush Hibernate уже увеличил версию в объекте
System.out.println("version(after) = " + item.getVersion()); // например: 6
}
Да, flush тут специально — чтобы увидеть момент синхронизации (о нём подробно поговорим в следующем разделе). Но уже сейчас полезно зафиксировать: версия — часть persistence-механики, которую ORM держит в голове так же, как id.
3. SQL для version check: WHERE version = ...
Слова «проверка версии» звучат так, будто Hibernate делает какой-то if в Java и сравнивает версии сам. На самом деле самая важная часть проверки происходит в SQL. Hibernate формирует UPDATE таким образом, чтобы база обновила строку только если версия совпала. И именно база возвращает информацию о том, сколько строк было обновлено.
Упрощённо (очень близко к реальности) это выглядит так:
-- UPDATE пройдёт только если в БД всё ещё лежит та версия, которую мы читали ранее
update stock_item
set available_quantity = ?,
reserved_quantity = ?,
version = ? -- новое значение версии (обычно old + 1)
where id = ?
and version = ?; -- старая версия (version check)
Обратите внимание на два места, где участвует версия. В SET version = ? Hibernate кладёт новое значение версии (обычно old + 1). А в WHERE ... and version = ? он кладёт старую версию, которую сущность имела в момент чтения. Это и есть version check.
Теперь самый важный момент дня: если кто-то другой успел обновить эту строку и увеличил version, то условие WHERE id = ? and version = oldVersion не сработает. База данных честно скажет: «я обновила 0 строк». Hibernate смотрит на это и делает вывод: «Значит, объект устарел. Это конфликт optimistic locking».
Чуть более «человеческая» мини-иллюстрация, чтобы мозг не уезжал в абстракции:
long oldVersion = 5;
long newVersion = oldVersion + 1;
// Просто иллюстрация: Hibernate делает примерно такую арифметику с версией
System.out.println("old = " + oldVersion); // old = 5
System.out.println("new = " + newVersion); // new = 6
Если транзакция A обновила строку с version = 5 и записала version = 6, то транзакция B, которая всё ещё пытается обновить «версию 5», внезапно обнаружит, что версии 5 больше нет. И это как раз то, что мы хотим: не дать ей тихо перезаписать чужие изменения.
Точно такая же проверка делается и для DELETE versioned entity: Hibernate добавит версию в WHERE, и удаление тоже будет «условным». Если строка уже изменилась, удаление не пройдет молча.
4. flush: отличие от commit и save
Слово flush звучит так, будто сейчас «всё смоет в унитаз» — и иногда это действительно похоже на правду, если вы включили SQL-логи и увидели пачку UPDATE. Но технически flush — это всего лишь принудительная синхронизация изменений из persistence context в базу. То есть Hibernate берёт накопленные изменения managed-сущностей и отправляет соответствующие SQL-команды.
Самое критичное: flush не завершает транзакцию. После flush() вы всё ещё можете сделать rollback, и база отменит изменения. Это очень важно для понимания: flush — не «сохранить навсегда», flush — «отправить SQL сейчас, а окончательное решение будет на commit/rollback».
Давайте зафиксируем в короткой таблице, потому что здесь новички путаются чаще всего:
| Событие в коде | Что реально происходит | Может ли появиться optimistic lock конфликт |
|---|---|---|
| item.setAvailableQuantity(...) | Меняем Java-объект, Hibernate просто отмечает изменение | Нет, потому что SQL ещё не отправлен |
| entityManager.flush() | Hibernate отправляет UPDATE ... WHERE version = ? | Да, конфликт становится видимым здесь |
| commit (в конце @Transactional) | Перед commit обычно будет flush + фиксация транзакции | Да, конфликт может проявиться здесь |
В Spring Boot вы чаще всего получаете EntityManager как зависимость. В нашем проекте это выглядит стандартно и не страшно:
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
@Service
public class InventoryService {
@PersistenceContext
private EntityManager entityManager; // Инжектим EntityManager, чтобы уметь делать flush вручную
// ...
}
А вызывается flush так:
// Принудительно синхронизируем persistence context с БД (но транзакцию не закрываем)
entityManager.flush();
И ещё один важный нюанс для жизни: flush может происходить и автоматически, не только когда вы вызвали его руками. Например, при завершении транзакции (commit) Spring/Hibernate в любом случае должны синхронизировать изменения. Поэтому если вы flush руками не делаете, это не значит, что его не будет — скорее всего, он просто случится «в конце», когда вы уже мысленно ушли пить чай.
5. Место конфликта: flush и commit в Spring
В обычном жизненном коде вы вызываете сервисный метод, и он возвращается — всё, операция закончилась. Но в Spring есть важная «закулисная сцена»: @Transactional работает через прокси, и commit транзакции происходит после выхода из метода. То есть технически в момент, когда вы пишете return; или метод просто заканчивается, Spring только начинает завершать транзакцию.
Отсюда эффект, который новички часто описывают так: «Я поставил try/catch вокруг кода, но исключение всё равно вылетает где-то потом и непонятно где». Это не заговор, а логика жизненного цикла транзакции: конфликт optimistic locking часто проявляется именно при flush, который случился на commit — а commit случился уже после выполнения тела метода.
Чтобы увидеть конфликт в предсказуемой точке (и вообще понимать, где он возникает), полезно иногда вызвать flush() вручную. Тогда момент отправки SQL становится видимым прямо внутри метода, в конкретной строке.
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void updateQuantity(Long id, int newQty) {
// 1) Читаем сущность (вместе с version) в persistence context
StockItem item = repository.findById(id).orElseThrow();
// 2) Для механики flush здесь достаточно одного поля:
// в полном reserve-flow менялись бы и availableQuantity, и reservedQuantity
item.setAvailableQuantity(newQty);
// 3) Делаем flush, чтобы конфликт проявился именно здесь (а не «после выхода из метода»)
entityManager.flush(); // если конфликт, он проявится прямо здесь
// 4) Эта строка не выполнится, если flush выбросил optimistic lock exception
System.out.println("Flush passed");
}
А теперь — схема, которая связывает всё в один таймлайн. Её удобно держать в голове, когда вы дебажите «почему setter прошёл, а потом всё упало».
flowchart LR
%% Таймлайн одной транзакции: где реально возникает SQL и где возможен конфликт
A["findById(): загрузили StockItem (version = 5)"] --> B["setAvailableQuantity(): поменяли объект в памяти"]
B --> C["flush(): отправили UPDATE ... WHERE version = 5"]
C --> D["commit(): фиксируем транзакцию"]
Если между A и C другая транзакция успела сделать version = 6, то на шаге C база обновит 0 строк, и Hibernate выбросит optimistic lock exception. Не на шаге B. Не на «где-то в setter». А там, где был SQL.
6. Мини-сценарий: SQL-логи и flush()
Сейчас мы соберём всё в один мини-сценарий именно в контексте нашего проекта. Мы не будем углубляться в многопоточность Java и не будем строить сложный стенд — это будет в практической лекции дня. Но мы сделаем самое главное: включим наблюдаемость и научимся понимать по логам, когда именно идёт version check.
Для начала важно, чтобы SQL был виден. В курсе мы уже включали SQL-логи в dev-профиле; напомню характерный минимальный кусок (у вас он может быть чуть другим, но смысл тот же):
logging:
level:
org.hibernate.SQL: DEBUG # Показывает сам SQL (SELECT/UPDATE/INSERT/DELETE)
org.hibernate.orm.jdbc.bind: TRACE # Показывает биндинг параметров (в т.ч. id и version)
Теперь сделаем в InventoryService метод, который явно «прибивает гвоздём» момент SQL, чтобы не искать его глазами по всему приложению. Он не идеален как production-код (flush руками нужен не всегда), но идеален как учебный микроскоп.
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void setAvailableQuantityWithFlush(Long id, int newQty) {
// Загружаем сущность: здесь будет SELECT (и чтение version)
StockItem item = repository.findById(id).orElseThrow();
// Меняем объект в памяти: пока что SQL нет
item.setAvailableQuantity(newQty);
// Принудительно отправляем UPDATE с version check именно здесь
entityManager.flush(); // UPDATE с version check уйдёт сюда
}
Что вы увидите в логах при успешном обновлении? Примерно такую картину: сначала select (когда загружали StockItem), потом update stock_item ... where id=? and version=?. Если конфликта нет, обновится 1 строка, версия увеличится, транзакция успешно закоммитится.
А что вы увидите при конфликте (когда параллельно другой запрос успел поменять тот же StockItem)? В логах вы всё равно увидите UPDATE ... WHERE version = old, но результат обновления будет 0 строк. И в этот момент Hibernate поймёт: «я пытался обновить “старую реальность”, но она уже не существует». После этого будет выброшено исключение optimistic locking.
Чтобы это стало совсем «осязаемым», полезно держать в голове такую последовательность из двух конкурентных транзакций:
sequenceDiagram
%% Две конкурентные транзакции читают одну и ту же версию, но обновить сможет только первая
participant TxA as "Tx A (операция A)"
participant TxB as "Tx B (операция B)"
participant DB as PostgreSQL
TxA->>DB: SELECT stock_item (version=5)
TxB->>DB: SELECT stock_item (version=5)
TxA->>DB: UPDATE ... WHERE id=10 AND version=5 (успех, version=6)
TxB->>DB: UPDATE ... WHERE id=10 AND version=5 (0 rows)
TxB-->>TxB: Optimistic lock exception
Обратите внимание, что никакой «магии синхронизации» тут нет. База данных не должна понимать, что вы используете Hibernate. Она просто выполняет честный SQL с честным WHERE. А Hibernate просто интерпретирует честный результат «0 строк обновлено» как конфликт.
И ещё одна практическая деталь, которая часто спасает часы жизни. Если вы пытаетесь «поймать» optimistic lock конфликт внутри метода и написать try/catch, то без flush вы можете вообще не поймать его там, где ожидаете, потому что исключение вылетит при commit после выхода из метода. Поэтому в учебных сценариях мы и вызываем flush явно: он делает момент проверки версии предсказуемым и видимым. Этого уже достаточно, чтобы идти к следующему инженерному вопросу: как превратить такой конфликт в понятный исход сервисной операции.
7. Типичные ошибки при работе с flush и optimistic locking
Тема flush и optimistic locking кажется сложной не потому, что там «много кода», а потому, что там много скрытых границ: граница транзакции, граница persistence context, граница «в памяти» vs «в базе». Поэтому ошибки здесь часто не компиляционные, а смысловые — приложение запускается, но ведёт себя «странно».
Ошибка №1: ждать исключение на строке setAvailableQuantity(...).
Это типичный эффект “мышления JDBC”: кажется, что изменение поля сразу равно SQL. В JPA это не так. Setter меняет только объект. Конфликт обнаруживается при отправке SQL, то есть обычно на flush() или на commit. Если помнить про dirty checking и отложенный SQL, ожидания становятся реалистичнее.
Ошибка №2: воспринимать flush() как «commit» или как «сохранить навсегда».
Flush действительно отправляет SQL в базу, поэтому выглядит страшно. Но транзакция всё ещё может откатиться. Если после flush случится ошибка и будет rollback, изменения исчезнут. Flush — это синхронизация, а не финальное решение судьбы данных.
Ошибка №3: пытаться «лечить» optimistic locking добавлением лишних save().
Когда вы меняете managed entity, save часто не нужен: dirty checking всё сделает сам. А конфликт optimistic locking — это не «забыли сохранить». Это обнаруженный факт, что данные уже изменились в другой транзакции. Дополнительный save не сделает конфликт менее конфликтным, он просто добавит вам путаницы.
Ошибка №4: оборачивать код в try/catch, но не понимать, что исключение может вылететь на commit после выхода из метода.
В Spring транзакция коммитится после завершения @Transactional метода. Поэтому optimistic lock exception может возникнуть «после последней строки метода». Если вы хотите в учебных целях увидеть конфликт внутри метода, используйте flush(). Если хотите обрабатывать конфликт архитектурно — это уже тема сервисной реакции (мы к ней идём в следующей лекции).
Ошибка №5: думать, что optimistic locking — это «про потоки Java», и пытаться лечить его synchronized.
Optimistic locking — это про конкуренцию транзакций в базе данных. Даже если у вас один поток в одном JVM-процессе, рядом может быть второй инстанс приложения, второй сервис, или просто второй запрос в тот же сервис. synchronized защищает только участок памяти внутри одного процесса и не решает проблему на уровне БД.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ