Привет, друзья!
На связи Михаил Поливаха, технический лидер проекта 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, вы имеете в виду коллекцию, где вы:
Не гарантируете никакой порядок элементов
И к тому же, где вы заявляете, что не гарантируете уникальность элементов.
Это всё мы уже в рамках Spring АйО Академии разбирали, я думаю, для многих тут ничего нового нет, но теперь смотрите.
List vs Set. ManyToMany
В интернете пишут, что в ManyToMany надо использовать Set всегда, потому что в случае с листом Hibernate делает следующее:
Удаляет все элементы из связующей таблицы для данной сущности
Делает re-INSERT оставшихся
Я хочу пояснить, почему Hibernate это делает, и почему по-другому он не может. Давайте рассмотрим many-to-many связку Developer <-> Project. Рассмотрим оба варианта - и List и Set, и как каждый из них воспринимает Hibernate:
@Entity class 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 - у нас просто нет выхода:
Удалить все связи конкретного родителя:
delete from developer_project where developer_id = 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"); // true list.add("A"); // true. Ещё один "A". Так можно.
Для Set всё иначе. Контракт Set.add говорит примерно следующее:
Добавь элемент, если его ещё нет. Верни
true, если коллекция изменилась, иfalse, если такой элемент уже был.
Set<String> set = new HashSet<>(); set.add("A"); // true set.add("A"); // false. Дубликат. Коллекция не изменилась.
И вот ключевой момент: чтобы честно вернуть этот boolean, реализация Set обязана знать, есть ли элемент уже в коллекции. Без знания текущего содержимого корректный ответ по контракту JCF просто невозможен.
Казалось бы, к чему я это. Сейчас узнаете.
Что это означает для Hibernate?
Мы знаем, что Hibernate не изобретает собственные коллекции с нуля в вакууме. Он подсовывает вам свои persistent-прокси (PersistentBag, PersistentSet и т.д.), но наружу они обязаны вести себя как обычные List/Set из JCF. В том числе по контракту add.
Теперь представьте: коллекция ленивая, в память ещё не загружена, и вы пишете:
developer.getProjects().add(newProject);
Случай List
List.add может (и должен) вернуть true всегда. Hibernate-прокси думает примерно так:
Контракт
Listвыполнен: вернёмtrueпрямо сейчас.Текущее содержимое коллекции для этого знать вообще не нужно.
И тут, если немного подумать, справедливости ради, для List.add нам коллекцию-то получается необязательно грузить лениво. И как итог: В нашем случае, List.add не провоцирует Lazy Loading. Красота.
Случай Set
А вот Set.add обязан синхронно вернуть правильный boolean: изменилась коллекция или нет. Если элемент уже есть, надо вернуть false и ничего не делать. Если нет, вернуть true и (если это owning side) запланировать вставку. Ещё раз подчеркну, вернуть boolean надо прямо сейчас, не дожидаясь flush.
Как это узнать, не глядя в текущее содержимое? Никак. Поэтому Hibernate вынужден:
Инициализировать коллекцию (тот самый Lazy Loading,
SELECTвсех связанных строк).Проверить, есть ли уже такой элемент.
Только после этого вернуть
true/falseи решить, нужен лиINSERT.
Итог: Set.add почти всегда провоцирует Lazy Loading, потому что иначе он нарушит контракт Set из Java. Это очень важная мысль. Вот теперь, дорогие друзья, давайте двигаться дальше.
List vs Set. Возвращаемся к OneToMany
Теперь, когда с JCF разобрались, возвращаемся к bidirectional @OneToMany. Возьмём классику Post / Comment:
@Entity class Post { @Id private Long id; @OneToMany(mappedBy = "post") private List<Comment> comments = new ArrayList<>(); } @Entity class 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 картина другая. У каждой дочерней сущности есть:
Собственный Primary Key (
comment.id).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 есть возможность уникально идентифицировать строку.
А теперь складываем два факта:
Для точечного
DELETE/UPDATEв bidirectionalOneToManyуникальность элементов коллекции не нужна так, как она нужна вManyToMany, т.к. есть дискриминатор: PK child + FK на parent.Зато
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)
Для
@ManyToManyв общем случае используйтеSet. Семантика того, что элементы вSetуникальны, позволит делать точечныйDELETE. Единственное, что ещё нужно помнить: вставка вSetбудет провоцировать Lazy Loading. Это неизбежно, потому что синхронно надо вернутьboolean, не откладывая ответ до конца транзакции. ДляListже, в силу того же JCF, всегда можно вернуть простоtrue, а коллекцию вообще не грузить.Для OneToMany в bidirectional случае (который лучше всегда и иметь) лучше использовать
List. У child есть дискриминатор (PK + FK через@JoinColumn), поэтому Hibernate умеет делать точечныйUPDATE/DELETEбез delete-all + re-insert, как у Bag в@ManyToMany. ПлюсList.addне форсирует Lazy Loading, аSet.addфорсирует.
Надеюсь, вам понравилось. Если хотите, чтобы я разобрал ряд других вопросов - пожалуйста, напишите об этом и поддержите статью. Кстати, мы скоро начнем новую программу в рамках Spring АйО Академии (какую - пока секрет, но уже не по Hibernate). Мы активно собираем фидбек от первого потока, чтобы понять, что людям зашло, а что не очень. Будем держать вас в курсе :)
Всем спасибо!
dyadyaSerezha
В таблице developer_project не было определено никаких ключей. А уникальность записей может осуществляться на уровне приложения. Тщательнее.
Это совершенно некорректно, потому что мы не знаем, удалили мы тот элемент или нет, и не знаем даже, не ошибка ли это дизайна или приложения, что в таблице связи появились две записи с одинаковой парой id.
А производительность уже дело десятое - тут не решён гораздо более важный вопрос о семантике связи или об имплементации этой семантике в базе данных.