JavaRush /Курси /Hibernate deep-dive /Стратегії ідентифікаторів і ключів

Стратегії ідентифікаторів і ключів

Hibernate deep-dive
Рівень 15 , Лекція 0
Відкрита

1. Ідентифікатор глибший, ніж @Id

Коли ви вперше бачите JPA‑сутність, @Id виглядає як нудна обов’язкова наліпка: «Без неї Hibernate не запуститься». Але в поглибленому курсі ця наліпка раптом стає половиною сюжету: від ідентифікатора залежить, як Hibernate впізнає, хто є хто, коли виконує SQL і чому ваші об’єкти іноді поводяться як персонажі серіалу з втратою памʼяті.

На побутовому рівні здається, що ідентифікатор потрібен лише для читання з таблиці: ось є product.id, і за ним ми робимо SELECT. На практиці ідентифікатор — це опорна точка, навколо якої побудована вся внутрішня дисципліна ORM. Саме за @Id Hibernate вирішує, чи можна вважати два об’єкти «тим самим» товаром, як організувати кеш першого рівня, як коректно виконати merge() для detached‑даних (ми вже це проходили) і чому повторний find() в одній транзакції повертає той самий об’єкт.

Ще один важливий момент: ідентифікатор — це не лише історія про базу. Це історія про вашу памʼять, колекції, equals()/hashCode() і навіть про те, як ви пишете сервісні сценарії. Ідентифікатор відповідає на три практичні запитання, які ми сьогодні триматимемо в голові: хто створює значення, коли воно зʼявляється і чи несе воно предметний зміст, чи залишається суто технічним ключем.

2. Види ідентичності: об’єктна, БД, бізнес

Дуже легко заплутатися, бо слово «ідентифікатор» звучить як «щось унікальне», а унікальне, здається, має бути одне. На практиці в застосунку одночасно живуть щонайменше три різні «ідентичності», і якщо їх не розвести, ви рано чи пізно отримаєте дивні баги, які важко пояснити колегам без жестів і нервового сміху.

Перша — об’єктна ідентичність у Java. Це відповідь на запитання: «Це той самий об’єкт у пам’яті?» — і перевіряється найчеснішим оператором ==. Якщо a == b, то це буквально один і той самий екземпляр, просто з двома іменами. Hibernate цю ідентичність підтримує всередині persistence context (identity map), але сам по собі світ JVM узагалі не зобов’язаний думати про базу даних.

Друга — таблична (database) ідентичність. Це первинний ключ рядка в таблиці. У PostgreSQL це зазвичай BIGINT, іноді UUID, іноді — у застарілих схемах — складений ключ. У реляційній моделі «хто ти такий» визначається саме первинним ключем. У рядка може змінитися name, status, price, але якщо його primary key не змінився — це той самий рядок.

Третя — предметна (business) ідентичність. Це те, що людина в предметній області вважає ідентифікатором: sku у товару, email у клієнта, orderNumber у замовлення. Це зручно, читабельно і часто справді унікально. Але предметна ідентичність не зобов’язана збігатися з primary key таблиці і зовсім не зобов’язана збігатися з тим, що зручно Hibernate для внутрішніх цілей.

Щоб не тримати це «на відчуттях», корисно один раз побачити все поруч:

Рівень «Хто ти?» Приклад Як перевіряємо
Java‑об’єкт Один і той самий екземпляр у пам’яті Product@0xA1 ==
База даних Один і той самий рядок таблиці product.id = 42 primary key
Домен (бізнес) Один і той самий товар «за змістом» sku = "SKU-001" унікальне бізнес‑поле

Hibernate живе на стику цих світів і робить важливу річ: він намагається так організувати роботу, щоб таблична ідентичність всередині однієї транзакції відповідала об’єктній ідентичності. Тобто «рядок product(id=42)» має бути представлений одним managed‑об’єктом Product, а не двома різними new Product() з однаковим id. І вгадайте, яка деталь потрібна, щоб це стало можливим? Так — ідентифікатор.

3. Persistence Context і identity map

Якби Hibernate був бібліотекарем, то @Id був би читацьким квитком книги. Не назвою, не описом і не кольором обкладинки, а тим номером, за яким бібліотекар точно знає: «Ага, ось ця книга вже в мене на столі, вдруге не тягнемо зі складу». Persistence context (у термінах Hibernate Session) улаштований саме так: він зберігає managed‑сутності й дістає їх за ключем «тип сутності + ідентифікатор».

Це важливо проговорити: одного id=1 недостатньо, бо Product(id=1) і Customer(id=1) — різні сутності. Тому ключ, яким оперує ORM, логічно схожий на EntityKey(entityName, id). Звідси випливає базовий ефект, який ви вже бачили раніше: повторний find() по тому самому id всередині однієї транзакції повертає той самий Java‑об’єкт, без другого SQL‑запиту.

Схематично persistence context можна уявити так:

flowchart LR
    %% Ключ: (тип сутності + id). За ним Hibernate знаходить managed-об’єкт у межах однієї транзакції.
    subgraph PC["Persistence Context (Session / кеш першого рівня)"]
      K1["EntityKey(Product, 42)"] --> P1["Product @0xA1 (managed)"]
      K2["EntityKey(Customer, 7)"] --> C1["Customer @0xB3 (managed)"]
    end

    %% flush синхронізує зміни з Persistence Context до бази.
    PC -->|flush| DB[(PostgreSQL)]

Зверніть увагу на важливу думку: persistence context «пам’ятає» сутність не за sku, не за іменем, не за всім набором полів, а саме за ідентифікатором.

Міні‑експеримент у нашому проєкті: один рядок — один об’єкт

У Commerce Persistence Lab такий експеримент зручно робити в сервісі (або в інтеграційному тесті), у транзакції:

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductDebugService {
    private final EntityManager em;

    public ProductDebugService(EntityManager em) { this.em = em; }

    @Transactional
    public void checkIdentityMap(Long id) {
        var p1 = em.find(Product.class, id);
        var p2 = em.find(Product.class, id);

        System.out.println(p1 == p2); // true
    }
}

У SQL‑лозі ви побачите один SELECT на перший find(). На другому find() запиту не буде, бо Hibernate вже тримає managed‑об’єкт у persistence context. І це не «кеш заради прискорення», а фундаментальна гарантія консистентності: якби у вас було два різні об’єкти на один рядок, dirty checking і flush перетворилися б на хаос.

Чому проксі теж «тримається» на @Id

Ми вже торкалися getReference() і proxy‑об’єктів. Важливий нюанс: проксі може існувати без повного завантаження сутності, бо ідентифікатор відомий. Тобто Hibernate може сказати: «Ось тобі об’єкт‑замінник, я знаю його id, а решту підтягну, коли попросиш».

Такий код добре показує ідею (у транзакції, щоб не впасти через lazy‑межу):

import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public void referenceIdOnly(EntityManager em, Long id) {
    // Отримуємо проксі: Hibernate знає ідентифікатор, але не зобов’язаний одразу робити SELECT.
    var ref = em.getReference(Product.class, id);

    // Важливо: читання id зазвичай не тригерить завантаження, бо id уже відомий ORM.
    System.out.println(ref.getId()); // 42 (і часто без SELECT)
}

Ідентифікатор тут — якір. Він дозволяє Hibernate зберігати посилання на сутність і в зв’язках (FK), і в проксі, і в identity map. У цьому сенсі @Id — не «поле таблиці», а «перша цеглинка» всієї runtime‑моделі.

4. Момент появи ідентифікатора

На цьому місці зазвичай починається легка паніка: «Зачекайте… а хіба id не завжди зʼявляється одразу?» На жаль. Або на щастя, якщо ви любите складні системи. Момент появи ідентифікатора залежить від того, як саме він генерується: базою, Hibernate на боці Java або вашим кодом. У наступних лекціях ми детально порівняємо стратегії, а зараз нам важливо зафіксувати загальну модель без назв анотацій.

У сутності в стані transient часто немає id (або він є, але це окремий усвідомлений вибір). Далі ви викликаєте persist(): Hibernate реєструє сутність у persistence context. І ось тут можливі варіанти: іноді id можна отримати одразу, іноді — лише коли ORM реально зробить INSERT (зазвичай під час flush). Іноді ж id взагалі приходить «ззовні» до persist() — наприклад, якщо це assigned id.

Якщо уявити це як часову лінію, вийде приблизно так:

sequenceDiagram
    %% Id може з’являтися в різні моменти: до persist(), після persist() або лише на INSERT.
    participant App as Ваш код
    participant PC as Persistence Context
    participant DB as PostgreSQL

    App->>App: new Product()
    Note over App: "transient: id може бути null"

    App->>PC: persist(product)
    Note over PC: "сутність стала managed"

    PC->>DB: flush
    Note over DB: "INSERT: база може видати id під час вставки"
    DB-->>PC: id (якщо генерує БД)

Чому нам узагалі важливий цей момент «коли id стає доступним»? Бо id — це не лише «щоб потім знайти». Іноді він потрібен вже зараз усередині бізнес‑операції. Наприклад, ви хочете зв’язати нову сутність з іншою, покласти її в мапу, залогувати «створено об’єкт X з id=…» або просто передати ідентифікатор далі по коду. І якщо ви подумки вважаєте, що id завжди відомий одразу, ви почнете писати код, який «зазвичай працює», а потім ламається при зміні стратегії генерації.

У Commerce Persistence Lab ми спеціально триматимемо SQL‑лог увімкненим і спостережуваним, щоб такі речі були не здогадками, а перевірюваними фактами. Це дуже дисциплінує: замість «здається, id зʼявляється тут» ви дивитеся на момент SQL і розумієте, чому id став ненульовим саме зараз.

5. Технічний id і бізнес‑поля

Іноді хочеться зробити красиво: раз sku унікальний, чому б не зробити його primary key? Людина ж за ним шукає товар, отже це «справжній ідентифікатор». Логіка звучить приємно, доки ви не починаєте жити з цією моделлю рік-два і не розумієте, що в цьому «приємно» є ціна. Hibernate‑курс якраз про ціну рішень, тому зупинимося на цьому спокійно й без релігії.

Технічний id (@Id) — це інструмент ORM і БД, який має бути стабільним, зручним для зв’язків (FK), не надто широким і не надто «розумним». Бізнес‑поле на кшталт sku — це частина доменної моделі. Воно читабельне, важливе для людей, часто бере участь в унікальності та інтеграціях. Але доменне життя складне: sku може мати формат, який бізнес захоче змінити; email може змінюватися; orderNumber може мати префікси, міграції, «переїзд на нову нумерацію з понеділка».

Тому дуже поширений і здоровий підхід: тримати технічний id як primary key, а бізнес‑поле — поруч, з унікальним обмеженням. На прикладі товару це виглядає максимально приземлено:

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Product {
    @Id
    // Технічний id: його задача — бути стабільним PK і ключем identity map.
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(nullable = false, unique = true)
    // Бізнес-ідентифікатор: унікальний для домену, але не зобов’язаний бути PK.
    private String sku;
}

Так, тут я вже показав SEQUENCE, але поки сприймайте це як «якийсь генератор». Порівняння стратегій буде в наступній лекції. У цій лекції важливо інше: id і sku розв’язують різні задачі. Якщо ви почнете ставитися до них як до одного й того самого, ви неминуче змішаєте і відповідальність, і архітектурні межі.

У репозиторії це зазвичай читається як «усередині живемо за id, а в бізнес‑сценаріях уміємо шукати за sku»:

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
    // Зручний доменний вхід: пошук за бізнес-полем, при цьому PK лишається технічним.
    Optional<Product> findBySku(String sku);
}

Зверніть увагу: наявність findBySku не скасовує сенсу @Id. Hibernate все одно будуватиме свою identity map за primary key. Просто тепер ви додали зручний вхід у доменну модель «по-людськи».

6. Id у коді: equals()/hashCode() і Set

Якби всі проблеми з id обмежувалися вибором колонки в БД, ми б закрили ноутбук і пішли пити чай. Але ORM цікавіший: ідентифікатор впливає на те, як ваші сутності поводяться в колекціях, як ви пишете equals()/hashCode() і навіть на те, які «невинні» операції раптово ламають стан об’єкта.

Ви вже бачили на 13‑му дні, що для entity не можна бездумно робити equality «за всіма полями» і що generated id — незручна опора для hashCode() до збереження. Сьогодні важливо пов’язати це із загальною моделлю: якщо id зʼявляється не одразу, то об’єкт може спочатку жити з id=null, потім отримати id, і якщо ваш hashCode() залежить від id — він зміниться посеред життя об’єкта. А HashSet і HashMap такого не люблять. Вони взагалі не підписувалися на серіал «об’єкт змінив хеш і пішов в іншу кишеню».

Ось міні‑приклад, який у реальному проєкті трапляється частіше, ніж хотілося б:

import java.util.HashSet;

// Уявіть, що нижче це виконується в транзакції, а em — доступний EntityManager.
var set = new HashSet<Product>();
var p = new Product(); // id поки null

set.add(p);
em.persist(p); // після persist/flush id може з’явитися і вплинути на hashCode()

System.out.println(set.contains(p)); // може стати false за невдалого hashCode()

Сенс не в тому, що persist() «псує колекції». Сенс у тому, що ви обрали таку стратегію equality, яка не сумісна з життєвим циклом сутності. І щойно ви починаєте сприймати ідентифікатор як частину runtime‑моделі, ці баги перестають бути містикою: ви буквально бачите, чому колекція поводиться саме так.

Ще один практичний зв’язок — DTO/мапінг. Помилка новачка: взяти вхідний DTO і «залити його в entity повністю», включно з id. У результаті ви самі собі організовуєте або спробу змінити primary key (що зазвичай заборонено й логічно безглуздо), або дивну семантику «я створив нову сутність, але вже з id». У нашому проєкті ми свідомо тримаємо стиль find + mutate, де id використовується як «знайти, кого змінювати», а не як «поле, яке ми редагуємо».

І ось тут хороша інженерна звичка: id — це річ, яку ви майже ніколи не «змінюєте». Ви її або отримуєте від генератора, або призначаєте один раз усвідомлено (assigned id), і далі ставитеся до неї як до ідентичності рядка.

7. Рамка вибору ідентифікатора: три питання

Щоб не тонути в анотаціях, корисно тримати просту рамку. Вона звучить майже як запитання на співбесіді, але в хорошому сенсі: відповідаючи на них, ви одразу розумієте, який клас рішень вам підходить, а який створить зайву складність. Зараз ми поставимо ці запитання без глибокого порівняння стратегій — воно буде в наступних лекціях дня.

Ось ці три запитання в «людському» вигляді:

Запитання Що ви з’ясовуєте Чому це важливо для поведінки Hibernate
Хто створює значення id? БД / Hibernate (Java-side) / ваш код Від цього залежить, де знаходиться «джерело істини»
Коли id стає доступним? До persist() / після persist() / лише на INSERT Це впливає на insert‑потік, зв’язки і ваш код навколо сутності
Чи несе id предметний зміст? так (business) / ні (technical) Це впливає на читабельність моделі і на те, що можна/не можна змінювати

Тими самими питаннями далі й користуватимемося. Спочатку важливо зрозуміти, як різні способи генерації technical PK змінюють момент появи id і форму INSERT‑потоку. Потім — навіщо поруч із цим PK живе business-id на кшталт sku або orderNumber. І лише потім — коли взагалі нечесно вдавати, що ідентичність уміщується в одне поле, бо рядок задає комбінація значень.

Якщо ви чесно відповіли на ці питання, ви вже на 80% захистилися від двох крайнощів. Перша крайність — «усюди один і той самий підхід, бо так звикли». Друга — «давайте зробимо красиво доменно, але потім здивуємося ціні для ORM і SQL». У нашому проєкті, як правило, у core‑сутностей буде технічний id, а бізнес‑поля на кшталт sku, email, orderNumber житимуть поруч і виражатимуть предметний зміст окремо. Але сьогодні ми поки що фіксуємо не конкретні відповіді, а саму звичку їх ставити.

8. Типові помилки під час роботи з ідентифікаторами

Помилки навколо ідентифікаторів найчастіше не виглядають як «помилка компіляції». Вони виглядають як дивні побічні ефекти: зайві запити, неочікувана поведінка колекцій, плутанина «чому Hibernate вважає це новою сутністю», проблеми з detached‑даними. Хороша новина в тому, що майже всі ці помилки лікуються одним і тим самим засобом: повернути ідентифікатор у runtime‑картину світу, а не ставитися до нього як до «колонки в таблиці».

Помилка №1: вважати, що @Id потрібен лише «щоб робити SELECT за ключем».
Коли ви так думаєте, ви втрачаєте з поля зору, що @Id — це ключ identity map у persistence context. Підсумок зазвичай такий: людина дивується, чому два об’єкти «з одним і тим самим id» в одній транзакції — це проблема, або чому повторний find() не робить SQL. Щойно ви починаєте мислити «тип сутності + id = ключ managed‑об’єкта», поведінка Hibernate перестає здаватися магією.

Помилка №2: змішувати технічний id і бізнес‑ідентифікацію, роблячи їх «однією сутністю».
Наприклад, робити sku primary key просто тому, що він унікальний. Іноді це виправдано, але частіше ви платите складністю міграцій і зв’язків, а виграєте лише гарний рядок у URL. Здорова модель зазвичай тримає технічний id для внутрішніх зв’язків і окреме унікальне бізнес‑поле для доменних сценаріїв.

Помилка №3: писати код так, ніби id зʼявляється завжди одразу після new.
Це призводить до логіки на кшталт «створю об’єкт, одразу виведу id, одразу покладу id в іншу структуру, одразу віддам назовні». За деяких стратегій генерації це просто не працює: id зʼявляється пізніше, і ви починаєте будувати костилі. Правильна звичка — не вгадувати, а розуміти, коли і чому id стає доступним (і перевіряти це за SQL‑логом).

Помилка №4: робити equals()/hashCode() на основі generated id і потім дивуватися поведінці HashSet.
Якщо id генерується не миттєво, то до збереження id=null, після збереження — уже число/UUID, і хеш змінюється. Колекції на хешах не зобов’язані «перебудовуватися й пробачати». Результат виглядає як «об’єкт зник із set», хоча ви тримаєте на нього посилання. Це не містика Hibernate, це чиста математика хеш‑таблиць, помножена на життєвий цикл entity.

Помилка №5: дозволяти зовнішньому DTO «керувати» полем id.
Часто це виглядає як «в апдейт прийшов id, я його просто сетнув у entity». У кращому разі ви отримаєте виняток, у гіршому — заплутаєте семантику new/existing і отримаєте дивну поведінку merge/dirty checking. Зрілий стиль — використовувати id як вказівник, кого змінювати, а не як дані, які змінюються.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ