Рубрики
Без рубрики

Лучший способ исправить гибернацию “HHH000104: Первые результаты/максимальные результаты, указанные при извлечении коллекции; применение в памяти!” предупреждающее сообщение

Узнайте, как исправить спящий режим “HHH000104: Первые результаты/максимальные результаты, указанные при извлечении коллекции; применение в памяти!” предупреждающее сообщение.

Автор оригинала: Vlad Mihalcea.

Вступление

Если вы используете Hibernate достаточно долго, то вы наверняка должны были видеть это сообщение журнала ПРЕДУПРЕЖДЕНИЙ при разбиении на страницы при объединении нескольких объектов.

HHH000104: Первый результат/максимальный результат, указанный при извлечении коллекции; применяется в памяти!

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

Модель предметной области

Учитывая, что у нас есть следующие организации:

Сущность Post имеет двунаправленную ассоциацию @OneToMany с Комментарием к сообщению дочерней сущностью.

Обратите внимание, что обе сущности используют API в стиле Fluent. Для получения более подробной информации о создании объектов с использованием API в стиле Fluent с JPA и гибернацией ознакомьтесь с этой статьей .

Теперь давайте предположим, что мы создаем 50 Сообщений сущностей, каждая из которых содержит несколько Комментариев к сообщению дочерних сущностей.

LocalDateTime timestamp = LocalDateTime
.of(
    2018, 10, 9, 12, 0, 0, 0
);

LongStream.rangeClosed(1, 50)
.forEach(postId -> {
    Post post = new Post()
    .setId(postId)
    .setTitle(
        String.format("High-Performance Java Persistence - Chapter %d",
        postId)
    )
    .setCreatedOn(
        Timestamp.valueOf(timestamp.plusMinutes(postId))
    );

    LongStream.rangeClosed(1, COMMENT_COUNT)
    .forEach(commentOffset -> {
        long commentId = ((postId - 1) * COMMENT_COUNT) + commentOffset;

        post.addComment(
            new PostComment()
            .setId(commentId)
            .setReview(
                String.format("Comment nr. %d - A must read!", commentId)
            )
            .setCreatedOn(
                Timestamp.valueOf(timestamp.plusMinutes(commentId))
            )
        );

    });

    entityManager.persist(post);
});

Проблема

Мы хотим получить все Сообщения сущности, названия которых соответствуют заданному шаблону. Однако мы также хотим получить связанные Комментарии к сообщению сущности.

Как я объяснил в этой статье , у вас может возникнуть соблазн использовать запрос разбиения на страницы JPA для извлечения Сообщений сущностей, а также присоединиться к извлечению сообщений сущностей, как показано в следующем запросе JPQL:

List posts  = entityManager.createQuery("""
    select p
    from Post p
    left join fetch p.comments
    where p.title like :titlePattern
    order by p.createdOn
    """, Post.class)
.setParameter(
    "titlePattern", 
    "High-Performance Java Persistence %"
)
.setMaxResults(5)
.getResultList();

Мы хотим получить объекты Post вместе с их комментариями и ограничить набор результатов максимальным количеством записей.

При выполнении приведенного выше запроса JPQL Hibernate ограничивает количество записей Post , но выдает вышеупомянутое предупреждение при выполнении следующего SQL-запроса:

-- HHH000104: firstResult/maxResults specified with collection fetch; 
-- applying in memory!

SELECT 
    p.id AS id1_0_0_,
    pc.id AS id1_1_1_,
    p.created_on AS created_2_0_0_,
    p.title AS title3_0_0_,
    pc.created_on AS created_2_1_1_,
    pc.post_id AS post_id4_1_1_,
    pc.review AS review3_1_1_,
    pc.post_id AS post_id4_1_0__,
    pc.id AS id1_1_0__
FROM 
     post p
LEFT OUTER JOIN 
     post_comment pc ON p.id=pc.post_id
WHERE 
     p.title LIKE 'High-Performance Java Persistence %'
ORDER BY 
     p.created_on

Обратите внимание, что в SQL-запросе используется без разбиения на страницы. Как упоминалось в предупреждающем сообщении HHH000104, разбиение на страницы выполняется в памяти, что плохо.

Причина, по которой Hibernate выполняет разбиение на страницы в памяти, заключается в том, что он не может просто усечь результирующий набор с помощью разбиения на страницы на уровне SQL. Если бы это было сделано, то результирующий набор был бы усечен в середине строк Комментариев , поэтому возвращалась бы сущность Post только с подмножеством комментариев .

Поскольку Hibernate способствует согласованности, он извлекает весь результирующий набор и выполняет разбиение на страницы в памяти. Однако это может быть неоптимальным, так что мы можем с этим поделать?

Исправлена проблема с двумя SQL-запросами, которые могут извлекать сущности в режиме чтения-записи

Самый простой способ исправить эту проблему-выполнить два запроса:

. В первом запросе будут получены идентификаторы Post сущностей, соответствующие указанным критериям фильтрации. . Второй запрос будет использовать ранее извлеченные Post идентификаторы сущностей для извлечения Post и Комментария к сообщению сущностей.

Этот подход очень прост в реализации и выглядит следующим образом:

List postIds = entityManager.createQuery("""
    select p.id
    from Post p
    where p.title like :titlePattern
    order by p.createdOn
    """, Long.class)
.setParameter(
    "titlePattern", 
    "High-Performance Java Persistence %"
)
.setMaxResults(5)
.getResultList();

List posts = entityManager.createQuery("""
    select distinct p
    from Post p
    left join fetch p.comments
    where p.id in (:postIds)
    order by p.createdOn
    """, Post.class)
.setParameter("postIds", postIds)
.setHint(
    QueryHints.HINT_PASS_DISTINCT_THROUGH, 
    false
)
.getResultList();

assertEquals(5, posts.size());

Post post1 = posts.get(0);

List comments = post1.getComments();

for (int i = 0; i < COMMENT_COUNT - 1; i++) {
    PostComment postComment1 = comments.get(i);

    assertEquals(
        String.format(
            "Comment nr. %d - A must read!",
            i + 1
        ),
        postComment1.getReview()
    );
}

Обратите внимание на спящий режим.запрос.передайте Distinct Через подсказку запроса JPA, которую мы использовали для указания Hibernate, чтобы предотвратить передачу ключевого слова JPQL DISTINCT в базовый SQL-запрос. Для получения более подробной информации об этой подсказке запроса ознакомьтесь с этой статьей .

Второй запрос также требует предложения ORDER BY, так как без него порядок записей Post не будет гарантирован.

При выполнении двух приведенных выше JPQL-запросов Hibernate генерирует следующие SQL-запросы:

Query:["
    SELECT 
        p.id AS col_0_0_
    FROM 
        post p
    WHERE 
        p.title LIKE ?
    ORDER BY 
        p.created_on
    LIMIT 
        ?
"], 
Params:[(
    'High-Performance Java Persistence %', 5
)]

Query:["
    SELECT 
        p.id AS id1_0_0_,
        pc.id AS id1_1_1_,
        p.created_on AS created_2_0_0_,
        p.title AS title3_0_0_,
        pc.created_on AS created_2_1_1_,
        pc.post_id AS post_id4_1_1_,
        pc.review AS review3_1_1_,
        pc.post_id AS post_id4_1_0__,
        pc.id AS id1_1_0__
    FROM 
        post p
    LEFT OUTER JOIN 
        post_comment pc ON p.id = pc.post_id
    WHERE 
        p.id IN (?, ?, ?, ?, ?)
    ORDER BY 
        p.created_on
"], 
Params:[(
    1, 2, 3, 4, 5
)]

Это самый простой способ устранить проблему, вызвавшую HHH000104 предупреждающее сообщение.

Исправление проблемы с помощью одного SQL-запроса, который может извлекать сущности только в режиме только для чтения

Как я уже объяснял, оконные функции являются ответом на многие проблемы, связанные с запросами.

Итак, нам просто нужно вычислить DENSE_RANK по набору результатов post и post_комментов , которые соответствуют нашим критериям фильтрации, и ограничить вывод только для первых N записей post.

Для этого нам нужно определить следующее @NamedNativeQuery вместе с соответствующим @SqlResultSetMapping :

@NamedNativeQuery(
    name = "PostWithCommentByRank",
    query = """
        SELECT *
        FROM (
            SELECT 
                *, 
                DENSE_RANK() OVER (
                    ORDER BY "p.created_on", "p.id"
                ) rank
            FROM (
                SELECT 
                    p.id AS "p.id", p.created_on AS "p.created_on",
                    p.title AS "p.title", pc.post_id AS "pc.post_id",
                    pc.id as "pc.id", pc.created_on AS "pc.created_on",
                    pc.review AS "pc.review"
                FROM  post p
                LEFT JOIN post_comment pc ON p.id = pc.post_id
                WHERE p.title LIKE :titlePattern
                ORDER BY p.created_on
            ) p_pc
        ) p_pc_r
        WHERE p_pc_r.rank <= :rank
        """,
    resultSetMapping = "PostWithCommentByRankMapping"
)
@SqlResultSetMapping(
    name = "PostWithCommentByRankMapping",
    entities = {
        @EntityResult(
            entityClass = Post.class,
            fields = {
                @FieldResult(name = "id", column = "p.id"),
                @FieldResult(name = "createdOn", column = "p.created_on"),
                @FieldResult(name = "title", column = "p.title"),
            }
        ),
        @EntityResult(
            entityClass = PostComment.class,
            fields = {
                @FieldResult(name = "id", column = "pc.id"),
                @FieldResult(name = "createdOn", column = "pc.created_on"),
                @FieldResult(name = "review", column = "pc.review"),
                @FieldResult(name = "post", column = "pc.post_id"),
            }
        )
    }
)

@NamedNativeQuery извлекает все Сообщения сущности, соответствующие предоставленному названию , вместе с их связанными последующими дочерними сущностями. Функция DENSE_RANK Окна используется для присвоения ранга для каждой Записи и Комментария к записи присоединенной записи, чтобы мы могли позже отфильтровать только количество записей , которые нас интересуют.

SqlResultSetMapping обеспечивает сопоставление между псевдонимами столбцов уровня SQL и свойствами сущности JPA, которые необходимо заполнить.

Для получения более подробной информации о том, как лучше всего использовать аннотацию JPA SqlResultSetMapping , вам следует прочитать эту статью .

Теперь мы можем выполнить Сообщение С Комментарием По Рангу |/@NamedNativeQuery :

List posts = entityManager
.createNamedQuery("PostWithCommentByRank")
.setParameter(
    "titlePattern", 
    "High-Performance Java Persistence %"
)
.setParameter(
    "rank", 
    5
)
.setHint(QueryHints.HINT_READONLY, true)
.unwrap(NativeQuery.class)
.setResultTransformer(
    new DistinctPostResultTransformer(entityManager)
)
.getResultList();

assertEquals(5, posts.size());

Post post1 = posts.get(0);

List comments = post1.getComments();

for (int i = 0; i < COMMENT_COUNT - 1; i++) {
    PostComment postComment1 = comments.get(i);

    assertEquals(
        String.format(
            "Comment nr. %d - A must read!",
            i + 1
        ),
        postComment1.getReview()
    );
}

Мы использовали подсказку запроса ТОЛЬКО ДЛЯ чтения JPA, чтобы указать Hibernate отказаться от состояния отсоединения базовой сущности. Для получения более подробной информации об этой оптимизации ознакомьтесь с этой статьей .

Теперь, по умолчанию, собственный SQL-запрос, такой как Сообщение С комментарием по рангу , будет извлекать Сообщение и Комментарий к сообщению в одной строке JDBC, поэтому мы получим Объект [] , содержащий обе сущности.

Тем не менее, мы хотим преобразовать табличный Объект[] массив в дерево родительско-дочерних сущностей, и по этой причине нам необходимо использовать Hibernate ResultTransformer Для получения более подробной информации о ResultTransformer , ознакомьтесь с этой статьей .

Отчетливый результирующий преобразователь выглядит следующим образом:

public class DistinctPostResultTransformer 
        extends BasicTransformerAdapter {

    private final EntityManager entityManager;

    public DistinctPostResultTransformer(
            EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Override
    public List transformList(
            List list) {
            
        Map identifiableMap = 
            new LinkedHashMap<>(list.size());
            
        for (Object entityArray : list) {
            if (Object[].class.isAssignableFrom(entityArray.getClass())) {
                Post post = null;
                PostComment comment = null;

                Object[] tuples = (Object[]) entityArray;

                for (Object tuple : tuples) {
                    if(tuple instanceof Identifiable) {
                        entityManager.detach(tuple);

                        if (tuple instanceof Post) {
                            post = (Post) tuple;
                        } 
                        else if (tuple instanceof PostComment) {
                            comment = (PostComment) tuple;
                        } 
                        else {
                            throw new UnsupportedOperationException(
                                "Tuple " + tuple.getClass() + " is not supported!"
                            );
                        }
                    }
                }

                if (post != null) {
                    if (!identifiableMap.containsKey(post.getId())) {
                        identifiableMap.put(post.getId(), post);
                        post.setComments(new ArrayList<>());
                    }
                    if (comment != null) {
                        post.addComment(comment);
                    }
                }
            }
        }
        return new ArrayList<>(identifiableMap.values());
    }
}

Преобразователь Distinct Post ResultTransformer должен отсоединять извлекаемые сущности, потому что мы перезаписываем дочернюю коллекцию и не хотим, чтобы это распространялось как переход состояния сущности :

post.setComments(new ArrayList<>());

Теперь мы не только можем получить как Сообщение , так и его Комментарии к сообщению с помощью одного запроса, но мы можем даже позже изменить эти сущности и объединить их обратно в последующей транзакции чтения-записи:

List posts = doInJPA(entityManager -> {
    return entityManager
    .createNamedQuery("PostWithCommentByRank")
    .setParameter(
        "titlePattern",
        "High-Performance Java Persistence %"
    )
    .setParameter(
        "rank",
        2
    )
    .unwrap(NativeQuery.class)
    .setResultTransformer(
        new DistinctPostResultTransformer(entityManager)
    )
    .getResultList();
});

assertEquals(2, posts.size());

Post post1 = posts.get(0);

post1.addComment(
    new PostComment()
    .setId((post1.getId() - 1) * COMMENT_COUNT)
    .setReview("Awesome!")
    .setCreatedOn(
        Timestamp.valueOf(LocalDateTime.now())
    )
);

Post post2 = posts.get(1);
post2.removeComment(post2.getComments().get(0));

doInJPA(entityManager -> {
    entityManager.merge(post1);
    entityManager.merge(post2);
});

И Hibernate правильно распространит изменения в базе данных:

INSERT INTO post_comment (
    created_on, 
    post_id, 
    review, 
    id
) 
VALUES (
    '2019-01-09 10:47:32.134', 
    1, 
    'Awesome!', 
    0
)

DELETE FROM post_comment 
WHERE id = 6

Потрясающе, правда?

Вывод

Итак, чтобы исправить проблему HHH000104 , у вас есть два варианта. Либо вы выполняете два запроса и извлекаете сущности либо в режиме чтения-записи, либо только для чтения, либо используете один запрос с оконными функциями для извлечения сущностей в режиме только для чтения.