Проблема декартова произведения (Cartesian product problem) в Hibernate

Если предыдущая проблема  извлечения данных с Hibernate была связана с большим количеством select, то на этот раз select будет один. Но какой: если select содержит два и более join, это приводит к выборке огромного количества лишних данных, которые передаются по сети, занимают оперативную память. Что также отрицательно сказывается на производительности. И таких join-ов тоже надо избегать. Ниже рассмотрим пример.

Когда возникает проблема

Столкнуться с проблемой можно как явно (просто написав Query с несколькими join), так и неявно, выполнив find() для сущности с EAGER-коллекциями (Hibernate при этом сгенерирует SQL с несколькими join).

В обоих случаях с точки зрения объектной модели мы хотим получить сущность вместе с заполненными полями коллекций.

Модель

Допустим, у нас есть пост с двумя коллекциями – тегов и картинок:

@Data
@NoArgsConstructor
@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;

    private String title;

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
    private List<Image> images=new ArrayList<>();

    @ElementCollection
    private Set<String> tags = new HashSet<>();

    public Post(String title){
        this.title=title;
    }

    public void addImage(Image image){
        image.setPost(this);
        this.images.add(image);
    }
}

Коллекция тегов – @ElementCollection, коллекция картинок – двунаправленной отношение @OneToMany. На факт возникновения проблемы вид коллекции не влияет (и отношение @ManyToMany тоже способно вызвать проблему).

Класс картинки:

@Data
@NoArgsConstructor
@Entity
public class Image {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;

    private String url;
    @ManyToOne
    private Post post;

    public Image(String url){
        this.url=url;
    }
}

По умолчанию поля коллекций заполняются лениво:

fetch = FetchType.LAZY

И это правильно, иначе при выборке Post мы столкнемся с N+1 select проблемой.

Но как же получить обе коллекции и при этом избегнуть как N+1 проблему, так и select-а c двумя join (Cartesian product problem)?

Ответ прост – нужно выполнить вместо одного select с несколькими join (в нашем случае двумя) несколько select-ов (в нашем случае два – по одному на коллекцию), в каждом из который ровно один join.

Как получить сущность с коллекциями и избежать проблемы декартова произведения

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

public class CartesianProblemTest {

    @BeforeAll
    private static void createPosts() {
        HibernateUtil.doInHibernate(session -> {
            for (int i = 0; i < 5; i++) {
                Post post = new Post("topic" + i);
                Image image1 = new Image("url1_" + i);
                Image image2 = new Image("url2_" + i);
                post.addImage(image1);
                post.addImage(image2);

                Set<String> tags = Arrays.asList("red", "green", "blue", "orange", "white").stream().collect(Collectors.toSet());
                post.setTags(tags);
                session.persist(post);
            }
        });
    }

  //тесты

}

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

Для этого получаем коллекции по очереди. В первом select – получим коллекцию картинок, а во втором – тегов.

@Test
@DisplayName("если FetchType.LAZY и выполнить отдельные select для заполнения коллекций, то проблемы нет")
public void givneLazy_whenSelectCollectionsByOne_thenOk() {
    HibernateUtil.doInHibernate(session -> {
        List<Post> posts = session
                .createQuery(
                        "select distinct p " +
                                "from Post p " +
                                "left join fetch p.images " +
                                "where p.id between :minId and :maxId ", Post.class)
                .setParameter("minId", 1L)
                .setParameter("maxId", 1L)
                .setHint(QueryHints.PASS_DISTINCT_THROUGH, false)
                .getResultList();

        posts = session
                .createQuery(
                        "select distinct p " +
                                "from Post p " +
                                "left join fetch p.tags t " +
                                "where p in :posts ", Post.class)
                .setParameter("posts", posts)
                .setHint(QueryHints.PASS_DISTINCT_THROUGH, false)
                .getResultList();
        
    });

В итоге поля коллекций будут заполнены.

distinct p использован для того, чтобы итоговый List<Post> не содержал дублирующихся постов. При этом .setHint(QueryHints.PASS_DISTINCT_THROUGH, false) не дает передаться ключевому слову distinct в native SQL – он там не нужен, поскольку выбранные строки в любом случае будут все разные.

После выполнения тесты в консоли мы увидим два select, в каждом из которых по одному join:

select post0_.id as id1_1_0_, images1_.id as id1_0_1_, 
post0_.title as title2_1_0_, images1_.post_id as post_id3_0_1_, 
images1_.url as url2_0_1_, images1_.post_id as post_id3_0_0__, images1_.id as id1_0_0__ 
from Post post0_ 
left outer join Image images1_ on post0_.id=images1_.post_id 
where post0_.id between ? and ?
---------------------------------------------
select post0_.id as id1_1_, post0_.title as title2_1_, 
tags1_.Post_id as Post_id1_2_0__, tags1_.tags as tags2_2_0__ 
from Post post0_ 
left outer join Post_tags tags1_ on post0_.id=tags1_.Post_id 
where post0_.id in (?)

И это вполне терпимо, в отличии от нескольких join в одном select. Поскольку каждый join умножает количество выбранных строк на количество элементов в коллекции. Ниже показано, как.

Cartesian product

На всякий случай продемонстрирую проблему наглядно, показав выборку, которую Hibernate автоматически формирует при выполнении find().

Для этого временно изменим fetch = FetchType.EAGER для обоих коллекций (чтобы коллекции в принципе заполнялись, ведь наша задача состоит в этом):

@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private List<Image> images = new ArrayList<>();

@ElementCollection(fetch = FetchType.EAGER)
private Set<String> tags = new HashSet<>();

И выберем всего одну запись с помощью find():

@Test
@DisplayName("если поставить FetchType.EAGER, то find создает большой Cartesian Product ")
public void givenEager_whenFind_thenCartesianProblem() {
    HibernateUtil.doInHibernate(session -> {
        Post post = session.find(Post.class, 1l);
    });
}

Вот что генерирует в консоли Hibernate:

select post0_.id as id1_1_0_, post0_.title as title2_1_0_, 
images1_.post_id as post_id3_0_1_, images1_.id as id1_0_1_, 
images1_.id as id1_0_2_, images1_.post_id as post_id3_0_2_, 
images1_.url as url2_0_2_, tags2_.Post_id as Post_id1_2_3_, 
tags2_.tags as tags2_2_3_ 
from Post post0_ 
left outer join Image images1_ on post0_.id=images1_.post_id 
left outer join Post_tags tags2_ on post0_.id=tags2_.Post_id 
where post0_.id=?

Как видно выше, это select с двумя join.

Если выполнить его на базе (выбираем один пост с двумя картинками и пятью тегами), получим такую выборку:

Декартово произведение
Декартово произведение

В ней дублируется информация, и это плохо.

У нас всего 2 картинки и 5 тегов, то есть на 1 пост формируется 2*5=10 строк. А могло бы 10*30 при 10 картинках и 30 тегах. А если коллекций еще больше? Кол-во строк умножается на кол-во элементов очередной коллекции.

Еще один способ: FetchMode.SUBSELECT

И рассмотрим еще один способ заполнить коллекции постов, при этом избажав проблемы как декартова произведения, так и N+1.

Для этого аннотируем обе коллекции с @Fetch(FetchMode.SUBSELECT):

@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
@Fetch(FetchMode.SUBSELECT)
private List<Image> images = new ArrayList<>();

@ElementCollection
@Fetch(FetchMode.SUBSELECT)
private Set<String> tags = new HashSet<>();

Выполним тест и получим три SQL-запроса в консоли:

@Test
@DisplayName("если поставить org.hibernate.annotations.FetchMode.SUBSELECT, и сделать обычный select, то тоже пофиксим проблему")
public void givenSubselect_whenSimpleSelect_thenOk() {
    List<Post> returnedPosts = HibernateUtil.doInHibernate(session -> {
        List<Post> posts = session.createQuery("select p from Post p", Post.class).getResultList();
        //достаточно обратиться к коллекциям одного элемента, чтобы заполнились коллекции всех элементов
        System.out.println(posts.get(0));
        return posts;
    });

    Assertions.assertEquals(5, returnedPosts.size());
    returnedPosts.forEach(System.out::println);
}

Генерируемые SQL-запросы:

select post0_.id as id1_1_, post0_.title as title2_1_ 
from Post post0_
------------------------------------------
select images0_.post_id as post_id3_0_1_, images0_.id as id1_0_1_,
       images0_.id as id1_0_0_, images0_.post_id as post_id3_0_0_, 
       images0_.url as url2_0_0_ 
from Image images0_ 
where images0_.post_id in (select post0_.id from Post post0_)
--------------------------------------
select tags0_.Post_id as Post_id1_2_0_, tags0_.tags as tags2_2_0_ 
from Post_tags tags0_ 
where tags0_.Post_id in (select post0_.id from Post post0_)

На этот раз после выборки постов  выбираются все картинки и все теги для все выбранных постов. Причем делается это только после обращения к коллекциям одного поста:

System.out.println(posts.get(0));
Для Post переопределен метод toString(), который и обращается к коллекциям при выводе поста.

Если к коллекциям одного поста не обратиться, то никакие коллекции загружены не будут. Если же обратиться, то будут загружены коллекции для все выбранных постов. Вот так работает FetchMode.SUBSELECT.

Итоги

Исходный код примера есть на GitHub.

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

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