Всем привет, сегодня я хотел бы поделиться с вами историей про 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 и 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 из-за скорости перехода на него. С моим решением вы можете это сделать гораздо быстрее.
Всем спасибо за внимание и хорошего дня!)