Путеводитель по ID для JPA сущностей. Часть 1: ID, генерируемые на сервере

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Разработка инструментария – очень познавательное занятие, потому что заставляется задуматься над теми вещами, которые в процессе разработки иногда не замечаешь. Казалось бы, создание @Id атрибута в JPA – рутинное занятие и каждый разработчик может сделать айдишник, даже не включая мозг. Однако, когда начинаешь углубляться в эту тему и пытаться разработать инструмент, который не только помогает писать код для определения ID, но и подсказывает потенциальные проблемы, то всплывает много интересного. И наши соображения, которыми мы руководствовались при разработке JPA Buddy, вылились в этот цикл статей. 

Введение. Зачем нам вообще нужны ID в JPA сущностях?

Спецификация JPA гласит, что сущность – это обычный Java класс, который удовлетворяет следующим условиям: 

  1. Должен быть помечен аннотацией @Entity.

  2. В классе должен быть конструктор без аргументов.

  3. Класс не должен быть закрытым (final).

  4. И обязательно должно быть поле (или несколько полей) ID с соответствующей аннотацией.

Как видим, поле ID – обязательное. Но почему?  

Реляционные базы данных не требуют обязательного определения первичного (и уникального) ключа для таблиц. Да и при доступе к базе через JDBC от нас не требуется знать, есть ли на таблицах первичные ключи или нет. Все, что мы делаем при работе через JDBC – работаем с БД и использованием SQL – языка запросов для баз данных. Чтобы получить данные, разработчик запускает SELECT запрос, которые возвращает некоторый набор значений. Чтобы сохранить данные, нужно написать и запустить оператор INSERT и т. д. Важный момент здесь: на таком уровне работы с базой данных нет прямой связи между записями в таблицах в базе данных и объектами в приложении. Преобразование выбранных данных в объекты выполняется, как правило, вручную и является частью бизнес-логики приложения. 

JPA проповедует другую философию: есть сущности – Java классы, экземпляры которых жестко привязаны к записям в таблицах БД. Ровно поэтому спецификация требует, чтобы разработчики указывали ключевые поля. Это нужно, чтобы делать однозначное сопоставление между объектом в приложении и записью в таблице. Такой подход помогает скрыть низкоуровневые операции с базой данных. Разработчик выбирает из базы объекты, модифицирует их и сохраняет, при этом ORM фреймворк точно знает, с какой конкретно записью БД происходит работа. В итоге это должно помогать сконцентрироваться разработчикам на реализации бизнес-логики, в то время как ORM фреймворк берет на себя все рутинные операции. И, как видно, наличие уникально идентификатора объекта – неотъемлемая часть этого процесса. 

Замечание: В общем-то, поле ID не должно отображаться на первичный ключ таблицы. Нужно делать отображение на какой-то столбец (столбцы), который уникально идентифицирует строку. Но дальше в статье термины «ID» и «первичный ключ» будут употребляться как взаимозаменяемые. 

Типы ID - что у нас есть?

Итак, нам нужно определить ID для нашей сущности. Что у нас есть в наличии?

Первое: ID может быть «простым» и «составным». «Простой» ID – это одно поле в сущности, а «составной» - это отдельный класс с набором полей для идентификации экземпляра сущности.

В подавляющем большинстве случаев мы используем простые ID для наших сущностей. Их можно генерировать автоматом (суррогатные ID) и это самый распространенный способ присвоения значений для первичного ключа. Генерацией может заниматься сервер БД или это может происходить в приложении. У этих подходов есть как плюсы, так и минусы.

В этой статье мы сфокусируемся на ID, генерируемых в БД. Для простоты изложения мы будем использовать самую распространенную имплементацию JPA – Hibernate – во всех примерах. Другие JPA имплементации будут упомянуты явно, при необходимости.

Генерация ID – зачем весь этот шум?

Генерация ID обычно случается только однажды – во время сохранения сущности в базу данных. Давайте предположим, что у нас есть приложение, которое работает эксклюзивно с БД и не создает большого количества данных, скажем, не больше 100 записей в секунду. В этом случае, мы, наверное, можем использовать любой способ генерации ID. Приложение для работы со списком названий стран, наверное, будет хорошим примером, новые страны в мире не так часто появляются. А что насчет сохранения данных с электросчетчиков? Если у нас есть 100 счетчиков, которые посылают нам данные каждый час, то можем смело сохранять один замер раз в 36 секунд. Небольшая нагрузка, очевидно. А если взять 1000 счетчиков? Десять тысяч? И сохранять с них данные каждые 10 минут? Сколько будет стоить бизнесу поменять способ генерации ID?

На практике, приложения имеют тенденции к росту, как и бизнес. Собственно, поэтому выбор стратегии генерации первичных ключей – важная тема, которая позволит вам избежать болезненных миграций в будущем. Далее будет употребляться термин «производительность», и, хотя мы не Facebook и не Twitter, и не сохраняем миллионы сущностей в секунду, но это не значит, что нам не нужно думать о выборе стратегии генерации ID заранее. Это позволит избежать некоторых неудобство в будущем.

Как работает генерация ID по умолчанию

Самый простой способ определить генерируемый ID для JPA сущности – поставить аннотации @Id и @GeneratedValue над нужным полем. Нам даже не нужно выставлять никакие параметры генерации, значения по умолчанию отработают нормально и в поле будет присваиваться уникальное значение каждый раз при сохранении.

@Table(name = "pet")
@Entity 
public class Pet {     
  @Id     
  @GeneratedValue     
  @Column(name = "id", nullable = false)     
  private Long id; 

}

В мире есть два типа значений по умолчанию: те, которые не надо трогать и те, которые обязательно надо поменять. Значения по умолчанию обычно выставляются так, чтобы с ними все работало. Но так ли они хороши в нашем случае? Давайте посмотрим на значения параметров аннотации @GeneratedValue:

public @interface GeneratedValue {
    GenerationType strategy() default AUTO;
  
    String generator() default "";
}

Что у нас тут? Стратегия генерации  - AUTO. Это означает, что JPA провайдер решает, как генерировать уникальные ID для нашей сущности. И сразу возникает вопрос: а достаточно ли хорош этот способ? Давайте сначала разберемся, какие вообще есть стратегии генерации.

Стандарт JPA, помимо AUTO, определяет ещё три стратегии:

  • IDENTITY - используется встроенный в БД тип данных столбца -identity - для генерации значения первичного ключа.

  • SEQUENCE - используется последовательность – специальный объект БД для генерации уникальных значений.

  • TABLE - для генерации уникального значения используется отдельная таблица, которая эмулирует последовательность. Когда требуется новое значение, JPA провайдер блокирует строку таблицы, обновляет хранящееся там значение и возвращает его обратно в приложение. Эта стратегия – наихудшая по производительности и ее желательно избегать. Больше про этот подход можно узнать из документации, здесь мы его рассматривать не будем.

Как написано в руководстве разработчика, если мы выставляем тип ID, отличный от UUID (Long, Integer и т. д.) и используем стратегию генерации AUTO, то Hibernate (начиная с версии 5.0) сделает следующее:

  • Попробует использовать стратегию SEQUENCE.

  • Если БД не поддерживает последовательности (например, MySQL), то будет использоваться стратегия TABLE (или IDENTITY, в версии до 5.0).

Почему Hibernate сначала пытается использовать SEQUENCE? Основная причина – производительность. Как уже говорилось, TABLE обладает наихудшей производительностью (хотя самой большой совместимостью с СУБД). Есть статья, в которой автор сравнивает разные стратегии генерации ID, сохраняя 10 000 сущностей в БД. Ему удалось добиться уменьшения времени со 185 секунд до 4.3, когда он начал использовать SEQUENCE вместо IDENTITY и включил некоторые оптимизации (такие, как пакетная обработка данных). Таким образом, обе стратегии: IDENTITY и TABLE будут работать, но производительность пострадает.

Самое интересное, что даже SEQUENCEв конфигурации по умолчанию не будет работать быстро, производительность будет близка к IDENTITY. Это происходит из-за того, что используется всего одна последовательность, а ее параметры не позволяют использовать кэширование ID средствами Hibernate. Мы подробнее поговорим об этом чуть позже.

Итого: если для генерации ID оставить значения по умолчанию, то, скорее всего, это негативно повлияет на производительность приложения. Для «боевого» применения будет лучше более тонко настроить стратегии генерации.

Sequence: как правильно готовить?

Стратегия SEQUENCE использует специализированный объект БД – последовательность (sequence) для генерации уникального значения ID сущности и это значение присваивается до сохранения (и это важно!) сущности в БД. Такой алгоритм обеспечивает возможность пакетного (batch) сохранения данных. Это происходит за счет того, что приложению не надо перезапрашивать из базы значение ID после сохранения каждой записи, как это происходит в случае использования identity столбцов, триггеров и т. д.

А если ничего не настраивать?

Чтобы использовать последовательность для генерации первичного ключа, все, что нам нужно – написать код, представленный ниже. По факту это то, что сделает стратегия `AUTO`, если наша БД поддерживает последовательности.

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
@Column(name = "id", nullable = false)
private Long id;

Если мы включим автоматическое создание схемы БД и показ выполняемых запросов в Hibernate, то мы увидим что-то такое:

create sequence hibernate_sequence start 1 increment 1;

JPA провайдер будет использовать только эту последовательность для всех операторов `INSERT`, сгенерированных для сущностей с настройками стратегии `SEQUENCE` по умолчанию. И это может привести к неприятным последствиям.

Во-первых, значения в последовательности могут просто
закончиться. В большинстве БД максимальное количество значений
последовательности – 2^63-1, исчерпать такое количество значений, конечно,
сложновато. Но все возможно, если ваше приложение генерирует огромное
количество данных; например, у вас огромное количество IoT устройств, постоянно
присылающих замеры или баннерная сеть, которая генерирует миллионы
кликов-событий каждый день.

2^63-1 – большое число

(i) Если мы будем сохранять 10.000 объектов в секунду, нам потребуется около 29 миллионов лет, чтобы исчерпать последовательность. Это означает, что в большинстве приложений можно не беспокоиться, что последовательность закончится, но помнить о конечности набора значений все равно нужно.

Во-вторых, будет страдать производительность. По умолчанию, мы увеличиваем значение последовательности на 1 и это не позволяет использовать оптимизацию выборки значений в Hibernate. Для каждого сохраняемого объекта ORM вынужден делать запрос в базу. Например, если мы попробуем сохранить пару объектов, то в логах Hibernate увидим примерно следующее:

select nextval ('hibernate_sequence')
insert into pet (name, id) values (?, ?)
select nextval ('hibernate_sequence')
insert into pet (name, id) values (?, ?)

Можно заметить, что у нас появляются накладные расходы: для каждой операцию вставки мы делаем дополнительную выборку из БД. И это, очевидно, будет влиять на скорость вставки данных в базу.

Итого: Значения по умолчанию для стратегии `SEQUENCE` неплохо работают для приложений, которые не генерируют большой объем данных. Если нам нужно больше производительности, и меньше проблем с единственной последовательностью, то нужно менять параметры генерации ID.

Что мы можем настроить?

Начнем с простого: назначим отдельную последовательность для генерации ID для сущности.

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "pet_seq")
@Column(name = "id", nullable = false)
private Long id;

Если опять заглянем в логи Hibernate, увидим следующий SQL:

create sequence pet_seq start 1 increment 50

Если не используется последовательность по умолчанию, Hibernate «кэширует» значения ID при выборке из последовательности. Идея в том, чтобы при одном запросе «захватить» какой-то диапазон значений и потом назначать значения ID из этого диапазона. По умолчанию, Hibernate захватывает 50 значений.

Оптимизация работает так:

  • Шаг 1: Hibernate выполняет один SELECT из последовательности и получает текущее значение.

  • Шаг 2: Если это значение равно начальному значению, с которым создавалась последовательность, то Hibernate выбирает следующее значение ID, и назначает это значение верхней границей диапазона. А значение, выбранное на шаге 1 – нижней границей. В противном случае переходим к шагу 4.

  • Шаг 3: Вставляем данные, назначаем ID, начиная с нижнейграницы до верхней, пока не закончатся значения.

  • Шаг 4: Выбираем следующее значение ID из последовательности (оно больше, чем начальное). В этом случае Hibernate вычисляет доступный диапазон, используя параметр allocationSize. Нижняя граница = ID – allocationSize+1, верхняя = ID. Дальше переходим к шагу 3.

Итого, мы делаем только два запроса `SELECT` чтобы сохранить первые 50 сущностей. Для следующих 50-ти нам нужно будет сделать только один дополнительный запрос. Если опять заглянем в логи Hibernate, то увидим:

select nextval ('pet_seq'); //получаем 1 – начальное значение, нужно ещё одно
select nextval ('pet_seq'); //получаем 51 – верхнюю границу диапазона
insert into pet (name, id) values (?, ?);// id=1
insert into pet (name, id) values (?, ?);//id=2
//сохраняем остальные 48 сущностей
select nextval ('pet_seq'); //выбираем 101, это верхняя граница, нижняя равна 101 – 50+1 = 52
insert into pet (name, id) values (?, ?);//id=52
//и т. д. 

В этом подходе есть один недостаток: если мы закрываем сессию с базой данных (приложение перезапустилось или мы пересоздаем entity manager), то неиспользованные значения будут потеряны навсегда. Хороший пример такого короткоживущего приложения – serverless lambda. Если сохраняем только одну сущность за запуск, то остальные 49 значений теряем. Это может привести к более быстрому окончанию значений в последовательности, поэтому для коротких сессий нужно уменьшать шаг последовательности, чтобы не терять много ID.

Для настройки параметров генерации ID, например, для уменьшения шага последовательности, используется аннотация @SequenceGenerator. Эта аннотация позволяет нам задать необходимые параметры для генерации последовательности в БД: название, начальное значение и шаг. Код ниже показывает, как создать последовательность с шагом 20.

@Id
@SequenceGenerator(name = "pet_seq", 
        sequenceName = "pet_sequence", 
        initialValue = 1, allocationSize = 20)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "pet_seq")
@Column(name = "id", nullable = false)
private Long id;

Hibernate сгенерирует и выполнит вот такой вот SQL, если последовательности нет в БД:

create sequence pet_sequence start 1 increment 20

При определении генератора таким способом нужно помнить следующее: если мы хотим использовать существующую последовательность, то параметр allocationSize должен в точности совпадать с шагом последовательности. Если в Hibernate включена валидация схемы, то приложение не запустится, если значения не совпадают.

Отключить валидацию схемы можно, установив параметр hibernate.id.sequence.increment_size_mismatch_strategy в значение LOG или FIX.

Для значения LOGHibernate проигнорирует несовпадение значений параметров генератора и последовательности, и это может привести к нарушениям уникальности при генерации ID. Например, если для генератора значение allocationSize равно 20, а в последовательности параметр increment равен 1, то мы получим что-то такое:

select nextval ('pet_seq'); // выбираем 1 начальное значение, нужно выбрать следующее
select nextval ('pet_seq'); //выбираем 2 – конечное значение диапазона
insert into pet (name, id) values (?, ?);// id=1
insert into pet (name, id) values (?, ?);//id=2
//Закончился диапазон, выбираем следующее
select nextval ('pet_seq'); //выбираем 3 конечное значение, считаем 3 – 20 + 1 = -16 - начальное
insert into pet (name, id) values (?, ?);//id=-16
insert into pet (name, id) values (?, ?);//id=-15
//Новая сессия
select nextval ('pet_seq'); //выбираем 4 – конечное значение, считаем 4 – 20 + 1 = -15 - начальное
insert into pet (name, id) values (?, ?);//id=-15 нарушение уникальности

Если поставим значение параметра - FIX, то в этом случае Hibernate автоматически поменяет значение allocationSize и выставит его равным шагу последовательности, т.е. для нашего случая выше будет установлена 1.

Еще одна штука, которую можно использовать при определении @SequenceGenerator - можно переиспользовать одну последовательность для разных сущностей. Нужно просто указать одинаковые значения в sequenceName для разных генераторов.

//ID Definition for ‘Pet’ entity
@Id
@SequenceGenerator(name = "pet_seq", sequenceName = "common_sequence")
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "pet_seq")
@Column(name = "id", nullable = false)
private Long id;

//ID Definition for ‘Owner’ entity
@Id
@SequenceGenerator(name = "owner_seq", sequenceName = " common_sequence ")
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "owner_seq")
@Column(name = "id", nullable = false)
private Long id;

Итого: явное задание генераторов позволяет нам:

  1. Пользоваться оптимизацией выборки ID для лучшей производительности.

  2. Настраивать размер пула ID в зависимости от количества вставляемых данных.

  3. Использовать одну и ту же последовательность для ID разных сущностей.

И, похоже, что стратегия SEQUENCE практически идеальна. Но и на солнце бывают пятна…

Несколько клиентов для одной базы – это проблема?

Несмотря на то, что SEQUENCE использует БД для генерации ID, но присваивание значений производится в коде приложения. С одной стороны, это дает нам оптимизации и пакетные вставки данных, но с другой стороны другие приложения, которые используют ту же самую БД, даже не подозревают, что им нужно использовать определенную последовательность для генерации значений первичных ключей.

Это может привести к тому, что другие приложения назначают ID в соответствии со своими соображениями и полностью игнорируют последовательность. В итоге, мы можем запросто получить нарушения уникальности первичного ключа, как в нашем приложении, так и в соседних.

Итого: Стратегия SEQUENCE для генерации ID может не очень хорошо себя показывать, если несколько клиентов работают с одной и той же базой. В таких случаях единственно надежный способ – генерация ID при вставке в базу. И здесь гораздо лучше работает стратегия IDENTITY.

Identity: за и против

IDENTITY - это, наверное, самая распространенная стратегия среди разработчиков, использующих MySQL. Но, кроме MySQL, многие СУБД поддерживают аналогичный механизм: специальный тип данных столбца таблицы, которые автоматом назначает уникальные значения вставляемым строкам. Так что эту стратегию можно видеть в большом количестве приложений. Иногда разработчики руководствуются принципом «работало на предыдущих проектах» при выборе этой стратегии в новых приложениях. Мало кто хочет менять привычки, которые не подводили. Когда мы определяем стратегию IDENTITY для генерации значений ID, мы получаем надежный способ обеспечения уникальности первичного ключа, (почти) не зависящий от клиентов БД.

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;

Для каждого оператора INSERT, база данных автоматически генерирует уникальное значение ID поля. Хотя в некоторых СУБД, если мы указываем значение ID явно, то оно не будет перезаписано, так что, в теории, несколько клиентов могут получить нарушение уникальности, если они назначают значения ID явно.

Поведение стратегии IDENTITY схоже с SEQUENCE, если мы определим шаг последовательности равным 1. Но есть одно значительное различие. Нужно помнить, что для назначения ID данные должны быть физически вставлены в таблицу. И только после того, как оператор INSERT выполнился, мы сможем узнать значение ID. Следовательно, JPA провайдер должен каким-то образом вернуть это значение обратно в приложение после сохранения объекта в БД.

Возникает вопрос: а как ORM выбирает значение ID после вставки? Если JDBC драйвер БД поддерживает JDBC v.3 (а современные БД поддерживают), это делается автоматически. JPA провайдер неявно вызывает метод Statement.getGeneratedValues(), который и возвращает сгенерированное значение. Под капотом при вставке данных вызывается примерно такой SQL:

insert into pet (name) values (‘Buddy’) RETURNING *

А что будет, если у нас старый драйвер? В этом случае JPA провайдер может попытаться выполнить дополнительный запрос самостоятельно (а зачастую нам придется делать это вручную), чтобы на основании сохраненных данных получить значение ID. Ну вот, например, как это может выглядеть для старой версии PostgreSQL. В PostgreSQL тип IDENTITY эмулируется через последовательность, но принцип будет примерно таким же в случае использования других СУБД:

insert into pet (name) values (?)
select currval('pet_id_seq')
insert into pet (name) values (?)
select currval('pet_id_seq')

Использование IDENTITY не позволяет использовать пакетную вставку данных. Поскольку ORM должен получать ID после каждой вставки (вне зависимости от версии JDBC драйвера), то он (ORM) разбивает сохранение массива объектов на отдельные вызовы INSERT. Мы просто не можем себе позволить отправить список объектов на сохранение в БД и получить массив ID взамен. БД не гарантирует порядок вставки для пакетных операций, так что ID могут быть не в том порядке, в котором был наш список объектов. Следовательно, нельзя будет надежно сопоставить значение ключа с объектом, а это есть критическая часть работы JPA. Так что единственный выход – сохранять по одному объекту.

Итого: стратегия IDENTITY проста в использовании и позволяет надежно получать уникальные ID вне зависимости от клиентов, использующих БД.

С другой стороны, данная стратегия не позволяет использовать пакетное сохранение данных, а производительность обычных операций вставки также может быть чуть ниже, чем при использовании последовательностей. Следовательно, рекомендуется использовать IDENTITY для случаев, когда мы сохраняем небольшой объем данных или когда есть риск, что одна и та же база данных используется несколькими приложениями, которые могут туда писать данные.

Подведем итоги: Identity vs Sequence vs остальное

Итак, какую же стратегию выбрать для генерации ID для наших JPA сущностей? Вот пара рекомендаций.

В первую очередь рассмотрите `SEQUENCE`. Эта стратегия обеспечивает самую высокую производительность в сравнении с остальными. Также нужно обратить внимание на следующее:

  1. Хорошей практикой считается задание отдельной последовательности для каждой сущности. Нужно избегать использования значений по умолчанию в настройках.

  2. Используйте `@SequenceGenerator` для тонкой настройки генерации значений ID и параметров генерируемой последовательности.

  3. Шаг последовательности задавайте в соответствии с нагрузкой приложения.

Использование `IDENTITY` оправдано, если:

  1. СУБД не поддерживает последовательности.

  2. Создается небольшой объем данных.

  3. В базу данных могут писать другие приложения.

По возможности старайтесь не использовать стратегии TABLE and AUTO, с ними будет наихудшая производительность.

Конечно, список всех возможных ID не только исчерпывается простыми полями, значения которых генерируются на сервере БД. В следующих статьях мы обсудим генерацию ID в коде приложения (особенно UUID). Также, хотя это и не очень распространенный случай, поговорим о составных ID, там тоже есть что обсудить.

Источник: https://habr.com/ru/company/haulmont/blog/653843/


Интересные статьи

Интересные статьи

Несколько слов об автореЧтобы был понятен контекст, нужно сказать несколько слов об авторе. Я с 2004 года занимаюсь разработкой программного обеспечения, начав с позиции junior java developer'а в аутс...
В этой статье я опишу как написать тесты для компонентов Angular приложения, используя инструменты которые предлагает нам Angular из коробки.
От Selectel: это вторая часть перевода статьи про отпечатки браузера (тут можно прочитать первую). Сегодня поговорим о законности сбора сторонними сервисами и сайтами отпечатков бра...
Заключительная часть о том, как концепция Jobs-to-be-Done меняет принципы создания и улучшения IT-продукта. Третья часть перевода книги «Intercom про Jobs-to-be-Done». Главы с седьмой по девя...
В первой части я немного рассказал о приложении RF & Microwave Toolbox. Во второй части расскажу о не менее интересном приложении, которое позволяет проектировать СВЧ устройства (преимуществе...