Всем привет, сегодня я хотел бы поделиться с вами историей про 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базе приходится:
Прочитать строки, отсортировать их (если есть
ORDER BY);Пропустить первые 70 строк;
Вернуть следующие 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(которое отвечает за фильтр для keyset) и 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 из-за скорости перехода на него. С моим решением вы можете это сделать гораздо быстрее.
Всем спасибо за внимание и хорошего дня!)