Автор оригинала: 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) { MapidentifiableMap = 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
, у вас есть два варианта. Либо вы выполняете два запроса и извлекаете сущности либо в режиме чтения-записи, либо только для чтения, либо используете один запрос с оконными функциями для извлечения сущностей в режиме только для чтения.