💾 Розділ 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.

Пов’язані теми