Автор оригинала: 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:
Listposts = 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 и Комментария к сообщению сущностей.
Этот подход очень прост в реализации и выглядит следующим образом:
ListpostIds = 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 :
Listposts = 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<>());
Теперь мы не только можем получить как Сообщение , так и его Комментарии к сообщению с помощью одного запроса, но мы можем даже позже изменить эти сущности и объединить их обратно в последующей транзакции чтения-записи:
Listposts = 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 , у вас есть два варианта. Либо вы выполняете два запроса и извлекаете сущности либо в режиме чтения-записи, либо только для чтения, либо используете один запрос с оконными функциями для извлечения сущностей в режиме только для чтения.