@Version и optimistic locking

Spring Data JPA
25 уровень , 1 лекция
Открыта

1. От lost update к optimistic locking

Когда впервые слышишь про optimistic locking, хочется представить себе что-то вроде “замка на двери” или “таблички: не трогать, я тут работаю”. Но наша проблема тоньше: обе транзакции сами по себе правильные, обе в @Transactional, обе проходят все проверки, и база не видит ничего незаконного. Мы хотим не запретить чтение и не “синхронизировать Java-потоки”, а научиться обнаруживать конфликт записи.

Давайте ещё раз сформулируем боль максимально прагматично. У нас есть stock_item — строка в таблице, которую постоянно меняют разные use case’ы: резервирование при оформлении заказа, возврат при отмене заказа, иногда ручная корректировка. Когда два изменения происходят почти одновременно, у каждого из них в голове “старое” значение. Оба делают read modify write, и один из результатов может быть потерян.

Важно уловить одну мысль: @Transactional делает операцию атомарной внутри самой транзакции, но не гарантирует, что две транзакции не перезапишут друг друга. Транзакция — это про “всё или ничего” для набора SQL-операций, а lost update — про то, что две разные транзакции успешно сделали всё, но вместе испортили итог.

И вот здесь на сцену выходит optimistic locking. Он говорит: “Окей, работайте параллельно сколько хотите. Но когда будете записывать результат — докажите, что вы пишете поверх той версии данных, которую реально читали”.

2. @Version и версия строки

Самая удобная аналогия для @Version — это номер редакции документа. Представьте, что вы редактируете общий Google Doc: вы открыли документ версии 12, начали править, а коллега за минуту успел сохранить версию 13. Когда вы нажмёте “сохранить”, система должна либо аккуратно смержить изменения, либо сказать: “Стоп, документ уже изменился — конфликт”. JPA/Hibernate делают примерно то же самое, только вместо документа — строка таблицы.

@Version — это аннотация JPA, которая помечает поле сущности как поле версии. Hibernate начинает воспринимать сущность не просто как “набор колонок”, а как “набор колонок + номер версии состояния”. Этот номер версии хранится в таблице рядом с остальными полями. Самое приятное: бизнес-код обычно почти не меняется, потому что контроль конфликтов вшит в механику UPDATE/DELETE.

Схематически конфликт с версией выглядит так:

sequenceDiagram
    participant TxA as Транзакция A
    participant DB as База данных
    participant TxB as Транзакция B

    TxA->>DB: "SELECT StockItem(id=1) -> version=5, qty=10"
    TxB->>DB: "SELECT StockItem(id=1) -> version=5, qty=10"

    Note over TxA,DB: Обновление делается только если совпали id и version
    TxA->>DB: "UPDATE (ожидаю version=5) -> OK, version становится 6"
    TxB->>DB: "UPDATE (ожидаю version=5) -> 0 rows updated (конфликт)"

Обратите внимание на философию: никто заранее не блокирует строку. Обе транзакции спокойно прочитали данные и пошли работать. “Замок” появляется не в начале, а в момент записи: Hibernate просит базу обновить строку только если версия совпадает. Если версия уже другая — значит, кто-то успел изменить строку раньше, и наш результат потенциально устарел.

Чтобы почувствовать это не как “ORM-магия”, а как инженерный контракт, полезно сравнить два мира:

Что происходит Без @Version С @Version
Две транзакции читают одну строку Да Да
Обе считают новое значение Да Да
Вторая запись может перезаписать первую “тихо” Да, классический lost update Нет, конфликт станет видимым
Приложение узнаёт о проблеме Обычно никак Да, через optimistic lock failure

И вот тут важный психологический момент: optimistic locking не делает систему “всегда успешной”, он делает систему честной. Лучше получить понятный конфликт и обработать его, чем молча потерять часть изменений и потом удивляться “почему остатки не сходятся”.

Колонка version через Flyway

Раз у нас дисциплина “схема живёт через миграции”, то добавление версии начинается не с Java-кода, а со схемы. Новичкам часто хочется: “Я добавлю поле в entity, Hibernate сам создаст колонку”. Но мы уже прошли Flyway и понимаем, что это ровно тот путь, который ломает воспроизводимость и превращает базу в загадочную зверушку “как у меня локально получилось”.

Поскольку StockItem уже существует, мы добавляем новую миграцию, которая создаст колонку version. Нам важно сразу сделать её NOT NULL, потому что версия по смыслу обязательна. Но если таблица уже содержит строки, то простое NOT NULL без значения упрётся в существующие данные. Поэтому учебно-правильный минимум — задать DEFAULT 0, чтобы старые строки получили стартовую версию.

Пример небольшой миграции для PostgreSQL может выглядеть так:

-- db/migration/V25_01__stock_item_add_version.sql
-- DEFAULT 0 нужен, чтобы существующие строки сразу получили стартовую версию
-- Отдельный индекс на version обычно не нужен: PK(id) и так участвует в WHERE id = ? AND version = ?
alter table stock_item
    add column version bigint not null default 0;

Да, это всего одна строчка, но по смыслу она меняет правила игры: теперь у каждой строки stock_item есть технический “номер состояния”. В момент конкурентных обновлений именно он будет отличать “я обновляю то, что видел” от “я обновляю уже устаревшую картинку”.

Ещё один нюанс, который стоит проговорить: version обычно не индексируют отдельно. Он участвует в UPDATE/DELETE вместе с id, а id и так индексирован как PK. То есть WHERE id = ? AND version = ? обычно нормально отрабатывает по PK, и отдельный индекс на version не даёт пользы, но добавляет стоимость записи.

3. JPA-маппинг: @Version в StockItem

Когда схема готова, можно честно добавить поле в entity. Здесь есть простое правило, которое спасает нервы: поле версии — это техническое поле, а не часть бизнес-модели. Оно не должно участвовать в ваших бизнес-расчётах, его не нужно “красиво называть” в доменной логике, и тем более не нужно пытаться “придать ему смысл” типа “версия остатка = номер ревизии склада”.

В нашем проекте StockItem живёт в com.example.shopdatajpa.inventory.entity. Добавим туда поле версии. Для учебного проекта достаточно числового типа long: он простой, поддерживается везде, не требует специальных конвертеров и прекрасно маппится в PostgreSQL bigint.

Минимальный пример (показываю только важную часть):

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;

@Entity
public class StockItem {

    @Id
    private Long id; // Идентификатор строки (PK): вместе с version участвует в поиске "той самой" записи

    @Version
    private long version; // Техническая версия для optimistic locking: бизнес-код руками не меняет

    // Сеттер для version обычно не нужен: Hibernate сам управляет значением при UPDATE/DELETE
}

Обратите внимание на два “тихих” решения:

Первое решение — мы не обязаны писать сеттер для version. Если аннотации стоят на полях (field access), Hibernate работает напрямую с полем через reflection. Это уменьшает шанс, что кто-то в сервисе решит “немного помочь ORM” и начнёт вручную увеличивать версию (а это почти всегда плохая идея).

Второе решение — long, а не Long. Примитив не может быть null, значит, в Java-объекте версия всегда будет иметь значение (по умолчанию 0). Это удобно для новичков, потому что вы не ловите “ой, версия null” в логах и дебаге. В реальном проекте Long тоже встречается, но тогда нужно чуть больше дисциплины, особенно на момент “ещё не сохранено в БД”.

Если вам хочется немного систематизировать варианты, то вот практичная табличка “без академии”:

Тип в Java Тип в PostgreSQL Когда подходит Комментарий
long / int bigint / integer Почти всегда Самый понятный вариант
Instant / LocalDateTime timestamp Иногда Версия как “время последнего изменения”, но требует аккуратности
UUID (обычно не используют) Почти никогда для @Version Слишком экзотично для базового курса

Для нашего курса выбор long version — самый “честный”: версия — это просто счётчик, который ORM увеличивает при каждом изменении строки.

4. Автообновление версии в Hibernate

После добавления @Version у новичка обычно возникает два вопроса. Первый: “А кто будет увеличивать версию?” Второй: “А если я сам увеличу — будет ещё надёжнее?” Так вот, первый ответ — Hibernate. Второй — пожалуйста, не надо (а если надо, то это уже отдельная история с большим количеством причин, почему вы уверены).

Механика выглядит так: когда Hibernate делает INSERT новой строки для versioned entity, он выставляет стартовую версию. Для числовых версий это обычно 0. При каждом UPDATE Hibernate увеличивает версию на 1 и записывает новое значение обратно в строку. При DELETE версия тоже участвует в проверке: удаление должно удалить “ту версию”, которую вы реально читали, а не “что-то, что уже поменяли”.

Здесь важное методическое уточнение: optimistic locking — это не “проверка в Java-коде”, это условие на уровне SQL. То есть база данных в момент обновления отвечает на вопрос: “Есть ли всё ещё строка с таким id и такой version?” Если да — обновляем и увеличиваем версию. Если нет — значит, состояние изменилось, и Hibernate считает это конфликтом.

И вот почему не надо трогать версию руками. Если вы сделаете что-то вроде:

item.setAvailableQuantity(item.getAvailableQuantity() - qty);

// Демонстрация антипаттерна: так делать не надо, версией управляет ORM
// На конфликте вы получите OptimisticLockException / ObjectOptimisticLockingFailureException
item.setVersion(item.getVersion() + 1);

то вы ломаете две вещи сразу. Во-первых, вы вмешиваетесь в контракт ORM, и поведение становится непредсказуемым (особенно если где-то ещё есть flush/commit, отложенные изменения и прочая “жизнь транзакции”). Во-вторых, вы создаёте иллюзию контроля: кажется, что вы “защитились”, а на самом деле вы просто внесли шум.

Правильная мысль такая: версия — это часть persistence-механики, как первичный ключ или dirty checking. Это не бизнес-правило. Вам не нужно “помогать” Hibernate делать работу, которую он делает стабильно уже десятилетиями.

5. Сервисный код с optimistic locking

Самое приятное в @Version — то, что правильно написанный сервисный код (тот, который уже опирается на @Transactional и dirty checking) обычно менять не нужно. Это прямо соответствует идее курса: mapping — это инженерное решение, которое влияет на поведение всего приложения, даже если бизнес-методы остались прежними.

Представьте наш привычный метод резерва, который мы уже обсуждали в лекции про lost update. Сама доменная операция остаётся той же: reserve(qty) переносит количество между availableQuantity и reservedQuantity, а не просто вычитает одно число. Сервис по-прежнему выглядит “обычно”: загрузили StockItem, вызвали доменный метод, вышли из транзакции.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class InventoryService {

    private final StockItemRepository stockItemRepository;

    public InventoryService(StockItemRepository stockItemRepository) {
        this.stockItemRepository = stockItemRepository;
    }

    @Transactional
    public void reserve(Long stockItemId, int qty) {
        // Загружаем entity в persistence context: Hibernate запоминает текущую version
        StockItem item = stockItemRepository.findById(stockItemId).orElseThrow();

        // Делаем тот же доменный reserve-flow, что и раньше:
        // reserve(qty) меняет и availableQuantity, и reservedQuantity
        item.reserve(qty);

        // Если кто-то параллельно успел обновить эту же строку,
        // то на flush/commit будет выброшено исключение optimistic locking
    }
}

С точки зрения Java — ничего нового. И это хорошо, потому что мы не хотим превращать каждый use case в “ручное сравнение версий, ручные if’ы, ручные retry”. Мы хотим, чтобы инфраструктура гарантировала: “если ты пытаешься записать устаревшее состояние — ты об этом узнаешь”.

Сервис остаётся таким, каким он должен быть по архитектуре: он выражает намерение (reserve), а не “танцует с SQL”. Детали конкуренции при этом становятся частью persistence layer, где им и место.

Отдельно подчеркну: именно поэтому optimistic locking хорошо ложится на подход “transaction boundary в сервисе”. Если бы вы обновляли StockItem кусками, без транзакции, или “в контроллере как получится”, механизм был бы намного менее предсказуемым. Не потому что @Version плохой, а потому что архитектура слоёв сломана.

6. Границы @Version

Очень важно не перепутать “механизм защиты от lost update” с “универсальным средством от всех бед”. @Version решает конкретную проблему: когда два изменения одной строки могли бы пройти молча и затереть друг друга, @Version делает конфликт видимым. Это уже огромный шаг к честной системе.

Но есть и границы, о которых стоит помнить.

@Version не заменяет бизнес-валидацию. Например, если у вас правило “нельзя уйти в минус”, оно всё равно должно быть выражено в коде (а иногда и в схеме через дополнительные constraints, если это уместно). Версия не скажет вам: “ты зарезервировал слишком много”. Она скажет другое: “кто-то уже поменял эту строку с момента, как ты её прочитал”.

@Version не является блокировкой. Он не делает “только один человек может читать/писать сейчас”. Две транзакции всё равно могут читать одновременно и даже готовить изменения параллельно. Конфликт обнаружится позже. Если вам нужно поведение “пусть второй подождёт, пока первый закончит”, это уже пессимистическая блокировка. Здесь важно просто не смешивать её с optimistic locking: это другой режим работы.

@Version работает, когда вы обновляете данные как entity через Hibernate. Если вы внезапно делаете bulk update (@Modifying) или прямой native SQL, вы можете обойти “нормальную” lifecycle-механику. И тогда optimistic locking либо не сработает так, как вы ожидаете, либо потребует явного учёта версии в запросе. Мы уже видели, что bulk-операции вообще живут по другим правилам — и версия тоже не исключение.

И ещё одна граница, чисто практическая: optimistic locking не делает конфликт “исчезающим”, он делает его “обнаруживаемым”. После обнаружения у приложения появляется выбор: сообщить об ошибке, повторить попытку, перезагрузить состояние и т.д. Пока важно одно: конфликт не должен быть тихим. Если состояние устарело, текущая операция должна завершиться как неуспешная, а не перезаписать чужие изменения.

7. Типичные ошибки при работе с @Version

Оптимистическая блокировка часто ломается не потому, что механизм сложный, а потому что мы (люди) слишком оптимистично относимся к своей внимательности. Ниже — ошибки, которые встречаются почти гарантированно, если не проговорить их заранее.

Ошибка №1: пытаться увеличивать version вручную.
Соблазн понятен: “Я же вижу поле, значит я могу им управлять”. Но @Version — это не ваш счётчик, а часть контракта между ORM и базой. Ручное изменение версии превращает поведение в непредсказуемый коктейль из dirty checking, flush-логики и ваших “улучшений”. Лучшее лекарство — вообще не делать сеттер для версии и считать это поле read-only для бизнес-кода.

Ошибка №2: добавить поле в entity, но забыть миграцию Flyway.
Тогда вы получите либо ошибку старта приложения (если Hibernate пытается читать/писать колонку, которой нет), либо странное поведение в зависимости от настроек DDL. В нашем курсе схема — источник правды, значит порядок простой: сначала миграция, потом маппинг, потом запуск.

Ошибка №3: сделать колонку version nullable или без backfill для существующих строк.
Оптимистическая блокировка предполагает, что версия есть всегда. Если в таблице уже лежат данные, а вы добавили version как NULL, то часть строк будет “без версии”, и вы получите либо исключения, либо нелогичные конфликты. Учебно-практичный минимум — NOT NULL DEFAULT 0, чтобы существующие строки получили стартовую версию.

Ошибка №4: поставить @Version “куда попало”, а не туда, где реально есть конкурентные обновления.
@Version имеет смысл там, где объект часто обновляют разные операции. В нашем домене это прежде всего StockItem, потому что остатки меняются постоянно. Если вы поставите версию, например, на справочник Category, вы формально получите защиту, но практической пользы будет мало, а шум в миграциях и запросах — останется.

Ошибка №5: ожидать, что @Version решит бизнес-конфликты типа “не хватает товара”.
Optimistic locking отвечает на вопрос “не изменил ли кто-то строку с момента чтения”. Он не отвечает на вопрос “можно ли так менять остаток”. Поэтому в голове нужно держать две разные причины отказа: бизнес-ошибка (“недостаточно остатка”) и технический конфликт согласованности (“версия устарела”). Если смешать их в одну кучу, сервис станет объяснять пользователю “внутреннюю ошибку ORM” вместо нормального смысла.

1
Задача
Spring Data JPA, 25 уровень, 1 лекция
Недоступна
Версионная сущность и миграция Flyway
Версионная сущность и миграция Flyway
1
Задача
Spring Data JPA, 25 уровень, 1 лекция
Недоступна
Первое успешное изменение versioned entity
Первое успешное изменение versioned entity
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ