💾 Раздел 16 · Вопрос #19

Что такое @Version и зачем она нужна

Аннотация @jakarta.persistence.Version (в старых версиях javax.persistence.Version) — это стандартная аннотация спецификации JPA, предназначенная для автоматической реализации о...

Аннотация @jakarta.persistence.Version (в старых версиях javax.persistence.Version) — это стандартная аннотация спецификации JPA, предназначенная для автоматической реализации оптимистичной блокировки (Optimistic Locking). Она защищает данные от критической аномалии параллелизма — потери обновлений (Lost Update), гарантируя, что ни одна транзакция не сможет перезаписать изменения, внесенные другим пользователем, без явного обнаружения конфликта.


🟢 Junior Level

Зачем нужна аннотация @Version

Представим систему бронирования авиабилетов или банковских переводов без контроля версий:

  1. Пользователь A читает баланс счета: $100.
  2. Пользователь B параллельно читает баланс счета: $100.
  3. Пользователь B снимает $20 и сохраняет баланс: $80.
  4. Пользователь A добавляет $50 и сохраняет свой расчет ($100 + $50 = $150).
  5. Итог: Запись пользователя A полностью затерла списание пользователя B! $20 бесследно пропали (аномалия Lost Update).

Аннотация @Version устраняет эту проблему на уровне ORM:

@Entity
@Table(name = "accounts")
public class Account {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private BigDecimal balance;

    @Version
    private Long version; // Управляется Hibernate автоматически
}

Как Hibernate управляет полем @Version

  • При первичной вставке (INSERT): Hibernate автоматически инициализирует поле version начальным значением 0 (или 1 в зависимости от диалекта/версии).
  • При каждом обновлении (UPDATE): Hibernate инкрементирует версию на единицу и добавляет проверку в условие WHERE:
    UPDATE accounts 
    SET balance = ?, version = version + 1 
    WHERE id = ? AND version = ?;
    
  • При конфликте версий: Если другая транзакция уже увеличила версию в таблице, СУБД обновит 0 строк. Hibernate фиксирует это и немедленно выбрасывает исключение jakarta.persistence.OptimisticLockException.

🟡 Middle Level

Разрешенные типы данных для @Version

Спецификация JPA жестко регламентирует типы данных, которые могут быть помечены аннотацией @Version:

Тип Java Тип в БД Особенности Рекомендация
int / Integer INTEGER Автоинкремент на +1 при каждом обновлении. Переполнение маловероятно (до 2 млрд версий). ✅ Рекомендуется
long / Long BIGINT Автоинкремент на +1. Исключает переполнение при любых нагрузках. ✅ Лучший выбор
short / Short SMALLINT Ограничен 32 767 версиями. Быстро переполняется при частых обновлениях. ⚠️ Не рекомендуется
java.sql.Timestamp / Instant TIMESTAMP При обновлении записывается системное время. ❌ Опасно в Production (риск коллизий в одну миллисекунду)

Разница между @Version и @LastModifiedDate

Разработчики часто путают версионирование с аудитом:

@Entity
@EntityListeners(AuditingEntityListener.class)
public class Document {
    @Id
    private Long id;

    // 1. Для оптимистичной блокировки и изоляции транзакций
    @Version
    private Long version; 

    // 2. Для аудита и бизнес-логики (Spring Data Auditing)
    @LastModifiedDate
    private Instant updatedAt; 
}
  • @Version: Предназначен строго для СУБД и ORM. Ручная мутация поля в бизнес-коде категорически запрещена (account.setVersion(5) сломает механизм проверки).
  • @LastModifiedDate: Служит для фиксации времени для аналитики, интерфейса и логов. Он не участвует в условии WHERE ... AND version = ?.

🔴 Senior Level

Влияние @Version на определение статуса «New Entity» в Spring Data JPA

При использовании ручного назначения первичных ключей (assigned ID, например, естественные ключи или клиентские UUID) возникает известная проблема:

  • Spring Data JPA SimpleJpaRepository.save() проверяет entityInformation.isNew(entity).
  • Если у сущности нет поля @Version, Spring проверяет id == null. Так как ID задан вручную, isNew() возвращает false, и Spring вызывает неэффективный entityManager.merge() (делая лишний штрафной SELECT перед INSERT).
  • Senior-инсайт: Если в сущности объявлено поле @Version с объектным типом (Long или Integer), Spring Data JPA меняет стратегию:
    // Внутри AbstractEntityInformation:
    if (versionAttribute != null) {
        return versionAttribute.get(entity) == null; // Если version == null, сущность новая!
    }
    

    Поскольку у нового объекта поле version равно null, Spring Data безошибочно определяет сущность как новую и вызывает быстрый entityManager.persist() без единого лишнего чтения!

               ОПРЕДЕЛЕНИЕ НОВОЙ СУЩНОСТИ В SPRING DATA JPA
               
                             [ save(entity) ]
                                    │
                       Есть ли аннотация @Version?
                                   / \
                            ДА    /   \   НЕТ
                                 /     \
             version == null?           id == null?
                /        \                 /        \
          ДА   /          \ НЕТ      ДА   /          \ НЕТ
              ▼            ▼             ▼            ▼
          persist()     merge()       persist()     merge()
        (Без SELECT)   (С SELECT!)  (Без SELECT)   (С SELECT!)

@Version и массовые HQL/JPQL обновления (Bulk Operations)

Критическая ловушка для сеньоров:

// Массовое изменение цен через JPQL:
@Modifying
@Query("UPDATE Product p SET p.price = p.price * 1.1 WHERE p.category = :category")
int increasePrices(@Param("category") String category);

Что происходит внутри:

  1. Массовые запросы UPDATE и DELETE транслируются напрямую в один SQL-стейтмент в базу данных.
  2. Механизм Dirty Checking и жизненный цикл JPA полностью игнорируются.
  3. Hibernate НЕ БУДЕТ автоматически инкрементировать колонку version в БД!
  4. Следствие: Поле version в базе данных останется старым, а если эти сущности уже были загружены в L1/L2 кэш, приложение продолжит работать с устаревшими версиями.
  5. Правило: При выполнении кастомных @Modifying запросов колонка версии обязана инкрементироваться вручную прямо в тексте JPQL:
    @Modifying
    @Query("UPDATE Product p SET p.price = p.price * 1.1, p.version = p.version + 1 WHERE p.category = :category")
    int increasePricesManuallyIncrementingVersion(@Param("category") String category);
    

4 Tricky Questions

1. Можно ли объявить в одной JPA-сущности две разные аннотации @Version (например, числовую для блокировок и Timestamp для контроля времени)?

Ответ:

  1. Категорически нет. Спецификация JPA (раздел 4.4.4.2 Optimistic Locking) прямо запрещает наличие более чем одного поля с аннотацией @Version в классе сущности.
  2. При попытке задеплоить приложение с двумя @Version Hibernate завершит инициализацию метамодели фатальным исключением: org.hibernate.AnnotationException: A factory cannot support multiple version properties: com.example.MyEntity.
  3. Для хранения времени изменения следует использовать стандартное поле с @LastModifiedDate или Hibernate @UpdateTimestamp.

2. Что произойдет, если передать detached-объект в entityManager.merge(), но на клиенте перед отправкой поле version было изменено вручную или обнулено (version = null)?

Ответ:

  1. Если version = null: Hibernate может посчитать сущность новой и попытаться выполнить SQL INSERT. Если первичный ключ уже занят, СУБД выбросит ошибку нарушения первичного ключа (Unique Constraint Violation).
  2. Если version передана со значением, не совпадающим с базой данных (например, клиент передал version = 1, а в БД уже version = 2): метод merge() загрузит актуальную строку из БД, обнаружит несовпадение версий и немедленно выбросит OptimisticLockException еще до выполнения каких-либо мутаций.

3. Инкрементирует ли Hibernate версию сущности, если транзакция выполнила только чтение без вызова сеттеров, но при этом был указан LockModeType.OPTIMISTIC?

Ответ:

  1. Нет, при LockModeType.OPTIMISTIC (ранее READ) Hibernate не инкрементирует версию.
  2. Во время фазы сброса контекста (flush) перед коммитом Hibernate выполняет дополнительный проверочный запрос: SELECT version FROM table WHERE id = ?.
  3. Если версия в БД по-прежнему совпадает с прочитанной ранее, транзакция успешно фиксируется. Версия остается неизменной.
  4. Чтобы версия инкрементировалась при отсутствии изменений полей, необходимо явно запросить режим LockModeType.OPTIMISTIC_FORCE_INCREMENT.

4. Почему при использовании оптимистичной блокировки в кластере микросервисов поле @Version не спасает от повторной обработки одного и того же события из Kafka при отсутствии идемпотентности?

Ответ:

  1. Если два микросервиса одновременно получат одинаковое событие создания новой сущности, поле version у обоих изначально будет null (или 0 при вставке).
  2. Оптимистичная блокировка защищает только операцию UPDATE существующих строк (WHERE version = ?).
  3. При параллельном INSERT обе транзакции попытаются вставить новую запись с одинаковым бизнес-ключом. Защитить от этого может только уникальный индекс на уровне базы данных (UNIQUE constraint), который приведет к DataIntegrityViolationException, но не @Version.

🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«Аннотация @Version в JPA активирует механизм оптимистичной блокировки для защиты от аномалии Lost Update. Полем версии должен быть Long или Integer. При каждом обновлении сущности Hibernate автоматически инкрементирует значение и добавляет условие WHERE id = ? AND version = ?. Если запись была изменена конкурентной транзакцией, запрос обновляет 0 строк и выбрасывается OptimisticLockException. Важный senior-нюанс: в Spring Data JPA при ручном присвоении ID наличие @Version == null позволяет фреймворку распознать объект как новый и выполнить быстрый persist() вместо merge(). При массовых HQL-запросах (@Modifying) версию необходимо инкрементировать вручную прямо в запросе».

Ключевые метрики для Production-мониторинга

  • Количество OptimisticLockException: сигнализирует о точках горячей конкуренции в бизнес-процессах.
  • Распределение версий по сущностям: аномально высокие версии (> 10 000) указывают на слишком частое обновление одного и того же объекта.

Красные флаги (Чего говорить НЕЛЬЗЯ)

  • ❌ «Использую Timestamp в качестве @Version для надёжности» — Риск коллизий в пределах одной миллисекунды; использовать строго Long или Integer.
  • ❌ «Я могу вручную вызывать entity.setVersion(v + 1) в сервисе» — Ручное управление версией ломает внутренний цикл синхронизации ORM.
  • ❌ «Массовый запрос UPDATE через @Modifying автоматически увеличивает @Version» — Нет, bulk-запросы идут в обход контекста, версию нужно инкрементировать вручную.
  • ❌ «Можно повесить две аннотации @Version на разные поля» — Запрещено спецификацией JPA, упадет с AnnotationException.

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