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

Почему лучше избегать дефолтной пагинации в Spring?

Давайте посмотрим на самый простой репозиторий в spring:

public interface UserRepository extends JpaRepository<User, Long> {
    Page<User> findAll(Pageable pageable);
}

Старый добрый Pageable в который мы передаем номер страницы, лимит и тд.

Вызов метода:

Pageable pageable = PageRequest.of(1, 10, Sort.by("name").ascending()); //для получения не рандомных значений использовать Sort обязательно!!
userRepository.findAll(pageable);

Что в таком случае происходит под капотом?

запрос номер 1 -
select u.id, u.name from user u 
order by u.name asc
limit 10 offset 10;

запрос номер 2 -
select count(u.id) from user u;

Мы получаем 2 SQL запроса: первый для получения данных по пользователю, второй для подсчета total(общее кол-во записей). Сразу хочу сказать, что нам не всегда нужен подсчет total, иногда мы просто хотим пройтись по всем объектам и считать total не нужно, для таких вариантов используйте Slice<T>, это очень хорошая практика, чтобы не вызывать лишние запросы:

public interface UserRepository extends JpaRepository<User, Long> {
    Slice<User> findAll(Pageable pageable);
}

Давайте представим, что у нас в бд 100 записей и мы хотим получить 8 страницу. Выполнится запрос в бд:

select id, name from user
order by name
limit 10 offset 70;

Но тут есть серьезная проблема - Spring по дефолту используется offset pagination.

Когда ты пишешь OFFSET 70базе приходится:

  1. Прочитать строки, отсортировать их (если есть ORDER BY);

  2. Пропустить первые 70 строк;

  3. Вернуть следующие 10.

Эти 70 строк не возвращаются клиенту, но СУБД всё равно их считывает (сканирует, сортирует, отбрасывает)

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

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

Примерно использовали так:

int pageSize = 100;
int pageNumber = 0;

Page<User> page = userRepository.findAll(PageRequest.of(pageNumber, pageSize, Sort.by("id")));

while (page.hasContent()) {
    // реальный метод опущен из-за ненадобности. Тут может быть любой ваш метод,
    // например поход в смежную систему, преобразования и тд
    page.getContent().forEach(user -> {
        System.out.println(user.getId() + " " + user.getName());
    });

    if (!page.hasNext()) break;

    pageNumber++;
    page = userRepository.findAll(PageRequest.of(pageNumber, pageSize, Sort.by("id")));
}

Вроде самый обычный код и когда мы его использовали на небольших объемах данных, все просто летало и никто не жаловался. В какой-то момент запросы начали выполняться по 5–10 минут, а позже доходили почти до часа. Конечно, пользователи и бизнес начали сильно ругаться, потому что они не собирались с этим мириться. Нужно было найти лучшее решение в кратчайшие сроки.

В интернете я наткнулся на Keyset pagination:

Что такое Keyset pagination?

Я знаю, что многие уже знают про keyset, но для тех кто не знал, расскажу.

Keyset pagination (или seek pagination) — это способ постраничного получения данных без использования OFFSET, вместо этого ты продолжаешь выборку от последнего элемента предыдущей страницы по какому-то ключу (обычно id):

SELECT id, name 
FROM users
WHERE id > 1000
ORDER BY id
LIMIT 10;

✅ Преимущества:

  • БД не сканирует тысячи строк — сразу идёт по индексу id.

  • Скорость стабильная, даже при миллионах строк.

  • Нет проблем со сдвигами, если данные добавляются.

Если ты используешь keyset pagination, то все поля, участвующие в WHERE и ORDER BY, должны быть покрыты индексом (или составным индексом).

Без индекса СУБД:

  • выполнит seq scan (чтение всей таблицы),

  • для каждой строки проверит WHERE,

  • отсортирует результат вручную,

  • и только потом возьмёт LIMIT.

То есть keyset-пагинация потеряет весь смысл — она будет не быстрее, чем offset.

Как подключить Keyset pagination в Spring на примере Blaze-Persistence

Мы поняли, что быстро переписать все на keyset не выйдет, пришлось искать готовое качественное решение. Тут нас спас Blaze-Persistence

Blaze-Persistence (Blaze-Persist / Blazebit) — это Java библиотека для JPA/Hibernate, которая добавляет мощный SQL-подобный DSL для сложных запросов, включая:

  • Keyset-pagination (seek-pagination) из коробки

  • Window functions

  • Dynamic entity views (DTO-проекции без N+1)

  • Поддержку сложных JOIN и подзапросов

То есть это как надстройка над JPA, чтобы писать эффективные и читаемые запросы для больших данных и API, без ручного SQL.

Gradle зависимости, которые подключали мы:

implementation 'com.blazebit:blaze-persistence-core-api:1.6.11'
implementation 'com.blazebit:blaze-persistence-integration-hibernate-5.6:1.6.11'
implementation 'com.blazebit:blaze-persistence-jpa-criteria-api:1.6.11'
implementation 'com.blazebit:blaze-persistence-jpa-criteria-impl:1.6.1

Сейчас я вам покажу боевое решение, которое мы применяли в продакшене. Мы написали свой репозиторий ТОЛЬКО для чтения данных. Он полностью готов к бою и использованию, не нужно ничего придумывать и дописывать. Он покрыт java doc с описанием методов:

/**
 * KeySet репозиторий для работы с постраничной выборкой, где вместо offset используется keySet
 *
 * @param <DOMAIN> - объект сущности
 */
@RequiredArgsConstructor
@Transactional(readOnly = true)
public abstract class CustomKeySetRepository<DOMAIN, ID> {

    private static final String ID = "id";

    @PersistenceContext
    protected EntityManager entityManager;

    protected final CriteriaBuilderFactory criteriaBuilderFactory;

    /**
     * Метод для распознавания класса сущности
     *
     * @return класс сущности
     */
    public abstract Class<DOMAIN> getDomainClass();

    /**
     * Метод для распознавания класса идентификатора сущности
     *
     * @return класс идентификатора сущности
     */
    public abstract Class<ID> getDomainIdClass();

    /**
     * Метод поиска количества всех записей
     *
     * @return количество всех записей
     */
    public Long findCount() {
        return this.findCount(null);
    }

    /**
     * Метод поиска количества всех записей
     *
     * @param specification - спецификация для фильтрации
     * @return количество всех записей после фильтра
     */
    public Long findCount(BlazeSpecification<DOMAIN> specification) {
        return this.getIdCriteriaBuilder(Sort.by(CustomKeySetRepository.ID), specification).getQueryRootCountQuery().getSingleResult();
    }

    /**
     * Метод поиска всех id, которые отсортированы по дефолту как Sort.Direction.ASC
     *
     * @return все id после фильтра
     */
    public List<ID> findAllIds() {
        return this.findAllIds(null, Sort.unsorted());
    }

    /**
     * Метод поиска всех id
     *
     * @param sort - указывает, как нужно сортировать идентификаторы сущности
     * @return все id после фильтра
     */
    public List<ID> findAllIds(Sort sort) {
        return this.findAllIds(null, sort);
    }

    /**
     * Метод поиска всех id
     *
     * @param sort          - указывает, как нужно сортировать идентификаторы сущности
     * @param specification - спецификация для фильтрации
     * @return все id после фильтра
     */
    public List<ID> findAllIds(BlazeSpecification<DOMAIN> specification, Sort sort) {
        return this.getIdCriteriaBuilder(sort, specification).getResultList();
    }

    /**
     * Метод поиска всех объектов по идентификаторам
     *
     * @param sort          - указывает, как нужно сортировать идентификаторы сущности
     * @param specification - спецификация для фильтрации
     * @param collection - коллекция из идентификаторов
     * @return список записей сущности
     */
    public List<DOMAIN> findAllByIds(Collection<ID> collection, Sort sort, BlazeSpecification<DOMAIN> specification) {
        BlazeSpecification<DOMAIN> idSpecification = (root, query, criteriaBuilder) -> root.get("id").in(collection);
        return this.findAll(idSpecification.and(specification), sort);
    }

    /**
     * Метод поиска всех записей
     *
     * @return все записи из базы данных
     */
    public List<DOMAIN> findAll() {
        return this.findAll(Sort.unsorted());
    }

    /**
     * Метод поиска всех записей
     *
     * @return все записи из базы данных
     */
    public List<DOMAIN> findAll(BlazeSpecification<DOMAIN> specification) {
        return this.findAll(specification, Sort.unsorted());
    }

    /**
     * Метод поиска всех записей
     *
     * @return все записи из базы данных
     */
    public List<DOMAIN> findAll(Sort sort) {
        return this.findAll(null, sort);
    }

    /**
     * Метод поиска всех записей
     *
     * @param specification - спецификация для фильтрации
     * @return все записи из базы данных после фильтра
     */
    public List<DOMAIN> findAll(BlazeSpecification<DOMAIN> specification, Sort sort) {
        return this.sortedCriteriaBuilder(sort, specification).getResultList();
    }

    /**
     * Метод, который выбирает первую страницу, для последующего поиска элементов
     *
     * @param pageable      - объект pageable для сортировки и установки количества объектов на странице
     * @param specification - спецификация для фильтрации
     * @return - PagedList лист с идентификаторами сущностей
     */
    public PagedList<ID> findTopIds(Pageable pageable, BlazeSpecification<DOMAIN> specification) {
        CriteriaBuilder<ID> criteriaBuilder = this.getIdCriteriaBuilder(pageable.getSort(), specification);

        int firstResult = this.calculateFirstResult(pageable);
        return criteriaBuilder.page(firstResult, pageable.getPageSize())
                .withKeysetExtraction(true)
                .getResultList();
    }

    /**
     * Метод, который выбирает первую страницу, для последующего поиска элементов, без фильтрации
     *
     * @param pageable - объект pageable для сортировки и установки количества объектов на странице
     * @return - PagedList лист с идентификаторами сущностей
     */
    public PagedList<ID> findTopIds(Pageable pageable) {
        return this.findTopIds(pageable, null);
    }

    /**
     * Метод, который ищет последующие N записей
     *
     * @param direction     - указывает, как нужно сортировать идентификаторы сущности, по убыванию или возрастанию
     * @param previousPage  - прошлая страница, из нее считается минимальный id и происходит keySet пагинация
     * @param specification - спецификация для фильтрации
     * @return PagedList - лист сущностей
     */
    public PagedList<ID> findNextIds(PagedList<ID> previousPage, Sort.Direction direction, BlazeSpecification<DOMAIN> specification) {
        CriteriaBuilder<ID> idCriteriaBuilder = this.getIdCriteriaBuilder(Sort.by(direction, CustomKeySetRepository.ID), specification);
        return this.getNextPagedList(idCriteriaBuilder, previousPage);
    }

    /**
     * Метод, который ищет последующие N записей, без сортировки
     *
     * @param direction    - указывает, как нужно сортировать идентификаторы сущности, по убыванию или возрастанию
     * @param previousPage - прошлая страница, из нее считается минимальный id и происходит keySet пагинация
     * @return PagedList - лист сущностей
     */
    public PagedList<ID> findNextIds(PagedList<ID> previousPage, Sort.Direction direction) {
        return this.findNextIds(previousPage, direction, null);
    }

    /**
     * Метод, который выбирает первую страницу, для последующего поиска элементов
     *
     * @param pageable      - объект pageable для сортировки и установки количества объектов на странице
     * @param specification - спецификация для фильтрации
     * @return PagedList - лист сущностей
     */
    public PagedList<DOMAIN> findTopN(Pageable pageable, BlazeSpecification<DOMAIN> specification) {
        int firstResult = this.calculateFirstResult(pageable);
        return this.sortedCriteriaBuilder(pageable.getSort(), specification)
                .page(firstResult, pageable.getPageSize())
                .withKeysetExtraction(true)
                .getResultList();
    }

    /**
     * Метод, который выбирает первую страницу, для последующего поиска элементов
     *
     * @param pageable - объект pageable для сортировки и установки количества объектов на странице
     * @return PagedList - лист сущностей
     */
    public PagedList<DOMAIN> findTopN(Pageable pageable) {
        return this.findTopN(pageable, null);
    }

    /**
     * Метод, который ищет последующие N записей
     *
     * @param sortBy       - сортировка для выборки
     * @param previousPage - прошлая страница, из нее считается минимальный id и происходит keySet пагинация
     * @param predicate    - predicate для фильтрации
     * @return PagedList - лист сущностей
     */
    public PagedList<DOMAIN> findNextN(Sort sortBy, PagedList<DOMAIN> previousPage, BlazeSpecification<DOMAIN> predicate) {
        CriteriaBuilder<DOMAIN> domainCriteriaBuilder = this.sortedCriteriaBuilder(sortBy, predicate);
        return this.getNextPagedList(domainCriteriaBuilder, previousPage);
    }

    /**
     * Метод, который ищет последующие N записей
     *
     * @param sortBy       - сортировка для выборки
     * @param previousPage - прошлая страница, из нее считается минимальный id и происходит keySet пагинация
     * @return PagedList - лист сущностей
     */
    public PagedList<DOMAIN> findNextN(Sort sortBy, PagedList<DOMAIN> previousPage) {
        return this.findNextN(sortBy, previousPage, null);
    }

    /**
     * Метод, который собирает CriteriaBuilder для фильтрации и сортировки объектов
     *
     * @param sort          - сортировка для выборки
     * @param specification - спецификация для фильтрации
     * @return CriteriaBuilder для построения запроса
     */
    protected CriteriaBuilder<DOMAIN> sortedCriteriaBuilder(Sort sort, BlazeSpecification<DOMAIN> specification) {
        BlazeCriteriaBuilder cb = BlazeCriteria.get(criteriaBuilderFactory);
        BlazeCriteriaQuery<DOMAIN> query = cb.createQuery(getDomainClass());
        BlazeRoot<DOMAIN> root = query.from(getDomainClass());

        this.addFilterToQuery(specification, cb, query, root);

        CriteriaBuilder<DOMAIN> criteriaBuilder = query.createCriteriaBuilder(entityManager);
        this.makePageableOrDefaultSort(sort, criteriaBuilder);
        return criteriaBuilder;
    }

    /**
     * Метод, который создает CriteriaBuilder для поиска по id сущности
     *
     * @param sort          - объект для сортировки
     * @param specification - спецификация для фильтрации
     * @return CriteriaBuilder<ID> - готовая критерия
     */
    protected CriteriaBuilder<ID> getIdCriteriaBuilder(Sort sort, BlazeSpecification<DOMAIN> specification) {
        BlazeCriteriaBuilder cb = BlazeCriteria.get(criteriaBuilderFactory);
        BlazeCriteriaQuery<ID> query = cb.createQuery(getDomainIdClass());
        BlazeRoot<DOMAIN> root = query.from(getDomainClass());

        query.select(root.get(CustomKeySetRepository.ID));

        this.addFilterToQuery(specification, cb, query, root);

        sort = sort.getOrderFor(CustomKeySetRepository.ID) == null
                ? sort.and(Sort.by(CustomKeySetRepository.ID))
                : sort;
        CriteriaBuilder<ID> criteriaBuilder = query.createCriteriaBuilder(entityManager);
        this.makePageableOrDefaultSort(sort, criteriaBuilder);
        return criteriaBuilder;
    }

    /**
     * Метод, который добавляет фильтрация в запрос
     *
     * @param specification - specification, по которой нужно отфильтровать
     * @param cb            - BlazeCriteriaBuilder
     * @param query         - сам запрос, куда нужно добавить фильтрацию
     * @param root          - корневой объект сущности
     */
    protected void addFilterToQuery(BlazeSpecification<DOMAIN> specification, BlazeCriteriaBuilder cb,
                                    BlazeCriteriaQuery<?> query, BlazeRoot<DOMAIN> root) {
        if (specification != null) {
            Predicate predicate = specification.toPredicate(root, query, cb);
            query.where(predicate);
        }
    }

    /**
     * Метод, который добавляет сортировку к запросу, если же она не указана, то сортировка происходит по полю id
     *
     * @param sort            - объект сортировки
     * @param criteriaBuilder - criteriaBuilder для сортировки
     */
    protected void makePageableOrDefaultSort(Sort sort, CriteriaBuilder<?> criteriaBuilder) {
        sort = sort.isUnsorted()
                ? Sort.by(Sort.Order.asc(CustomKeySetRepository.ID))
                : sort;
        sort.forEach(order -> criteriaBuilder.orderBy(
                order.getProperty(),
                order.isAscending()
        ));
    }

    /**
     * Метод, который позволяет достать следующую страницу объектов, основываясь на старой.
     *
     * @param criteriaBuilder - criteriaBuilder для запроса
     * @param previousPage    - предыдущая страница
     * @param <T>             - класс, объект которого получится в итоге
     * @return - PagedList<T> следующая страница объектов
     */
    protected <T> PagedList<T> getNextPagedList(CriteriaBuilder<T> criteriaBuilder, PagedList<T> previousPage) {
        return criteriaBuilder
                .page(
                        previousPage.getKeysetPage(),
                        previousPage.getPage() * previousPage.getMaxResults(),
                        previousPage.getMaxResults()
                )
                .getResultList();
    }

    /**
     * Метод, который считает первый элемент для поиска объектов
     *
     * @param pageable - из pageable определяется первый элемент по номеру страницы и ее размеру
     * @return - номер первого элемента для выборки
     */
    protected int calculateFirstResult(Pageable pageable) {
        return pageable.getPageNumber() * pageable.getPageSize();
    }
}

Также код BlazeSpecification, так как она кастомная:

/**
 * Объект спецификации, который использует Blaze Persist объекты
 *
 * @param <T> - объект сущности
 */
@FunctionalInterface
public interface BlazeSpecification<T> {

    /**
     * Метод для реализации функционального интерфейса для работы с фильтрами
     * @param root - корневой объект
     * @param query - запрос, в который добавляется фильтрация
     * @param criteriaBuilder - объект для сборки предиката
     * @return - предикат фильтра
     */
    Predicate toPredicate(BlazeRoot<T> root, BlazeCriteriaQuery<?> query, BlazeCriteriaBuilder criteriaBuilder);

    static <E> BlazeSpecification<E> toBlazeSpecification(Specification<E> specification) {
        return specification::toPredicate;
    }

    /**
     * Метод объединения спецификаций для получения общего объекта. Работает как 'и' сужая вариант выборки.
     *
     * @param addedSpec - спецификация, которую нужно связать с текущей
     * @return - Объединенная спецификация
     */
    default BlazeSpecification<T> and(BlazeSpecification<T> addedSpec) {
        return (root, query, criteriaBuilder) -> {
            Predicate thisPredicate = this.toPredicate(root, query, criteriaBuilder);
            return addedSpec == null
                    ? thisPredicate
                    : criteriaBuilder.and(thisPredicate, addedSpec.toPredicate(root, query, criteriaBuilder));
        };
    }

    /**
     * Метод объединения спецификаций для получения общего объекта. Работает как 'или' расширяя вариант выборки.
     *
     * @param addedSpec - спецификация, которую нужно связать с текущей
     * @return - Объединенная спецификация
     */
    default BlazeSpecification<T> or(BlazeSpecification<T> addedSpec) {
        return (root, query, criteriaBuilder) -> {
            Predicate thisPredicate = this.toPredicate(root, query, criteriaBuilder);
            return addedSpec == null
                    ? thisPredicate
                    : criteriaBuilder.or(thisPredicate, addedSpec.toPredicate(root, query, criteriaBuilder));
        };
    }
}

Самое основное, что тут есть:

criteriaBuilder.page(firstResult, pageable.getPageSize())
 .withKeysetExtraction(true)
 .getResultList();

.withKeysetExtraction(true)

  • Включает keyset-пагинацию.

  • Blaze-Persistence анализирует сортировку (ORDER BY) и запоминает ключи последнего элемента страницы.

  • При следующем вызове .page(...) можно сразу продолжить выборку без сканирования предыдущих строк.

Вот пример нашего прошлого метода с дефолтной пагинацией:

public void fetchAllUsersInBatches() {
        int pageSize = 100;

        // Начальная страница
        Pageable pageable = PageRequest.of(0, pageSize, Sort.by("id").ascending());

        // Берём первую пачку через keyset
        PagedList<User> currentBatch = userRepository.findTopN(pageable);

        while (!currentBatch.isEmpty()) {
            
            List<User> users = currentBatch.getResultList();
            users.forEach(user -> System.out.println(user.getId() + " " + user.getName()));

            // Берём следующую пачку через keyset
            currentBatch = userRepository.findNextN(Sort.by("id"), currentBatch);
        }
    }

Теперь наш код работает на keyset пагинации и выборка происходит гораздо быстрее, в нашем случае мы сократили выборку всех записей с +- часа до 5-8 минут!

Итог

Сегодня я вам показал кейс из реального проекта, который может стать полезен и вам. Советую отказаться от дефолтной пагинации в Spring, особенно, если видится рост данных или вы собираете большие объемы данных. Реализовать keyset пагинацию можно многими путями, мы же выбрали Blaze persistence из-за скорости перехода на него. С моим решением вы можете это сделать гораздо быстрее.

Всем спасибо за внимание и хорошего дня!)

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