1. Сервисный контракт и отказы
Когда мы впервые сталкиваемся с OptimisticLockException, есть соблазн подумать: «О, это какая-то внутренняя ошибка Hibernate, сейчас я ее поймаю, напишу в лог “не повезло” и продолжу». Но сервисный слой — это не место, где мы “боремся с фреймворком”. Сервис — это слой, который должен выражать смысл операции для проекта: «зарезервировать товар», «уменьшить остаток», «вернуть остаток».
Представьте, что вы пишете метод reserve(stockItemId, qty) и на него кто-то будет опираться сверху — другой сервис, тонкий контроллер, тест, консольный runner. Тот, кто вызывает метод, не обязан понимать, что такое “старая версия сущности” и почему в UPDATE внезапно появилась еще одна колонка. Вызывающей стороне важно другое: операция прошла или нет, и если нет — почему именно.
@Lock(PESSIMISTIC_WRITE) полезно держать в голове как точечный инструмент, но для StockItem в нашем mini-shop базовым остаётся versioned entity и optimistic conflict. Поэтому здесь нас интересует не новое сравнение стратегий, а вполне прикладной вопрос: как превратить version conflict в понятный результат метода reserve(...).
На практике хороший сервисный контракт для изменения остатков должен давать как минимум два понятных исхода: либо резерв прошел успешно, либо он не прошел. А если не прошел, причина должна быть различима: «нельзя, потому что товара не хватает» и «нельзя, потому что остаток уже успел измениться в другой операции». Эти причины звучат похоже, но для системы это два разных мира: первая — бизнес-логика, вторая — конфликт согласованности.
Если оставить наверх “сырую” OptimisticLockException, вы фактически говорите: «Понимание конкурентности — это проблема каждого, кто захочет вызвать мой метод». В небольшом проекте это быстро превращается в хаос, а в учебном — в ощущение, что ORM работает “на удачу”. Нам, наоборот, нужно сделать поведение предсказуемым и читаемым.
Два отказа: недостаток и конфликт версии
Очень важно психологически разделить две ситуации. Первая — честная бизнес-ошибка: вы хотите зарезервировать 5 единиц, а доступно 2. Это не проблема конкурентности, это просто невозможность выполнить правило домена. Вторая — технически-бизнесовая ошибка: вы начинали резервирование, считая, что доступно 10, но пока вы выполняли операцию, другая транзакция уже изменила эту же строку и “подняла” версию. Ваша операция уже не имеет права молча перезаписывать новые данные.
Чтобы не путаться, удобно держать в голове маленькую таблицу «что случилось и что делать дальше». Она не заменяет код, но помогает мозгу не смешивать причины.
| Ситуация | Что это по смыслу | Как обычно проявляется | Какой “правильный следующий шаг” |
|---|---|---|---|
| Остатка недостаточно | Бизнес-ограничение | Проверка в коде не проходит (available < qty) | Сообщить об отказе и, при необходимости, показать текущее доступное количество |
| Конфликт версии | Конкурентный конфликт (optimistic locking) | OptimisticLockException на flush/commit | Завершить операцию как неуспешную, попросить повторить на свежих данных (новое чтение) |
Почему нельзя сделать один общий IllegalStateException("что-то пошло не так")? Потому что вам придется объяснять пользователю и разработчикам, почему “не пошло не так”. В одном случае вы могли бы сразу предложить: “доступно 2, хотите зарезервировать 2?”, а в другом случае надо сказать: “ваши данные устарели, обновитесь и повторите”.
Есть еще одна тонкость: optimistic locking конфликт может возникнуть даже когда остатка “формально хватает”. Вы читали 10, вам нужно 3 — кажется, что все ок. Но между чтением и записью кто-то уже уменьшил остаток до 1 и успешно закоммитил. Ваш код на старой копии продолжает думать, что все хорошо, и именно для этого нужен конфликт версий: он не дает “дорисовать поверх реальности” свой старый мир.
2. Реализация: reserve() и flush()
Сейчас мы соберем в одну связную картинку то, что уже знаем про @Transactional, dirty checking, flush и @Version. Нам нужен короткий сервисный метод, который делает одну понятную вещь: пытается зарезервировать количество. Если он не может — он честно завершает операцию с понятной причиной. А чтобы конфликт версии был обнаружен в предсказуемом месте, мы добавим явный flush().
Начнем с напоминания: @Version живет на сущности. Это не “настройка сервиса”, это часть persistence-модели. Пример минимально важного фрагмента StockItem:
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
@Entity
public class StockItem {
@Id
private Long id;
// Текущее доступное количество, которое можно резервировать
private int availableQuantity;
// Уже зарезервированное количество (например, под заказы)
private int reservedQuantity;
@Version
// Версия строки для optimistic locking:
// при UPDATE Hibernate добавит условие WHERE ... AND version = ?
private long version;
}
Теперь нам нужно, чтобы изменение остатка было не “где-то в сервисе в виде формулы”, а в более читаемом виде. Даже в учебном проекте удобно держать простую доменную операцию на сущности: она не делает магии, она просто собирает инварианты в одном месте. Например, резервирование:
public void reserve(int qty) {
// Защищаемся от мусорного ввода: резервировать ноль или минус — бессмысленно
if (qty <= 0) {
throw new IllegalArgumentException("qty must be positive");
}
// Бизнес-проверка: физически не можем зарезервировать больше, чем доступно
if (availableQuantity < qty) {
throw new NotEnoughStockException(id, qty, availableQuantity);
}
// Переносим количество из "доступно" в "зарезервировано"
// Важно держать эти два поля согласованными
availableQuantity -= qty;
reservedQuantity += qty;
}
Здесь важно не то, что мы “делаем DDD”, а то, что логика становится читаемой: мы не забываем про reservedQuantity, не допускаем отрицательных значений и не размазываем проверку по разным методам.
Теперь переходим к сервису. Нам нужно три вещи: репозиторий, EntityManager (чтобы вызвать flush()) и транзакция. Сама транзакция по-прежнему начинается на уровне сервиса, как мы делали в модуле про @Transactional. Ключевой момент: flush() нужен не “чтобы точно сохранить”, а чтобы сделать конфликт видимым прямо внутри метода.
Вот компактная версия метода:
import jakarta.persistence.EntityManager;
import jakarta.persistence.OptimisticLockException;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void reserve(Long stockItemId, int qty) {
try {
// Загружаем сущность в persistence context: дальше она станет managed
StockItem item = repository.findById(stockItemId).orElseThrow();
// Меняем состояние объекта в памяти (dirty checking отметит изменения)
item.reserve(qty);
// Принудительно отправляем UPDATE в БД прямо здесь,
// чтобы конфликт версии проявился внутри метода, а не "где-то на commit"
entityManager.flush();
} catch (OptimisticLockException ex) {
// Превращаем низкоуровневую техническую причину в понятный сервисный сигнал
throw new StockItemVersionConflictException(stockItemId, ex);
}
}
Здесь в catch показан именно JPA OptimisticLockException, потому что конфликт поднимается на явном entityManager.flush(). Если тот же конфликт всплывает уже на границе Spring Data / transaction interceptor, вы чаще увидите ObjectOptimisticLockingFailureException — по смыслу это тот же version conflict, просто на другом слое.
Что здесь происходит по шагам, если говорить “на пальцах”, без мистики. Мы читаем StockItem (он становится managed внутри persistence context), меняем поля (Hibernate это отмечает через dirty checking), а затем мы принудительно просим ORM “отправь изменения в базу прямо сейчас”. В этот момент и происходит реальная проверка версии, потому что именно сейчас генерируется UPDATE ... WHERE id=? AND version=?.
Если кто-то уже успел изменить строку и версия стала другой, база обновит 0 rows, а Hibernate/JPA поднимет OptimisticLockException. И мы ловим ее там, где хотим: внутри метода, в понятной строчке, а не где-то “на выходе из транзакции”.
Чтобы визуально зафиксировать логику, удобно представить ее как маленькую блок-схему:
flowchart TD
A["Начало reserve()"] --> B[Загрузить StockItem]
B --> C{Хватает остатка?}
C -- нет --> D[NotEnoughStockException]
C -- да --> E["Изменить поля (dirty checking)"]
E --> F["flush()"]
F --> G{Версия совпала?}
G -- да --> H[Успех]
G -- нет --> I["OptimisticLockException -> service error"]
И обратите внимание на важное методическое “почему”: мы не добавляли никакой ручной проверки версий, не писали if(item.getVersion() != ...), не делали “хитрых” SQL. Мы просто корректно используем механизм, который уже есть в ORM, и аккуратно превращаем его в нормальное поведение сервиса.
3. Конфликт версии как сервисный сигнал
Поймать OptimisticLockException — это полдела. Вторая половина — не оставить ее наверху как “ошибку Hibernate”, а превратить в понятный сигнал для проекта. По смыслу это очень похоже на то, что мы делали с DataIntegrityViolationException в теме про constraints: база и ORM сообщили низкоуровневый факт, а сервис должен сказать человеческими словами, что именно означает этот факт для use case.
Самый простой вариант — бросить IllegalStateException с понятным текстом. В учебной демке это допустимо, но в проекте быстро становится неудобно: вы теряете тип ошибки. Гораздо приятнее завести маленькое исключение, которое прямо называется по смыслу.
Например, так:
public class StockItemVersionConflictException extends RuntimeException {
public StockItemVersionConflictException(Long stockItemId, Throwable cause) {
// Явно говорим: проблема не в "плохом Hibernate", а в параллельном изменении данных
super("StockItem id=" + stockItemId + " was changed concurrently", cause);
}
}
Теперь сервисный код “говорит” понятнее: у нас не “упал Hibernate”, у нас “вот этот остаток изменили параллельно”. А значит, вызывающая сторона (хоть другой сервис, хоть тест) может различать ситуации по типу исключения. Это особенно важно, если вы хотите различать конфликт версии и бизнес-ошибку недостатка товара.
Для недостатка остатка можно сделать отдельное исключение:
public class NotEnoughStockException extends RuntimeException {
public NotEnoughStockException(Long id, int requested, int available) {
// Это бизнес-отказ: даже на свежих данных операция невозможна с такими параметрами
super("StockItem id=" + id + ": requested=" + requested + ", available=" + available);
}
}
С точки зрения “чистоты архитектуры” кто-то скажет: “исключения — это плохо”. Я бы перефразировал: “плохие исключения — это плохо”. Исключение, которое отражает реальную причину отказа операции, как раз делает код проще: если операция не может быть выполнена, она не возвращает “полууспех” и не заставляет клиента проверять странные флаги.
Если вы захотите, вы можете сделать и result-based стиль (например, ReservationResult), но в рамках сегодняшней темы важно другое: конфликт версии — это отдельная причина отказа, и она должна быть различима.
Ещё один практический момент: в реальном проекте этот сигнал может прийти как JPA OptimisticLockException или как Spring-обёртка ObjectOptimisticLockingFailureException. Для сервисного контракта это одна и та же ситуация: мы попытались изменить устаревшее состояние и должны превратить это в понятный отказ операции.
4. После конфликта: перечитать и завершить
Когда конфликт версии произошел, хочется сделать “умно”: поймать исключение, тут же внутри catch еще раз загрузить сущность, пересчитать, попробовать снова — и обязательно победить. Но тут есть неприятная правда: после optimistic locking failure ваша текущая транзакция уже по сути “сломана” как unit of work. Даже если вы где-то перехватили исключение, транзакция может быть помечена как rollback-only, а persistence context — находиться в состоянии, где продолжать работу рискованно.
Поэтому самый здравый и предсказуемый подход для нашего уровня курса звучит скучно, но надежно: если конфликт версии произошел, текущая операция считается неуспешной. Сервис бросает понятную ошибку наверх. Дальше вызывающая сторона решает, что делать: показать сообщение “данные изменились, повторите”, или повторить операцию заново (уже в новой транзакции), или сначала перечитать текущий остаток и спросить пользователя.
И вот здесь нам очень пригодится маленький read-метод, который дает актуальное количество. Он не должен менять данные, поэтому readOnly = true подходит идеально:
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
public int currentAvailableQuantity(Long stockItemId) {
// read-only транзакция: мы только читаем актуальное состояние, ничего не меняем
return repository.findById(stockItemId)
.orElseThrow()
.getAvailableQuantity();
}
Обратите внимание, что этот метод должен вызываться как отдельная сервисная операция. Если пытаться дергать его “изнутри” того же класса в catch, вы можете упереться в старую добрую ловушку self-invocation (мы ее уже обсуждали в модуле про @Transactional): аннотация на методе не сработает, если вы вызвали метод напрямую this.currentAvailableQuantity(...). В учебном проекте проще всего держать идею: “конфликт = выходим, читаем заново отдельным вызовом”.
Чтобы еще раз связать это с “человеческой” логикой, представьте двух сотрудников склада, которые одновременно правят одну цифру в учетной системе. Optimistic locking в этом смысле работает как “защита от редактирования устаревшей версии документа”: если вы открыли документ версии 10, а пока вы его правили, кто-то уже сохранил версию 11, то система не должна молча перезаписать 11-й вариант вашим 10-м. Она должна сказать: “извините, документ обновился, перечитайте и повторите”.
И да, это иногда неудобно. Но это честное неудобство, которое спасает данные. В мире остатков неудобство вида “повторите операцию” обычно дешевле, чем неудобство вида “почему у нас минус 20 айфонов на складе, если мы их не продавали?”. У оптимистического подхода философия простая: конфликты редки — поэтому мы не блокируем заранее, но когда конфликт случился, мы его не игнорируем и не маскируем.
5. Типичные ошибки при конфликте версии
Ошибка №1: проглотить OptimisticLockException и вернуть “успех”.
Иногда разработчик ловит исключение и делает вид, что все хорошо, потому что “ну это же всего лишь конфликт”. На практике это почти всегда приводит к тому, что вызывающая сторона думает, что резерв сделан, строит дальнейшую логику (например, оформляет заказ), а в базе изменения не зафиксированы. В итоге система начинает вести себя так, будто она “случайно забывает” изменения — очень неприятный класс багов.
Ошибка №2: пытаться “доделать операцию” в том же методе после конфликта.
После optimistic locking failure хочется в catch заново прочитать сущность и повторить изменения. Но transaction boundary и persistence context — это unit of work. Если unit of work уже получил конфликт на записи, правильнее завершить его как неуспешный и начать новый. Иначе вы рискуете работать в транзакции, которая уже помечена на rollback, и получить еще более странные эффекты: от повторных исключений до неожиданного поведения при commit.
Ошибка №3: ожидать исключение в момент setter-а, а не на flush/commit.
Новичок пишет item.setAvailableQuantity(...) и удивляется, что “ничего не происходит”. Потом метод возвращается, и ошибка появляется где-то выше, будто она “не отсюда”. Причина в том, что setter меняет объект, а SQL уходит при синхронизации с базой. Если вам нужна предсказуемая точка, добавляйте явный flush() именно там, где вы хотите обнаружить конфликт.
Ошибка №4: смешать конфликт версии с “недостаточно товара”.
Обе ситуации приводят к отказу операции, поэтому их легко склеить в одну кучу. Но бизнес-ошибка “не хватает товара” означает, что даже при свежих данных операция невозможна без изменения входных параметров. А конфликт версии означает, что входные параметры могут быть нормальными, просто ваша копия данных устарела. Если вы возвращаете одну и ту же ошибку, вы лишаете системы возможности вести себя по-разному в этих сценариях.
Ошибка №5: лечить optimistic locking пессимистическими блокировками “на всякий случай”.
После первого конфликта версий появляется желание: “давайте поставим @Lock(PESSIMISTIC_WRITE) везде, и пусть мир подождет”. Это превращает вашу базу в очередь: чем больше операций, тем больше ожиданий, тем выше шанс блокировок и деградации производительности. Пессимистический подход — инструмент, но дорогой. Сегодняшний вывод практичный: сначала научитесь корректно обрабатывать optimistic conflict и переводить его в понятный сервисный сигнал, а уже потом думайте, где действительно нужен @Lock.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ