Что вам нужно знать про семантику List и Set в Hibernate

от автора

Привет, друзья!

На связи Михаил Поливаха, технический лидер проекта Axelix.

В рамках Spring АйО Академии я встречал большое количество похожих вопросов, посвященных правильному дизайну сущностей и т.д.

Я обещался на все эти вопросы ответить в рамках статьи. На самом деле, одной статьи у меня не вышло, скорее всего, будет серия статей. Надеюсь, будет интересно 🙂

Elephant In the Room: AI

Перед тем как начнём, я хочу адресовать слона в посудной лавке.

Многие знания о том, “как надо” можно оформить в Skill для AI Agent-а, и я считаю, что лучше так и сделать. С другой же стороны, я постараюсь сфокусироваться не на том, как “правильно”, а на том, почему правильно именно таким образом. Я до сих пор считаю, что знания и компетенции качественного инженера имеют огромную ценность и в эру AI это остается так.

Я постараюсь пояснить причины того или иного поведения, а не просто сказать, что нужно делать так.

List vs Set

Это извечный вопрос, и на него уже много раз отвечали. Я лишь поясню, почему ответ именно такой, какой он есть.

Начать нужно с того, что когда вы используете List или Set, вы неявно коммуницируете для Hibernate определенную семантику. Hibernate, поскольку является ORM фреймворком, работает поверх SQL. А в SQL таблица без ограничений уникальности это по сути “Bag” — коллекция, где мы не можем гарантировать отсутствие дубликатов, и где отсутствует чёткий порядок элементов.

В Java отсутствует как таковой тип для “Bag”, поэтому Hibernate предполагает, что, когда вы используете List без @OrderColumn, вы имеете в виду коллекцию, где вы:

  1. Не гарантируете никакой порядок элементов

  2. И к тому же, где вы заявляете, что не гарантируете уникальность элементов.

Это всё мы уже в рамках Spring АйО Академии разбирали, я думаю, для многих тут ничего нового нет, но теперь смотрите.

List vs Set. ManyToMany

В интернете пишут, что в ManyToMany надо использовать Set всегда, потому что в случае с листом Hibernate делает следующее:

  • Удаляет все элементы из связующей таблицы для данной сущности

  • Делает re-INSERT оставшихся

Я хочу пояснить, почему Hibernate это делает, и почему по-другому он не может. Давайте рассмотрим many-to-many связку Developer <-> Project. Рассмотрим оба варианта — и List и Set, и как каждый из них воспринимает Hibernate:

@Entityclass Developer {    @Id    private Long id;    @ManyToMany    @JoinTable(        name = "developer_project",        joinColumns = @JoinColumn(name = "developer_id"),        inverseJoinColumns = @JoinColumn(name = "project_id")    )    private Set<Project> projects = new HashSet<>();}

И таблицу связи:

CREATE TABLE developer_project(    developer_id bigint not null,    project_id bigint not null)

На первый взгляд всё просто. И теперь представим себе, что как раз мы хотим отвязать того или иного Developer-а от того или иного проекта. По сути дела, должна уйти одна запись из таблицы developer_project.

Ключевой вопрос, который я попрошу вас держать сейчас в голове:

Как дизайнеры ORM фреймворка, можем ли мы с вами однозначно идентифицировать (!) конкретную строку, которую нужно удалить из join-таблицы?

Сценарий 1: Set в ManyToMany

Семантически, Set запрещает дубликаты. Используя Set вы говорите — в этой связующей коллекции не может быть двух записей (developer_id=1, project_id=99), то есть, выбирая Set, вы сигнализируете Hibernate, что пара (developer_id, project_id) представляет уникальную связь.

Важный нюанс, без которого Set в Hibernate легко превратить в тыкву: уникальность элементов в Set определяется через equals/hashCode ваших сущностей. Если эти методы сделаны “как получилось”, Hibernate может и не увидеть, что две ссылки — это одна и та же связь. Тема “правильного” equals/hashCode для Java объектов вообще тема отдельной докторской работы.

Допустим, у разработчика были проекты [A, B, C], и мы удалили B:

developer.getProjects().remove(projectB);

В таком случае, Hibernate может безопасно сделать:

delete from developer_project where developer_id = 1 and project_id = :projectBId;

Тут всё просто. Это ровно одна операция на конкретную связь. Hibernate понимает, что, поскольку используется Set, то удалять именно ту запись, которую вы передали по ёё ключу — это безопасно — удалится именно та запись, которую вы попросили.

Сценарий 2: List без @OrderColumn (Bag)

Теперь тот же пример, но коллекция:

private List<Project> projects = new ArrayList<>();

Для Hibernate это семантика Bag, т.е. порядок не детерминирован, а ещё допустимы дубликаты. То есть, Hibernate предполагает (из-за использования List), что ваша связующая таблица может иметь такое содержимое:

(developer_id=1, project_id=10)(developer_id=1, project_id=99)(developer_id=1, project_id=99)

И вот вы на List-е в Java коде выполняете операцию удаления привязки разработчика к проекту.

getProjects().remove(project);

Hibernate конечно же запроксировал List. Обратите внимание — remove всегда удаляет конкретный элемент из List-а. А теперь представьте себе, что мы с вами пишем реализацию этого прокси. Безопасно ли отправлять в БД запрос?

delete from developer_project where developer_id = ... and project_id = ...;

Конечно же нет! Потому что используя List, нам явно сказали — могут быть дубли! Мы можем удалить не одну строку, а N! Кроме того, у элементов в Bag ещё и порядка нет, т.е. мы с вами, реализуя прокси, не можем просто посмотреть на абстрактный “порядковый номер” элемента, чтобы сделать точечное удаление!

Именно поэтому Hibernate выбирает единственно безопасную стратегию для неиндексированного Bag — у нас просто нет выхода:

  1. Удалить все связи конкретного родителя:

delete from developer_projectwhere developer_id = 1;
  1. Пройти по текущему содержимому Java-коллекции и вставить обратно:

insert into developer_project(developer_id, project_id) values (1, 10);insert into developer_project(developer_id, project_id) values (1, 99);

С точки зрения корректности это безопасно — мы удалим только один элемент и выполним семантику List. С точки зрения производительности это просто торпеда в борт, особенно на больших коллекциях.

То есть проблема не в “Плохом Hibernate”, а в том, что, используя List, была выбрана модель, где:

  • в памяти дубликаты валидны

  • в таблице строка не имеет уникального row identity для конкретного дубликата. Несмотря на то, что т.н. связующая таблица конечно должна иметь некоторый ключ, по которому записи вы определяете уникально — у Hibernate такого знания нет, он об этой семантике просто не знает. Он просто видит List, и воспринимает List как Bag.

В таких условиях точечный DELETE становится неразрешимой задачей.

List vs Set. OneToMany

Вообще OneToMany (и инверсный ему ManyToOne) можно настроить двумя способами — unidirectional (т.е. будет только OneToMany, например, или только ManyToOne) и bidirectional (будет и OneToMany, и обратный ему ManyToOne).

Начну я с того, что unidirectional OneToMany это плохо, и делать так не стоит. На вопрос “почему” я отвечу в отдельной статье. Поэтому, мы с вами предположим, что если Вы и используете OneToMany, то только двунаправленный (bidirectional). И прежде чем начать пояснять, как тут “правильно”, я хочу рассказать вот о чём.

Java Collection Framework. Семантика List.add и Set.add

Давайте на минуту забудем про Hibernate и вспомним, что вообще обещает Java Collections Framework.

И у List, и у Set метод add выглядит одинаково:

boolean add(E e);

Одинаковая сигнатура. Разная семантика.

Для List (берём ArrayList как самый частый случай) add означает: “положи элемент в конец”. Дубликаты в List разрешены. Поэтому реализация спокойно делает append и всегда возвращает true. Ей не нужно смотреть, был ли такой элемент раньше. Ей вообще не нужно знать текущее содержимое коллекции.

List<String> list = new ArrayList<>();list.add("A"); // truelist.add("A"); // true. Ещё один "A". Так можно.

Для Set всё иначе. Контракт Set.add говорит примерно следующее:

Добавь элемент, если его ещё нет. Верни true, если коллекция изменилась, и false, если такой элемент уже был.

Set<String> set = new HashSet<>();set.add("A"); // trueset.add("A"); // false. Дубликат. Коллекция не изменилась.

И вот ключевой момент: чтобы честно вернуть этот boolean, реализация Set обязана знать, есть ли элемент уже в коллекции. Без знания текущего содержимого корректный ответ по контракту JCF просто невозможен.

Казалось бы, к чему я это. Сейчас узнаете.

Что это означает для Hibernate?

Мы знаем, что Hibernate не изобретает собственные коллекции с нуля в вакууме. Он подсовывает вам свои persistent-прокси (PersistentBag, PersistentSet и т.д.), но наружу они обязаны вести себя как обычные List/Set из JCF. В том числе по контракту add.

Теперь представьте: коллекция ленивая, в память ещё не загружена, и вы пишете:

developer.getProjects().add(newProject);

Случай List

List.add может (и должен) вернуть true всегда. Hibernate-прокси думает примерно так:

  1. Контракт List выполнен: вернём true прямо сейчас.

  2. Текущее содержимое коллекции для этого знать вообще не нужно.

И тут, если немного подумать, справедливости ради, для List.add нам коллекцию-то получается необязательно грузить лениво. И как итог: В нашем случае, List.add не провоцирует Lazy Loading. Красота.

Случай Set

А вот Set.add обязан синхронно вернуть правильный boolean: изменилась коллекция или нет. Если элемент уже есть, надо вернуть false и ничего не делать. Если нет, вернуть true и (если это owning side) запланировать вставку. Ещё раз подчеркну, вернуть boolean надо прямо сейчас, не дожидаясь flush.

Как это узнать, не глядя в текущее содержимое? Никак. Поэтому Hibernate вынужден:

  1. Инициализировать коллекцию (тот самый Lazy Loading, SELECT всех связанных строк).

  2. Проверить, есть ли уже такой элемент.

  3. Только после этого вернуть true/false и решить, нужен ли INSERT.

Итог: Set.add почти всегда провоцирует Lazy Loading, потому что иначе он нарушит контракт Set из Java. Это очень важная мысль. Вот теперь, дорогие друзья, давайте двигаться дальше.

List vs Set. Возвращаемся к OneToMany

Теперь, когда с JCF разобрались, возвращаемся к bidirectional @OneToMany. Возьмём классику Post / Comment:

@Entityclass Post {    @Id    private Long id;    @OneToMany(mappedBy = "post")    private List<Comment> comments = new ArrayList<>();}@Entityclass Comment {    @Id    private Long id;    @ManyToOne(fetch = FetchType.LAZY)    @JoinColumn(name = "post_id")    private Post post;}

И схема:

CREATE TABLE comment(    id bigint primary key,    post_id bigint references post(id),    ...);

Смотрите, чем это принципиально отличается от ManyToMany.

В ManyToMany у нас была join-таблица без собственного identity строки именно с точки зрения Hibernate. Там уникальность элементов коллекции была критична, чтобы уметь делать точечный DELETE. Поэтому Set там почти обязателен.

В bidirectional OneToMany картина другая. У каждой дочерней сущности есть:

  1. Собственный Primary Key (comment.id).

  2. Foreign Key на родителя (comment.post_id).

То есть у строки в БД есть дискриминатор. Это значит, что конкретный child однозначно идентифицируется своим id, а принадлежность к родителю выражена через post_id. Hibernate не нужно гадать “какую из нескольких одинаковых связей удалять”. Связи не одинаковые: у каждой child-сущности свой identity.

Поэтому точечная операция выглядит естественно:

update comment set post_id = null where id = 42;-- или, при orphanRemoval / удалении:delete from comment where id = 42;

Никакого delete-all + re-insert, как в случае bag в ManyToMany, тут не требуется. То есть в этом конкретном случае семантика List как Bag уже не ломает идентифицируемость строки, потому что у Hibernate есть возможность уникально идентифицировать строку.

А теперь складываем два факта:

  1. Для точечного DELETE/UPDATE в bidirectional OneToMany уникальность элементов коллекции не нужна так, как она нужна в ManyToMany, т.к. есть дискриминатор: PK child + FK на parent.

  2. Зато Set.add по-прежнему провоцирует Lazy Loading, а List.add нет. И вот это уже чистый выигрыш List на стороне Java-API.

Вывод довольно прямой: в bidirectional @OneToMany лучше использовать List. Вы получаете:

  • добавление элемента без принудительной загрузки всей коллекции

  • точечный SQL при изменении/удалении связи через identity child-сущности (без delete-all + re-insert)

Если же взять Set:

@OneToMany(mappedBy = "post")private Set<Comment> comments = new HashSet<>();

то при каждом comments.add(comment) Hibernate будет вынужден инициализировать коллекцию, чтобы честно ответить по контракту Set (ранее говорили). А выигрыша в идентифицируемости строк, ради которого Set так полезен в ManyToMany, здесь уже нет.

Ещё раз подчеркну: речь именно про bidirectional @OneToMany. Unidirectional @OneToMany я выше назвал плохой идеей, и к нему мы вернёмся как-нибудь в будущем. Но если связь двунаправленная (а так в общем случае и надо), то List здесь обычно предпочтительнее Set.

List vs Set. Итог. Что Вам Важно Знать

Многие, очень многие вещи в том, как Hibernate работает с List/Set, обусловлены тем, как вообще задизайнен Java Collections Framework (JCF)

  1. Для @ManyToMany в общем случае используйте Set. Семантика того, что элементы в Set уникальны, позволит делать точечный DELETE. Единственное, что ещё нужно помнить: вставка в Set будет провоцировать Lazy Loading. Это неизбежно, потому что синхронно надо вернуть boolean, не откладывая ответ до конца транзакции. Для List же, в силу того же JCF, всегда можно вернуть просто true, а коллекцию вообще не грузить.

  2. Для OneToMany в bidirectional случае (который лучше всегда и иметь) лучше использовать List. У child есть дискриминатор (PK + FK через @JoinColumn), поэтому Hibernate умеет делать точечный UPDATE/DELETE без delete-all + re-insert, как у Bag в @ManyToMany. Плюс List.add не форсирует Lazy Loading, а Set.add форсирует.

Надеюсь, вам понравилось. Если хотите, чтобы я разобрал ряд других вопросов — пожалуйста, напишите об этом и поддержите статью. Кстати, мы скоро начнем новую программу в рамках Spring АйО Академии (какую — пока секрет, но уже не по Hibernate). Мы активно собираем фидбек от первого потока, чтобы понять, что людям зашло, а что не очень. Будем держать вас в курсе 🙂

Всем спасибо!

ссылка на оригинал статьи https://habr.com/ru/articles/1059922/