Что такое @Version и зачем она нужна
Аннотация @jakarta.persistence.Version (в старых версиях javax.persistence.Version) — это стандартная аннотация спецификации JPA, предназначенная для автоматической реализации о...
Аннотация @jakarta.persistence.Version (в старых версиях javax.persistence.Version) — это стандартная аннотация спецификации JPA, предназначенная для автоматической реализации оптимистичной блокировки (Optimistic Locking). Она защищает данные от критической аномалии параллелизма — потери обновлений (Lost Update), гарантируя, что ни одна транзакция не сможет перезаписать изменения, внесенные другим пользователем, без явного обнаружения конфликта.
🟢 Junior Level
Зачем нужна аннотация @Version
Представим систему бронирования авиабилетов или банковских переводов без контроля версий:
- Пользователь A читает баланс счета:
$100. - Пользователь B параллельно читает баланс счета:
$100. - Пользователь B снимает
$20и сохраняет баланс:$80. - Пользователь A добавляет
$50и сохраняет свой расчет ($100+$50=$150). - Итог: Запись пользователя 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);
Что происходит внутри:
- Массовые запросы
UPDATEиDELETEтранслируются напрямую в один SQL-стейтмент в базу данных. - Механизм Dirty Checking и жизненный цикл JPA полностью игнорируются.
- Hibernate НЕ БУДЕТ автоматически инкрементировать колонку
versionв БД! - Следствие: Поле
versionв базе данных останется старым, а если эти сущности уже были загружены в L1/L2 кэш, приложение продолжит работать с устаревшими версиями. - Правило: При выполнении кастомных
@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 для контроля времени)?
Ответ:
- Категорически нет. Спецификация JPA (раздел 4.4.4.2 Optimistic Locking) прямо запрещает наличие более чем одного поля с аннотацией
@Versionв классе сущности. - При попытке задеплоить приложение с двумя
@VersionHibernate завершит инициализацию метамодели фатальным исключением:org.hibernate.AnnotationException: A factory cannot support multiple version properties: com.example.MyEntity. - Для хранения времени изменения следует использовать стандартное поле с
@LastModifiedDateили Hibernate@UpdateTimestamp.
2. Что произойдет, если передать detached-объект в entityManager.merge(), но на клиенте перед отправкой поле version было изменено вручную или обнулено (version = null)?
Ответ:
- Если
version = null: Hibernate может посчитать сущность новой и попытаться выполнить SQLINSERT. Если первичный ключ уже занят, СУБД выбросит ошибку нарушения первичного ключа (Unique Constraint Violation). - Если
versionпередана со значением, не совпадающим с базой данных (например, клиент передалversion = 1, а в БД ужеversion = 2): методmerge()загрузит актуальную строку из БД, обнаружит несовпадение версий и немедленно выброситOptimisticLockExceptionеще до выполнения каких-либо мутаций.
3. Инкрементирует ли Hibernate версию сущности, если транзакция выполнила только чтение без вызова сеттеров, но при этом был указан LockModeType.OPTIMISTIC?
Ответ:
- Нет, при
LockModeType.OPTIMISTIC(ранееREAD) Hibernate не инкрементирует версию. - Во время фазы сброса контекста (
flush) перед коммитом Hibernate выполняет дополнительный проверочный запрос:SELECT version FROM table WHERE id = ?. - Если версия в БД по-прежнему совпадает с прочитанной ранее, транзакция успешно фиксируется. Версия остается неизменной.
- Чтобы версия инкрементировалась при отсутствии изменений полей, необходимо явно запросить режим
LockModeType.OPTIMISTIC_FORCE_INCREMENT.
4. Почему при использовании оптимистичной блокировки в кластере микросервисов поле @Version не спасает от повторной обработки одного и того же события из Kafka при отсутствии идемпотентности?
Ответ:
- Если два микросервиса одновременно получат одинаковое событие создания новой сущности, поле
versionу обоих изначально будетnull(или0при вставке). - Оптимистичная блокировка защищает только операцию
UPDATEсуществующих строк (WHERE version = ?). - При параллельном
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.