1. Идентификатор глубже, чем @Id
Когда вы впервые видите JPA‑сущность, @Id выглядит как скучная обязательная наклейка: “без неё Hibernate не заведётся”. Но в deep‑dive курсе эта наклейка внезапно становится половиной сюжета: от идентификатора зависит, как Hibernate узнаёт “кто есть кто”, когда делает SQL и почему ваши объекты иногда ведут себя как персонажи сериала с потерей памяти.
На бытовом уровне кажется, что идентификатор нужен только “чтобы читать из таблицы”: ну вот есть product.id, по нему мы делаем SELECT. На практике идентификатор — это опорная точка, вокруг которой построена вся внутренняя дисциплина ORM. Именно по @Id Hibernate решает, можно ли считать два объекта “тем же самым” товаром, как устроить first-level cache, как корректно выполнить merge() для detached‑данных (мы уже это проходили), и почему повторный find() в одной транзакции возвращает тот же самый объект.
Ещё один важный момент: идентификатор — это не только история “про базу”. Это история про вашу память, коллекции, equals()/hashCode() и даже про то, как вы пишете сервисные сценарии. Идентификатор отвечает на три практических вопроса, которые мы сегодня будем держать в голове: кто создаёт значение, когда оно появляется, и несёт ли оно предметный смысл или остаётся чисто техническим ключом.
2. Виды идентичности: объект, БД, бизнес
Очень легко запутаться, потому что слово “идентификатор” звучит как “что-то уникальное”, а уникальное, кажется, должно быть одно. На практике в приложении одновременно живут как минимум три разных “идентичности”, и если их не развести, вы рано или поздно получите странные баги, которые тяжело объяснить коллегам без жестов и нервного смеха.
Первая — объектная идентичность в Java. Это ответ на вопрос: “Это тот же самый объект в памяти?” и проверяется самым честным оператором ==. Если a == b, то это буквально один и тот же экземпляр, просто у него два имени. Hibernate эту идентичность поддерживает внутри persistence context (identity map), но сам по себе JVM‑мир вообще не обязан думать о базе данных.
Вторая — табличная (database) идентичность. Это первичный ключ строки в таблице. В PostgreSQL это обычно BIGINT, иногда UUID, иногда (в legacy) составной ключ. В реляционной модели “кто ты такой” определяется именно первичным ключом. У строки может поменяться 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 / 1st-level cache)"]
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 как указатель, кого менять, а не как данные, которые меняются.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ