Отношение OneToMany в Hibernate и Spring

В этой статье покажу подводные камни одностороннего отношения OneToMany, и почему его лучше не использовать.

Модель возьмем прежнюю — топик и комментарии к нему:

Только теперь класс Topic будет ссылаться на коллекцию комментариев в одностороннем порядке, а не наоборот.

Модель

Класс Comment:

@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String text;

  // getters/setters/constructors

}

Класс Topic:

@Entity
public class Topic {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String title;
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "topic_id")
    private List<Comment> comments=new ArrayList<>();
   
   //getters/setters/constructors   

}

Как видите, коллекция комментариев аннотирована @OneToMany.

Кроме того, добавлена аннотация @JoinColumn(name = «topic_id»). Без нее Hibernate создаст три таблицы вместо двух, что вообще никуда не годится.

При запуске тестов таблицы заполняются данными — они находятся в файле data.sql.

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

Добавление топика

Итак, при добавлении одного топика с тремя комментариями на первый взгляд должно выполниться 4 оператора insert. Но это не так. Попробуем выполнить тест:

@Test
@DisplayName("при добавлении топика для каждого комментария выполняется insert и update")
public void whenAddTopicWithComments_thenInsertsWithUpdates() {
    Topic topic = new Topic("title");
    topic.getComments().add(new Comment("c1"));
    topic.getComments().add(new Comment("c2"));
    topic.getComments().add(new Comment("c3"));
    topic = topicRepository.save(topic);

    Assertions.assertEquals(4, topicRepository.count());
}

В консоли получим:

Hibernate: insert into topic (title, id) values (?, ?)
Hibernate: insert into comment (text, id) values (?, ?)
Hibernate: insert into comment (text, id) values (?, ?)
Hibernate: insert into comment (text, id) values (?, ?)
Hibernate: update comment set topic_id=? where id=?
Hibernate: update comment set topic_id=? where id=?
Hibernate: update comment set topic_id=? where id=?

Hibernate сначала все добавляет, а потом для каждого комментария обновляет столбец topic_id .

При добавлении комментариев к существующему топику проблема сохраняется: помимо insert выполняются операторы update.

Удаление комментария из топика

Аналогичная проблема при удалении комментария. Попробуем удалить один комментарий из топика:

@Test
@DisplayName("при удалении комментария для него выполняется update и delete")

public void whenDeleteComment_thenDeleteWithUpdate() {
   Topic topic = topicRepository.getOne(-1l);
   topic.getComments().remove(0);

   Assertions.assertEquals(2, commentRepository.count());
}

Тут мы удалили комментарий из коллекции, так что топик перестал на него ссылаться. Поскольку orphanRemoval = true, комментарий должен удаляться также из таблицы комментариев. То есть требуется один оператор delete. Но в реальности это не так:

Hibernate: update comment set topic_id=null where topic_id=? and id=?
Hibernate: delete from comment where id=?

Сначала у комментария обнуляется поле topic_id, а затем он удаляется.

Чтобы избежать вышеприведенных операторов update, придется поменять отношение с unidirectional на bidirectional, сделать сторону @ManyToOne главной и работать с ней. Так эффективнее.

Замена одностороннего @OneToMany  на двустороннее @OneToMany + @ManyToOne

Итак, добавим поле topic в класс Comment:

@Entity
public class GoodComment {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String text;

    @ManyToOne(fetch = FetchType.LAZY)
    private GoodTopic topic;
    
   //getters/setters/constructors 

}

И сделаем коллекцию не основной стороной с помощью mappedBy. Также уберем JoinColumn, он тут не нужен:

@Entity
public class GoodTopic {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String title;
    @OneToMany(mappedBy = "topic", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<GoodComment> comments=new ArrayList<>();
   
    public void addComment(GoodComment comment) {
        comments.add(comment);
        comment.setTopic(this);
    }

    public void removeComment(GoodComment comment) {
        comments.remove(comment);
        comment.setTopic(null);
    }

   //getters/setters/constructors
}

Обратите внимание, что добавлены методы addComment()  и removeComment(), в них поддерживается согласованность двустороннего отношения.

Теперь при выполнении аналогичных тестов лишних операторов update нет:

@DataJpaTest
public class GoodStructureTest {
    @Autowired
    private GoodTopicRepository topicRepository;
    @Autowired
    private GoodCommentRepository commentRepository;

    @Test
    @DisplayName("при добавлении топика для каждого комментария выполняется один insert")
    public void whenAddTopicWithComments_thenInsertsWithUpdates() {
        GoodTopic topic = new GoodTopic("title");
        topic.addComment(new GoodComment("c1"));
        topic.addComment(new GoodComment("c2"));
        topic.addComment(new GoodComment("c3"));
        topic = topicRepository.save(topic);

        Assertions.assertEquals(4, topicRepository.count());
    }

    @Test
    @DisplayName("при удалении комментария для него выполняется один delete")

    public void whenDeleteComment_thenDeleteWithUpdate() {
       GoodTopic topic = topicRepository.getOne(-11l);
       topic.removeComment(topic.getComments().get(0));

       Assertions.assertEquals(2, commentRepository.count());
    }
}

Итоги

Итак, использование одностороннего @OneToMany  чревато лишними SQL операторами. Если коллекция все же необходима, лучше сделать отношение двусторонним (bidirectional): добавить ссылку с противоположного конца отношения и работать со стороной @ManyToOne.

Можно использовать одностороннее отношение @ManyToOne, а для получения элементов коллекции написать отдельный запрос.

Исходный код есть на GitHub.

Отношение OneToMany в Hibernate и Spring: 5 комментариев

  1. А что эффективнее? OneToMany + ManyToOne или то о чём вы пишите здесь: «Можно использовать одностороннее отношение @ManyToOne, а для получения элементов коллекции написать отдельный запрос.»

    1. В обоих случаях получить элементы коллекции можно единственным select. Тут лучше смотреть на то, какие объекты. Например, если это многотысячная коллекция сообщений, приходящих юзеру, то нет смысла держать ассоциацию @OneToMany List — слишком много памяти занимает. Эту коллекцию все равно при надобности можно получить с помощью jpql-запроса (причем лучше постраничного), даже если у нас одностороннее отношение @ManyToOne.
      В этом смысл фразы.

  2. Добрый день.
    Пишу совершенно без придирки, просто хотелось узнать, по какой причине лист comments в топике загружается жадно, в то время как сам топик в листе загружается лениво (в случае двусторонней связи между сущностями)? Если я не ошибаюсь, это может вызвать проблему n+1 select’a при загрузке топика из бд со всеми вытекающими.
    Если это просто было неважно в контексте данной темы, я пойму. Спасибо!

  3. Приветствую.
    А если есть таблица ролей с ролями ROLE_ADMIN и ROLE_USER, и например пользователи admin (может иметь обе роли одновременно) и пользователь user (имеет только роль ROLE_USER), то какое отношение выставлять между таблицами? При ManyToMany все работает, при OneToMany каждый пользователь может обладать только одной ролью…

    1. не похоже, что между этими таблицами требуется отношение. И потом, в статье про отношение между сущностями, а не таблицами — расставляешь аннотации и генерируются таблицы, вопрос странно поставлен.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *