В Spring Data 3.1 появилось новое API для итерирования по большому объему данных: Scrolling API. Давайте изучим его, посмотрим на практические применения с примерами кода и преимущества по сравнению со всем известным Pageable. А также разберём, чего Scrolling API пока не хватает, и можно ли его вообще брать в свои проекты. Если хотите выяснить, как с новым API в теории ускорить пагинацию больших данных, — заходите под кат.

Разбор API

Работа с API происходит через Derived Query Method, и тут мы видим первое большое отличие от изначального подхода с Pageable. Размер страницы жестко задается в самом методе и не может быть явно передан в качестве параметра запроса:

Window<Person> findFirst10ByLastnameOrderByFirstname(String lastname, ScrollPosition scrollPosition); 

Сразу видим два новых класса: Window и ScrollPosition. Window хранит результат, а ScrollPosition определяет точку, с которой мы хотим загрузить данные.

Window

Поглядим поближе на Window. Интерфейс похож на Page/Slice, за исключением того, что вместо методов для получения следующей страницы имеются методы для получения ScrollPosition некоторого элемента.

public interface Window<T> extends Streamable<T> { 
    int size(); 
 
    boolean isEmpty(); 
 
    List<T> getContent(); 
 
    default boolean isLast() { 
        ... 
    } 
 
    boolean hasNext(); 
 
    ScrollPosition positionAt(int index); 
 
    default ScrollPosition positionAt(T object) { 
        ... 
    } 
 
    <U> Window<U> map(Function<? super T, ? extends U> converter); 
} 

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

Window<User> users = repository.findFirst10ByLastnameOrderByFirstname(
    "Doe",
    ScrollPosition.offset()
); 
 
users = repository.findFirst10ByLastnameOrderByFirstname(
    "Doe",
    users.positionAt(users.size() - 1)
); 

Пример, конечно, искусственный, и, как правило, пагинация управляется с UI.

ScrollPosition

ScrollPosition бывает двух видов: offset и keyset. Offset фактически работает так же, как и Pageable: за счет добавления OFFSET в запрос к базе. KeySet работает несколько интереснее и модифицирует запрос, чтобы откинуть уже обработанные данные. Такой подход может дать рост производительности, так как обычная OFFSET пагинация требует повторного прохода по всем ранее загруженным данным. Таким образом, интерфейс имеет несколько фабричных методов для создания offset или keyset ScrollingPosition. Кроме того, keyset пагинация не чувствительна к вставке и удалению уже просмотренных данных. Зато она чувствительна к модификации (но ведь это ожидаемое поведение?)

public interface ScrollPosition { 
    static KeysetScrollPosition keyset() { 
        ... 
    } 
 
    static OffsetScrollPosition offset() { 
        ... 
    } 
 
    static OffsetScrollPosition offset(long offset) { 
        ... 
    } 
 
    static KeysetScrollPosition forward(Map<String, ?> keys) { 
        ... 
    } 
 
    static KeysetScrollPosition backward(Map<String, ?> keys) { 
        ... 
    } 
 
    static KeysetScrollPosition of(Map<String, ?> keys, Direction direction) { 
        ... 
    } 
}

А как это работает?

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

spring.jpa.show-sql=true

Тестировать будем такой вот метод:

public interface PersonRepository extends JpaRepository<Person, Long> { 
    Window<Person> findFirst100ByLastnameOrderByFirstname(
        String lastname,
        ScrollPosition scrollPosition
    );
} 

Сначала передадим ScrollPosition.offset():

Window<Person> personWindow = personRepository.findFirst100ByLastnameOrderByFirstname(
    lastname, 
    ScrollPosition.offset(offset)
);

Видим в логах вот такой запрос:

select 
    p1_0.id,p1_0.age,p1_0.firstname,p1_0.lastname 
from person p1_0 
where 
    p1_0.lastname=?
order by p1_0.firstname
offset ? rows fetch first ? rows only

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

Давайте посмотрим, как обстоят дела с KeySet. Грузим первую страницу:

Window<Person> personWindow = personRepository.findFirst100ByLastnameOrderByFirstname( 
    lastname, 
    ScrollPosition.keyset() 
); 

В логах видим:

select
    p1_0.id,p1_0.age,p1_0.firstname,p1_0.lastname 
from person p1_0 
where 
    p1_0.lastname=? order by p1_0.firstname,p1_0.id 
fetch first ? rows only

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

personRepository.findFirst100ByLastnameOrderByFirstname( 
    lastname, 
    personWindow.positionAt(personWindow.size()  -  1) 
);

В логах видим уже более сложный запрос.

select 
    p1_0.id,p1_0.age,p1_0.firstname,p1_0.lastname 
from person p1_0 
where 
    p1_0.lastname=? and (p1_0.firstname>? or p1_0.firstname=? and p1_0.id>?) 
    order by p1_0.firstname,p1_0.id
    fetch first ? rows only 

Запрос становится еще сложнее, у нас изменилось условие в WHERE. Но какие параметры будут в него переданы? Можно ответить на этот вопрос, посмотрев на содержимое ScrollingPosition перед его исполнением. 

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

Ограничения Keyset пагинации

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

Второе ограничение — отсутствие номера страницы. Мы не можем грузить данные с какого-то произвольного места, и, так или иначе, нам придется последовательно перебирать их. Но, справедливости ради, в offset пагинации это так или иначе тоже происходит. 

И, напоследок, поля сортировки должны быть non-nullable. Это явно отражено в документации и связано с особенностью работы операторов сравнения.

Загрузка данных с клиента

Я не нашел предпочтительного способа управления скроллингом со стороны клиента. Если вернуть в REST Controller Window, то ScrollPosition мы в нем не увидим. Я нашел класс OffsetScrollPositionHandlerMethodArgumentResolver для парсинга OffsetScrollPosition в качестве параметра метода контроллера. Но не нашел ничего подобного для KeySetScrollPosition, так что реализация тут полностью ложится на плечи разработчика. Но принцип будет такой, что с клиента должен прийти последний загруженный объект, после которого мы хотим грузить остальные. И конструировать ScrollPosition надо будет вручную.

@GetMapping("/by-lastname-keyset/{lastname}") 
public Window<Person> findFirst100ByLastnameKeysetAfter( 

    @PathVariable String lastname, 

    @RequestParam @Nullable String lastFirstname,  

    @RequestParam @Nullable String lastId 

) { 
    Window<Person> personWindow = personRepository.findFirst100ByLastnameOrderByFirstname( 
        lastname, 
        lastFirstname != null && lastId != null ? 
            ScrollPosition.of(Map.of("firstname", lastFirstname, "id", lastId), ScrollPosition.Direction.FORWARD) : 
            ScrollPosition.keyset() 
    ); 
 
    return personWindow; 
} 

Что интересно, в Spring for GraphQL поддержали и ScrollPosition и Window! Неужели GraphQL обгоняет ресты? Важная деталь - в Spring for GraphQL ScrollSubrange помимо ScrollPosition принимает и count, так что придется написать некоторую дополнительную логику для загрузки нужного количества записей.

WindowIterator

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

WindowIterator<User> users = WindowIterator.of(
    position -> repository.findFirst10ByLastnameOrderByFirstname("Doe", position)
).startingAt(ScrollPosition.keyset()); 
 
while (users.hasNext()) { 
  User u = users.next(); 
  // consume the user 
} 

Такой подход позволит не словить OutOfMemory при итерации по большим объемам данных, позволяя обрабатывать их частями. Однако, хочется заметить, что это можно было делать и раньше с помощью Stream и QueryHints:

@QueryHints(  
	@QueryHint(name = HINT_FETCH_SIZE, value = "25")  
) 
@Query("select b from BigTable b")  
Stream<BigTable> getAll();

Погоняем данные?

А тут начинается самое интересное. Конечно, я решил прогнать оба вида пагинации на большом наборе данных. Если быть точным, на табличке с 10М записей. По одному полному проходу каждым методом, база данных - PostgreSQL. И какие же результаты я получил? 

Для тестов я сделал два метода в своем рест контроллере:

@GetMapping("/traverse-lastname-offset/{lastname}")
public void traverseOffset(@PathVariable String lastname) {
    long start = System.currentTimeMillis();

    ScrollPosition offset = ScrollPosition.offset();
    Window<Person> personWindow;
    do {
        personWindow = personRepository.findFirst100ByLastnameOrderByFirstname(lastname, offset);
        offset = personWindow.positionAt(personWindow.size() - 1);
    } while (personWindow.hasNext());

    long time = System.currentTimeMillis() - start;
    log.info("offsetTime: " + time);
}

@GetMapping("/traverse-lastname-keyset/{lastname}")
public void traverseKeySet(@PathVariable String lastname) {
    long start = System.currentTimeMillis();

    ScrollPosition offset = ScrollPosition.keyset();
    Window<Person> personWindow;
    do {
        personWindow = personRepository.findFirst100ByLastnameOrderByFirstname(lastname, offset);
        if (personWindow.hasNext()) {
            offset = personWindow.positionAt(personWindow.size() - 1);
        }
    } while (personWindow.hasNext());

    long time = System.currentTimeMillis() - start;
    log.info("keysetTime: " + time);
}

Дергаем по очереди эндпоинты и получаем такие результаты: 
offsetTime: 350 119 мс
keysetTime: 3 275 537 мс

И… что-то не сходится. Почему новая супер-пупер пагинация работает в 10 раз медленней? Сначала это поставило меня в тупик, и я пошел пересматривать доклад братьев Сазоновых про KeySet пагинацию. Там была одна небольшая ремарка, которая и ответила на мой вопрос.

Вернемся к запросу, который для нас сгенерировала Spring Data (а Hibernate транслировал в sql). Продублирую его:

select 
    p1_0.id,p1_0.age,p1_0.firstname,p1_0.lastname 
from person p1_0 
where 
    p1_0.lastname=? and (p1_0.firstname>? or p1_0.firstname=? and p1_0.id>?) 
order by p1_0.firstname,p1_0.id 
    fetch first ? rows only

Проблема находится вот тут: p1_0.firstname>? or p1_0.firstname=? and p1_0.id>? и корень всего зла - оператор or.

СУБД приходится два раза проходить по индексу firstname и после этого склеивать результаты, что очень быстро вырождается чуть ли не в full scan. Однако, если переписать это вот так: p1_0.firstname>=? and (p1_0.firstname > ? or p1_0.id>?), что логически то же самое, СУБД справляется на ура. Я решил воспроизвести это вручную. 

public interface PersonRepository extends JpaRepository<Person, Long> {
    Window<Person> findFirst100ByLastnameOrderByFirstname(String lastname, ScrollPosition scrollPosition);

    // запрос первой страницы 
    @Query("SELECT p FROM Person p WHERE p.lastname = :lastname ORDER BY p.firstname, p.id")
    List<Person> findPersonsByLastname(
        @Param("lastname") String lastname,
        Pageable pageable);


    // запрос второй страницы 
    @Query("SELECT p FROM Person p WHERE p.lastname = :lastname AND (p.firstname >= :firstname AND (p.firstname > :firstname OR p.id > :id)) ORDER BY p.firstname, p.id")
    List<Person> findPersonsByLastnameAndFirstnameAndId(
        @Param("lastname") String lastname,
        @Param("firstname") String firstname,
        @Param("id") Long id,
        Pageable pageable);

}

// контроллер

@GetMapping("/traverse-lastname-keyset-manual/{lastname}")
public void traverseKeySetManual(@PathVariable String lastname) {
    long start = System.currentTimeMillis();

    Pageable pageable = Pageable.ofSize(100);

    Person lastPerson = null;
    List<Person> personWindow = personRepository.findPersonsByLastname(lastname, pageable);
    while (!personWindow.isEmpty()) {
        lastPerson = personWindow.get(personWindow.size() - 1);

        personWindow = personRepository.findPersonsByLastnameAndFirstnameAndId(
            lastname,
            lastPerson.getFirstname(),
            lastPerson.getId(),
            pageable
        );
    }

    long time = System.currentTimeMillis() - start;
    log.info("keysetManualTime: " + time);
}

Получаем: 81 652 мс. Что примерно в 4.2 раза быстрее, чем offset. Но только это не Scrolling API...

Кто же виноват, и что делать? Я не лез глубоко в кишки Spring Data JPA, однако коллеги мне подсказали, что возможно виноват Hibernate, так как у него тоже есть такой вариант пагинации. Я написал еще один Rest эндпоинт для теста: 

@GetMapping("/traverse-lastname-keyset-hibernate/{lastname}") 
public void traverseKeySetHibernate(@PathVariable String lastname) { 
    long start = System.currentTimeMillis(); 
 
    Session session = entityManager.unwrap(Session.class); 
 
    Query<Person> query = session.createQuery("select p from Person p where p.lastname=:lastname", Person.class) 
        .setParameter("lastname", lastname); 
 
    KeyedPage<Person> keyedPage = Page.first(100) 
        .keyedBy(List.of( 
            Order.asc(Person.class, "firstname"),  
           Order.asc(Person.class, "id") 
        )); 
     
   KeyedResultList<Person> keyedResultList; 
    do { 
        keyedResultList = query 
            .getKeyedResultList(keyedPage); 
 
        keyedPage = keyedResultList.getNextPage(); 
    } while (!keyedResultList.isLastPage()); 
 
    long time = System.currentTimeMillis() - start; 
    log.info("keysetHibernateTime: " + time); 
} 

И что я вижу в логах? 

select 
    p1_0.id,p1_0.age,p1_0.firstname,p1_0.lastname 
from person p1_0 
where 
    p1_0.lastname=? and (p1_0.firstname>'Amber' or p1_0.id>652194 and p1_0.firstname='Amber') 
    order by p1_0.firstname,p1_0.id 
    offset ? rows fetch first ? rows only

 Опять неэффективный запрос, время работы сравнимое.

Хорошо, может это все-таки вина СУБД? Попробуем погонять на mysql? Вводные те же, 10 миллионов записей, запускаем тесты... В логах видим тот же “не эффективный” запрос, однако смотрим на цифры: 

offsetTime: 690 711 мс 

keysetTime: 365 627 мс

keysetManualTime: 338 057 мс

KeySet работает в 2 раза быстрее, при этом, разница между реализацией Spring Data и “ручным” вариантом почти не существенная. Это значит, что планировщик MySQL запрос скорее всего оптимизирует.

Вместо заключения

Подразумевалось, что новое API поможет ускорить пагинацию по большим данным. Но вопросики остаются не маленькие. Генерируется запрос, который не может нормально обработаться PostgreSQL. Кто-то может сказать, что это проблема PostgreSQL, ведь на MySQL все окей? Я бы так не сказал, такое ощущение, что новый API еще никем не используется, и это подтверждается количеством issue на Github. А первый релиз вышел уже год назад!

Если вы хотите больше погрузиться в различные аспекты пагинации, советую посмотреть доклад братьев Сазоновых с JPoint 2023 https://www.youtube.com/watch?v=wi6h9ox1wwM, в котором, в том числе, упоминается и финт с индексом.

Репозиторий с кодом https://github.com/alexander-shustanov/scrolling-api.

P.S.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм - Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Ждем всех, присоединяйтесь!

Комментарии (3)


  1. RustamKuramshin
    04.06.2024 12:58
    +3

    Хорошее исследование. Может стоит указать на какой версии PostgreSQL тестировалось, вроде 16-я версия привнесла много оптимизаций.
    Логгер хибера не очень надежный. Были случаи когда он просто не писал запросы, которые летели в БД. Можно еще подложить конфиг для PostgreSQL и в его логах смотреть запросы.
    Был ли какой-то "разогрев" системы на тестах? При каких RPS тестировалось?


    1. spring_aio Автор
      04.06.2024 12:58
      +2

      PostgreSQL поднимал через docker compose, postgres:latest, соотвествтено это 16ый

      Тест был достаточно простой, REST контроллер я сделал просто для удобной ручки. И в методе прогружались в цикле все страницы, один конкурентный пользователь)

      Тесты проводил несколько раз, но в статье не отобразил. Можно сказать, что разогрев был


      1. RustamKuramshin
        04.06.2024 12:58
        +1

        Ок, понятно