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

Что нового в JPA 2.2 – Поток результатов выполнения запроса

Поддержка потока JPA 2.2 позволяет использовать функцию потока Java 1.8 для наборов результатов JDBC. В этой статье вы увидите, что потоковая передача или прокрутка набора результатов JDBC не так эффективны, как разбиение на страницы.

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

Теперь , когда Бюллетень для обзора JPA 2.2 был одобрен , давайте начнем анализировать некоторые новые дополнения к стандарту, которые уже довольно давно поддерживаются Hibernate. В этой статье мы рассмотрим, как результаты потоковых запросов JPA 2.2 поддерживаются Hibernate и предостережения об использовании курсоров базы данных только для ограничения объема данных, которые необходимо извлечь.

Вероятно одна из наиболее ожидаемых функций JPA 2.2 , Потоковые результаты запросов были добавлены по следующим причинам :

Чтение больших наборов данных с использованием JPA в наши дни довольно неудобно , так как все сигнатуры методов возвращают Списки , что приводит к тому, что весь “набор результатов” извлекается в память, прежде чем его можно будет передать клиентам.

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

Вышеупомянутая цитата была взята из связанного выпуска JPA 2.2 GitHub .

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

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

В то время как JPA уже предлагает разбиение на страницы SQL_level с помощью setFirstResult и setMaxResults Методы запроса , потоковая передача курсора базы данных не поддерживалась спецификацией JPA 2.1.

По умолчанию JPA Запрос всегда возвращает либо Список , либо один объект. Чтобы создать Список , необходимо выполнить навигацию по набору результатов JDBC , следовательно, все базовые данные будут извлечены для заполнения возвращаемого Списка (например, объекты, проекции DTO ).

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

Предполагая, что вы запустите следующий тестовый случай:

List posts = doInJPA(entityManager -> {
    try(Stream postStream = entityManager
        .createQuery(
            "select p " +
            "from Post p " +
            "order by p.createdOn desc", Post.class)
        .unwrap(Query.class)
        .stream()
    ) {
        return postStream
        .limit( 50 )
        .collect( 
            Collectors.toList() 
        );
    }
});

Метод Hibernate stream создает прокручиваемый файл JDBC Набор результатов вот так:

final ScrollableResultsImplementor scrollableResults = scroll( 
    ScrollMode.FORWARD_ONLY 
);

Однако к моменту вызова метода limit Hibernate уже сгенерировал следующую инструкцию SQL:

SELECT 
    p.id as id1_0_, 
    p.title as title2_0_ 
FROM 
    post p
ORDER BY
    p.created_on DESC

Обратите внимание на размер выборки драйвера JDBC

JDBC Набор результатов действует как курсор на уровне приложения, и драйвер JDBC должен решить, как будет извлечен базовый набор данных:

  • В Oracle размер выборки JDBC по умолчанию составляет всего 10 это означает, что для набора результатов из 100 записей потребуется 10 обходов базы данных.
  • В SQL Server используется метод адаптивной буферизации , позволяющий извлекать данные не сразу, а пакетами и только в случае необходимости для клиента.
  • В PostgreSQL и MySQL весь Результирующий набор извлекается сразу, даже если клиент не просматривает весь Результирующий набор .

Чтобы использовать пользовательский размер выборки, вам необходимо вызвать метод setFetchSize для объекта JDBC Оператор .

Для MySQL, чтобы использовать курсор, у вас есть два варианта:

Для PostgreSQL, чтобы использовать курсор базы данных, вам необходимо указать заданный размер выборки, как показано в следующем примере:

try(Stream postStream = entityManager
    .createQuery(
        "select p " +
        "from Post p " +
        "order by p.createdOn desc", Post.class)
    .setHint( QueryHints.HINT_FETCH_SIZE, 50 )
    .unwrap(Query.class)
    .stream()
) {
    return postStream
    .limit( 50 )
    .collect( 
        Collectors.toList() 
    );
}

Без подсказок Запроса. HINT_FETCH_SIZE JPA с помощью Hibernate будет использовать размер выборки по умолчанию//, что приведет к тому, что весь Набор результатов будет извлечен сразу на стороне клиента.

Некоторые идеи действительно звучат в теории, но на самом деле они оказываются неоптимальными. Некоторое время назад Марк Палуч открыл проблему HHH-11260 , которая требовала, чтобы Hibernate автоматически устанавливал Целое число. MIN_VALUE размер выборки при каждом вызове потока или прокрутите Запрос методы.

Однако, проводя тест производительности, мы поняли, что Потоковая передача MySQL извлечение всего набора результатов на самом деле более эффективна, если мы используем Набор результатов разбиение на страницы.

Забота о плане выполнения

Теперь инструкция SQL выполняется в три этапа:

  • Сначала оператор SQL анализируется в AST и создается дерево запросов
  • Во-вторых, для связанного дерева запросов создается План выполнения
  • В-третьих, выполняется План выполнения и Результирующий набор отправляется клиенту

Первый и второй шаги можно пропустить, если база данных использует кэш плана выполнения (например, Oracle, SQL Server).

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

Это связано с тем, что для чтения из индекса без покрытия требуется дополнительная операция чтения с произвольным доступом для соответствующей страницы, чтобы можно было также извлекать столбцы, не включенные в индекс. Поскольку произвольный доступ может стать очень дорогим при большом количестве операций, СУБД может выбрать последовательное сканирование, поскольку последовательные чтения выполняются быстрее, чем операции произвольного доступа.

Так , предполагая, что у нас есть следующий индекс базы данных:

CREATE INDEX idx_post_created_on ON post ( created_on DESC )

И, если у нас есть 5000 строк post и мы получим план выполнения для следующего потокового запроса:

List executionPlanLines = doInJPA(entityManager -> {
    try(Stream postStream = entityManager
        .createNativeQuery(
            "EXPLAIN ANALYZE " +
            "SELECT p " +
            "FROM post p " +
            "ORDER BY p.created_on DESC")
        .setHint( QueryHints.HINT_FETCH_SIZE, 50 )
        .unwrap(Query.class)
        .stream()
    ) {
        return postStream.collect( Collectors.toList() );
    }
});

LOGGER.info( "Execution plan: {}",
             executionPlanLines
             .stream()
             .map( line -> (String) line[0] )
             .collect( Collectors.joining( "\n" ) )
);

Мы получим следующий план выполнения:

Execution plan: 
  Sort  (cost=65.53..66.83 rows=518 width=564) (actual time=4.339..5.473 rows=5000 loops=1)
  Sort Key: created_on DESC
  Sort Method: quicksort  Memory: 896kB
  ->  Seq Scan on post p  (cost=0.00..42.18 rows=518 width=564) (actual time=0.041..1.833 rows=5000 loops=1)
Planning time: 1.840 ms
Execution time: 6.611 ms

Это плохо! PostgreSQL выбрал последовательное сканирование всей таблицы вместо использования индекса, поскольку предполагал, что мы собираемся извлечь все строки из таблицы post .

Вместо этого, если мы проинструктируем PostgreSQL, что нам нужно всего 50 записей:

List executionPlanLines = doInJPA(entityManager -> {
    return entityManager
        .createNativeQuery(
            "EXPLAIN ANALYZE " +
            "SELECT p " +
            "FROM post p " +
            "ORDER BY p.created_on DESC")
        .setMaxResults( 50 )
        .unwrap(Query.class)
        .getResultList();
});

LOGGER.info( "Execution plan: {}",
             executionPlanLines
             .stream()
             .collect( Collectors.joining( "\n" ) )
);

Теперь мы получаем следующий план выполнения:

Execution plan: 
Limit  (cost=0.28..25.35 rows=50 width=564) (actual time=0.038..0.051 rows=50 loops=1)
  ->  Index Scan using idx_post_created_on on post p  (cost=0.28..260.04 rows=518 width=564) (actual time=0.037..0.049 rows=50 loops=1)
Planning time: 1.511 ms
Execution time: 0.148 ms

Вот это уже больше похоже на правду! Вместо последовательного сканирования мы теперь получаем сканирование индекса, которое охватывает только 50 строк.

Без передачи ожидаемого размера “Результирующего набора” база данных будет использовать План выполнения, подходящий для случаев, когда необходимо извлечь все записи. Поэтому использование потоковой передачи через курсор базы данных, чтобы избежать извлечения всего “Набора результатов”, может привести к неоптимальному плану выполнения.

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

Однако для отправки данных клиенту разбиение на страницы-лучший способ гарантировать, что мы получим ровно столько данных, сколько нам нужно.

Хотя функция потоковой передачи запросов JPA 2.2 направлена на предотвращение получения слишком большого количества данных, вместо этого гораздо эффективнее использовать Набор результатов разбиение на страницы.

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