1. Что сравниваем: стратегии id и SQL
Мы уже разложили id на несколько вопросов, и сейчас сузим фокус до самого болезненного: кто вообще выдаёт значение и в какой момент Hibernate успевает его узнать. Когда смотришь на аннотацию @GeneratedValue, хочется думать: “Ну это же просто как поле заполняется, какая разница?”. Но Hibernate воспринимает идентификатор как часть своей внутренней математики: пока у объекта нет понятной идентичности, его сложнее учитывать в persistence context, сложнее выстраивать порядок INSERT‑ов и сложнее гарантировать корректную синхронизацию графа. Поэтому стратегия id — это не “украшение модели”, а реальный переключатель поведения.
В этой лекции мы сравним четыре практические стратегии именно с точки зрения insert‑потока: кто генерирует значение и когда Hibernate может его узнать. Вам важно удержать две оси, потому что дальше почти все эффекты (ранний INSERT, невозможность batching, дополнительные round‑trip’ы к базе) окажутся просто следствием этих двух вопросов.
| Стратегия | Кто генерирует id | Когда id становится известен Hibernate |
|---|---|---|
| IDENTITY | база данных | только во время INSERT |
| SEQUENCE | база данных (sequence) | до INSERT (Hibernate может заранее взять nextval) |
| UUID | Java (приложение / Hibernate) | до INSERT (без обращения к БД) |
| assigned id | ваш код | ещё до persist() (если вы не забыли) |
Чтобы не быть голословными, ниже мы будем смотреть на одну и ту же идею через три линзы: что происходит в памяти, как выглядит SQL‑трейс и почему это влияет на “чувство контроля” над вставками.
2. IDENTITY: id из базы → INSERT сразу
IDENTITY часто выглядит как самый “естественный” вариант: колонка автоинкрементится, база сама выдаёт число, и жизнь прекрасна. Но у Hibernate есть маленькая проблема: чтобы поставить id в Java‑объект, ему нужно выполнить реальный INSERT и получить сгенерированное значение. А значит, он теряет главное удовольствие ORM‑подхода — возможность спокойно складывать INSERT‑ы в очередь и отправлять их одной пачкой на flush().
На практике это означает, что persist() для IDENTITY очень часто “внезапно” приводит к SQL раньше, чем вы ожидаете. И это не каприз ORM, а логическое следствие: Hibernate не может сказать объекту “твой id = …”, пока база не сказала это Hibernate. Представьте очередь в МФЦ: пока вам не выдали талончик с номером, вы не можете “официально” участвовать в очереди. С IDENTITY талончик выдаётся только на входе, и каждый человек обязан подойти к стойке отдельно.
Мини‑пример entity с IDENTITY
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
class LabIdentityProduct {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // id появится только после реального INSERT в БД
private Long id;
private String name; // обычное поле: само по себе не влияет на момент генерации id
}
Как это выглядит в SQL (упрощённо)
Для PostgreSQL типичный паттерн — insert ... returning id (или получение generated keys через JDBC), и вы увидите примерно такую идею:
insert into lab_identity_product (name) values (?) returning id;
Важно не конкретное “returning”, а факт: вставка должна случиться, чтобы id стал известен.
Почему IDENTITY плохо сочетается с batching
Мы не уходим в настройки batching (это будет отдельная большая история), но смысл можно понять прямо сейчас. Batch insert — это когда Hibernate пытается отправить несколько INSERT как “пакет” одинаковых statement’ов. При IDENTITY после каждого INSERT Hibernate обязан получить конкретный id и записать его обратно в конкретный объект. В результате вставки становятся последовательными и “синхронными”: вставили — получили id — вставили — получили id. Даже если вы включили batching флагом, IDENTITY в большинстве случаев сделает так, что insert batching для сущности не заработает, потому что механика получения id это ломает.
Нюанс, который часто удивляет
Если вы привыкли думать: “SQL уйдёт на flush/commit”, то с IDENTITY вы будете регулярно ловить ощущение “Hibernate предал меня”. На самом деле он просто честно выполняет контракт генерации id. Поэтому, когда вы анализируете SQL‑лог и видите INSERT раньше, чем ожидали, один из первых вопросов должен быть: “А какая у нас стратегия id?”.
3. SEQUENCE: id заранее, INSERT позже
После IDENTITY стратегия SEQUENCE обычно ощущается как “ORM‑friendly”. Причина простая: sequence — это отдельный объект БД, который умеет выдавать новые значения (nextval) без вставки строки. Hibernate может взять id заранее, назначить его сущности в памяти, пометить сущность как managed, и уже потом, на flush(), отправить INSERT. Получается более управляемый поток: id есть, а строки в таблице ещё может не быть.
Если провести аналогию, то SEQUENCE — это как взять номерок в электронной очереди в приложении ещё дома. Номер уже у вас, вы уже “участник процесса”, но к окну вы подойдёте позже, когда придёте в офис. Hibernate от этого счастлив: он может собирать пачку вставок и отправлять их более эффективно.
Мини‑пример entity с SEQUENCE
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
@Entity
class LabSequenceProduct {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "lab_seq_product_seq") // id можно получить до INSERT
@SequenceGenerator(
name = "lab_seq_product_seq",
sequenceName = "lab_seq_product_seq" // имя sequence в БД (её нужно создать миграцией)
)
private Long id;
private String name;
}
Обратите внимание: в реальном проекте с Flyway вы обязаны создать sequence миграцией. Hibernate не должен “додумывать схему” за вас.
Фрагмент миграции Flyway (идея)
create sequence lab_seq_product_seq start with 1 increment by 1;
create table lab_sequence_product (
id bigint not null primary key,
name text not null
);
Здесь важная мысль: колонка id не обязана иметь default nextval(...). Hibernate сам может вызывать nextval перед INSERT (и обычно так и делает). Наличие default — отдельный выбор, но для понимания insert‑потока это не принципиально.
Как это выглядит в SQL‑трейсе
Часто вы увидите два шага: сначала получение значения из sequence, потом вставку (вставка может быть позже, на flush()):
select nextval('lab_seq_product_seq');
insert into lab_sequence_product (name, id) values (?, ?);
Да, это дополнительный запрос к БД — но он позволяет Hibernate не привязывать id к факту вставки.
Почему SEQUENCE дружит с batching
Поскольку id известен заранее, Hibernate может сформировать пачку INSERT‑ов одинаковой формы и отправить их как batch, а не как серию “вставил — получил id — вставил — получил id”. У него нет необходимости “останавливаться” после каждой строки ради id.
Есть ещё один тонкий плюс: когда вы создаёте граф сущностей и вам нужно проставлять ссылки (например, “ребёнок ссылается на родителя”), заранее известный id иногда упрощает жизнь. Не путайте это с тем, что Hibernate “везде использует id для связей” — это скорее про внутреннюю организацию и предсказуемость.
Про “пропуски” в номерах
Интуитивно хочется: “Sequence — значит 1,2,3,4 без пропусков”. На практике пропуски — нормальны и неизбежны. Транзакция может откатиться, приложение может упасть, Hibernate может брать диапазоны значений “про запас” (оптимизации). Поэтому последовательность — это генератор уникальных чисел, а не устройство для красивых “идеально сплошных” порядковых номеров для бухгалтерии.
4. UUID: id без базы, цена в индексах
После числовых id UUID часто воспринимается как “современный и удобный” вариант: можно генерировать идентификаторы без обращения к базе, удобно для распределённых систем, можно иметь id до вставки строки, и вообще “всё выглядит взрослым”. В реальности это действительно удобный инструмент — но как и любой инструмент, он имеет цену. Причём цена в основном не в Hibernate, а в физике хранения и индексации в базе.
С точки зрения insert‑потока UUID ведёт себя очень похоже на SEQUENCE в одном ключевом моменте: id известен до INSERT. Разница в том, что UUID генерируется на Java‑стороне, то есть лишнего round‑trip’а к базе за nextval нет. Но зато вставляемое значение больше, менее читабельно в логах и обычно хуже для locality индекса (если UUID случайный).
Мини‑пример entity с UUID
import java.util.UUID;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
class LabUuidProduct {
@Id
@GeneratedValue(strategy = GenerationType.UUID) // id генерируется на стороне приложения, без обращения к БД
private UUID id;
private String name;
}
Как выглядит вставка
SQL будет выглядеть как обычный INSERT, просто с UUID‑параметром:
insert into lab_uuid_product (name, id) values (?, ?);
При этом “фокус” происходит ещё до SQL: Hibernate уже назначил id объекту в памяти. Это легко увидеть даже без базы: вы можете напечатать id после persist() и он уже будет.
Почему UUID может быть дороже, чем кажется (на уровне базы)
UUID обычно занимает больше места, чем bigint. Индексы по UUID тяжелее, сравнение UUID тяжелее, а если UUID случайный, вставки будут “раскиданы” по B‑Tree индексу и могут создавать больше page splits. PostgreSQL с типом uuid работает адекватно, но “адекватно” не означает “бесплатно”.
В лабораторном проекте это особенно чувствуется на сущностях, которые участвуют в массовых вставках или очень частых join’ах. Впрочем, не надо делать из этого религию: UUID иногда реально оправдан, особенно если вам нужно формировать id на стороне приложения до вставки, или если сущность живёт на границе интеграции и id приходит не из вашей БД.
5. Assigned id: id задаёт ваш код
Assigned id — это ситуация, когда вы не ставите @GeneratedValue и сами задаёте значение @Id. Для Hibernate это максимально “прозрачная” стратегия: никакой магии, никакой генерации, никакого “а где моя sequence”. Но за прозрачность вы платите ответственностью: вы обязаны гарантировать уникальность, обязаны установить id до persist(), и обязаны понимать, откуда он вообще берётся.
С точки зрения insert‑потока assigned id тоже “удобен”: id известен заранее, Hibernate может складывать INSERT‑ы в очередь, batching возможен (по крайней мере концептуально). Проблема обычно не в производительности, а в человеческом факторе: забыли установить id — получите исключение; установили дубль — получите constraint violation; поменяли источник id — сломали совместимость.
Мини‑пример entity с assigned id
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
class ExternalCatalogItem {
@Id
private String externalId; // ключ приходит извне: вы обязаны заполнить его сами до persist()
private String name;
}
Где чаще всего используют assigned id
Самый здоровый сценарий — когда id реально приходит извне и вы не можете или не хотите подменять его внутренним числом. Например, импорт из внешнего каталога, интеграция с партнёром, данные из legacy‑системы. В таком мире внешний id — не “просто поле”, а часть контракта: он должен быть стабилен.
Как выглядит типичная проблема в коде
ExternalCatalogItem item = new ExternalCatalogItem();
// item.setExternalId("EXT-123"); // забыли
entityManager.persist(item); // бах — и Hibernate не знает, что вставлять в PK
Hibernate не сможет вставить строку, потому что primary key — это не “optional” штука. В зависимости от настроек вы получите исключение либо на persist(), либо на flush(), но смысл один: assigned id требует дисциплины.
6. Сравнение по insert‑потоку
После деталей легко утонуть в нюансах, поэтому полезно собрать сравнение в одну таблицу. Это не “рейтинг лучших стратегий”, а шпаргалка: что вы платите и что получаете именно в вставках.
| Критерий | IDENTITY | SEQUENCE | UUID | assigned id |
|---|---|---|---|---|
| id появляется в объекте | после реального INSERT | до INSERT | до INSERT | до persist() |
| вставка может отложиться до flush | обычно нет (часто “раньше”) | да | да | да |
| дополнительный round‑trip | нет (но есть необходимость вставить) | да (nextval) | нет | нет |
| insert batching (концептуально) | почти всегда плохо | обычно хорошо | обычно хорошо | обычно хорошо |
| простота глазами новичка | кажется простым | “чуть сложнее” | “моднее” | “сам сделаю” |
| типовые риски | ранние INSERT, хуже batch‑поток | пропуски в значениях, нужен DDL | большие индексы, тяжёлые ключи | дисциплина, уникальность, дубли |
Ещё полезна схема, чтобы буквально увидеть момент, где IDENTITY “ломает” отложенную вставку:
flowchart TD
A["persist(entity)"] --> B{"Стратегия id?"}
B -->|IDENTITY| C["Hibernate вынужден сделать INSERT"]
C --> D["БД генерирует id"]
D --> E["id присвоен объекту"]
B -->|SEQUENCE| F["Hibernate делает nextval()"]
F --> G["id присвоен объекту"]
G --> H["INSERT откладывается до flush/commit"]
B -->|UUID| I["Hibernate генерирует UUID в Java"]
I --> J["id присвоен объекту"]
J --> K["INSERT откладывается до flush/commit"]
B -->|assigned| L["Ваш код уже присвоил id"]
L --> M["Hibernate принимает id"]
M --> N["INSERT откладывается до flush/commit"]
Если вы запомните только эту диаграмму — уже будет неплохо. Остальные нюансы — это вариации вокруг неё.
Разница в SQL‑логе Commerce Persistence Lab
Очень хочется превращать обсуждение стратегий в философию и “войны подходов”. Но мы на deep‑dive курсе, поэтому критерий простой: смотрите SQL‑лог и задавайте себе вопрос “почему он ушёл именно сейчас”.
Представьте, что вы в lab‑коде делаете примерно такой сценарий: создаёте три сущности в одной транзакции, но не вызываете flush() вручную и не делаете запросов.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
class InsertFlowScenario {
private final EntityManager em;
InsertFlowScenario(EntityManager em) {
this.em = em;
}
@Transactional
void run() {
// Важно: здесь мы только регистрируем сущности в persistence context.
// Когда именно появится SQL — зависит от стратегии генерации id.
em.persist(new LabSequenceProduct("A"));
em.persist(new LabSequenceProduct("B"));
em.persist(new LabSequenceProduct("C"));
// commit завершит транзакцию и вызовет flush
// (и вот там Hibernate обычно отправит INSERT'ы, если ему не пришлось делать это раньше)
}
}
Если это SEQUENCE, то вы часто увидите: сначала несколько select nextval(...), а insert — ближе к концу транзакции (на flush()). Если это IDENTITY, то вы можете увидеть insert сразу после каждого persist(). И это как раз тот момент, когда у разработчика “ломается интуиция”, если он думал, что persist всегда “без SQL”.
Отдельно полезно смотреть на логи не только глазами “сколько запросов”, но и глазами “какая форма запросов”. IDENTITY часто выдаёт форму “insert returning”, а SEQUENCE — отдельные “select nextval” плюс обычный insert.
7. Типичные ошибки при выборе стратегии id
Ошибка №1: выбирать IDENTITY “потому что так проще” и удивляться ранним INSERT.
Новички часто ставят IDENTITY на любой Long id, потому что это выглядит как минимальный набор аннотаций. Потом они включают SQL‑trace и видят, что вставки уходят раньше flush(), и начинают искать “кто вызывает flush”. Проблема не в flush. Проблема в том, что для IDENTITY вставка — это способ получить id. Если вы хотите более управляемый insert‑поток, нужно осознанно выбирать стратегию, в которой id доступен заранее.
Ошибка №2: ожидать от SEQUENCE красивой нумерации без пропусков.
Секвенс выдаёт уникальные значения, а не “идеальный порядковый номер”. Откаты транзакций, перезапуски приложения и оптимизации выдачи диапазонов значений неизбежно создают gaps. Если вам нужен красивый “номер заказа без пропусков”, это вообще другая бизнес‑задача и решается по‑другому, а не через надежду на sequence.
Ошибка №3: брать UUID как “просто моднее”, не думая о цене хранения и индексов.
UUID удобен тем, что появляется без базы и до вставки. Но это не бесплатный бонус: вы храните более тяжёлые ключи, строите более тяжёлые индексы, и чтения/джойны могут стать дороже. UUID — хороший инструмент, когда он решает реальную инженерную задачу (например, id нужен до INSERT и вы не хотите/не можете обращаться к sequence). Если задача этого не требует — UUID может стать красивым, но дорогим аксессуаром.
Ошибка №4: использовать assigned id и забывать присвоить значение до persist().
Assigned id — это “максимум контроля”, но контроль требует дисциплины. Если id не установлен, Hibernate не сможет вставить строку (PK не может быть null). И это обычно всплывает не сразу: вы написали код, он “почти работает”, а потом в одном сценарии забыли заполнить поле — и всё упало на flush(). С assigned id полезно воспринимать установку идентификатора как часть конструктора/фабрики, а не как “сеттер где‑нибудь потом”.
Ошибка №5: пытаться выводить “человеческий смысл” из технического id.
Когда разработчик видит последовательный Long id, ему хочется использовать его как “номер сущности” и показывать пользователю или завязывать на него бизнес‑логику. Но технический id — это прежде всего механизм идентичности строки, а не бизнес‑атрибут. Как только вы начинаете относиться к нему как к бизнес‑смыслу, вы резко ограничиваете себе свободу менять стратегию, оптимизировать вставки и эволюционировать схему.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ