Технологии

Как дефолтная пагинация в Spring сломала проект и как это починить

Всем привет, сегодня я хотел бы поделиться с вами историей про Spring пагинацию, почему она ужасна, как она вызвала кучу проблем и как ее починить. Почему лучше избегать дефолтной пагинации в Spring? Давайте посмотрим на самый простой репозиторий в spring: public interface UserRepository extends JpaRepository { Page 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, это очень хорошая практика, чтобы не вызывать лишние запросы: public interface UserRepository extends JpaRepository { Slice 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 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 - объект сущности */ @RequiredArgsConstructor @Transactional(readOnly = true) public abstract class CustomKeySetRepository { private static final String ID = "id"; @PersistenceContext protected EntityManager entityManager; protected final CriteriaBuilderFactory criteriaBuilderFactory; /** * Метод для распознавания класса сущности * * @return класс сущности */ public abstract Class getDomainClass(); /** * Метод для распознавания класса идентификатора сущности * * @return класс идентификатора сущности */ public abstract Class getDomainIdClass(); /** * Метод поиска количества всех записей * * @return количество всех записей */ public Long findCount() { return this.findCount(null); } /** * Метод поиска количества всех записей * * @param specification - спецификация для фильтрации * @return количество всех записей после фильтра */ public Long findCount(BlazeSpecification specification) { return this.getIdCriteriaBuilder(Sort.by(CustomKeySetRepository.ID), specification).getQueryRootCountQuery().getSingleResult(); } /** * Метод поиска всех id, которые отсортированы по дефолту как Sort.Direction.ASC * * @return все id после фильтра */ public List findAllIds() { return this.findAllIds(null, Sort.unsorted()); } /** * Метод поиска всех id * * @param sort - указывает, как нужно сортировать идентификаторы сущности * @return все id после фильтра */ public List findAllIds(Sort sort) { return this.findAllIds(null, sort); } /** * Метод поиска всех id * * @param sort - указывает, как нужно сортировать идентификаторы сущности * @param specification - спецификация для фильтрации * @return все id после фильтра */ public List findAllIds(BlazeSpecification specification, Sort sort) { return this.getIdCriteriaBuilder(sort, specification).getResultList(); } /** * Метод поиска всех объектов по идентификаторам * * @param sort - указывает, как нужно сортировать идентификаторы сущности * @param specification - спецификация для фильтрации * @param collection - коллекция из идентификаторов * @return список записей сущности */ public List findAllByIds(Collection collection, Sort sort, BlazeSpecification specification) { BlazeSpecification idSpecification = (root, query, criteriaBuilder) -> root.get("id").in(collection); return this.findAll(idSpecification.and(specification), sort); } /** * Метод поиска всех записей * * @return все записи из базы данных */ public List findAll() { return this.findAll(Sort.unsorted()); } /** * Метод поиска всех записей * * @return все записи из базы данных */ public List findAll(BlazeSpecification specification) { return this.findAll(specification, Sort.unsorted()); } /** * Метод поиска всех записей * * @return все записи из базы данных */ public List findAll(Sort sort) { return this.findAll(null, sort); } /** * Метод поиска всех записей * * @param specification - спецификация для фильтрации * @return все записи из базы данных после фильтра */ public List findAll(BlazeSpecification specification, Sort sort) { return this.sortedCriteriaBuilder(sort, specification).getResultList(); } /** * Метод, который выбирает первую страницу, для последующего поиска элементов * * @param pageable - объект pageable для сортировки и установки количества объектов на странице * @param specification - спецификация для фильтрации * @return - PagedList лист с идентификаторами сущностей */ public PagedList findTopIds(Pageable pageable, BlazeSpecification specification) { CriteriaBuilder 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 findTopIds(Pageable pageable) { return this.findTopIds(pageable, null); } /** * Метод, который ищет последующие N записей * * @param direction - указывает, как нужно сортировать идентификаторы сущности, по убыванию или возрастанию * @param previousPage - прошлая страница, из нее считается минимальный id и происходит keySet пагинация * @param specification - спецификация для фильтрации * @return PagedList - лист сущностей */ public PagedList findNextIds(PagedList previousPage, Sort.Direction direction, BlazeSpecification specification) { CriteriaBuilder 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 findNextIds(PagedList previousPage, Sort.Direction direction) { return this.findNextIds(previousPage, direction, null); } /** * Метод, который выбирает первую страницу, для последующего поиска элементов * * @param pageable - объект pageable для сортировки и установки количества объектов на странице * @param specification - спецификация для фильтрации * @return PagedList - лист сущностей */ public PagedList findTopN(Pageable pageable, BlazeSpecification 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 findTopN(Pageable pageable) { return this.findTopN(pageable, null); } /** * Метод, который ищет последующие N записей * * @param sortBy - сортировка для выборки * @param previousPage - прошлая страница, из нее считается минимальный id и происходит keySet пагинация * @param predicate - predicate для фильтрации * @return PagedList - лист сущностей */ public PagedList findNextN(Sort sortBy, PagedList previousPage, BlazeSpecification predicate) { CriteriaBuilder domainCriteriaBuilder = this.sortedCriteriaBuilder(sortBy, predicate); return this.getNextPagedList(domainCriteriaBuilder, previousPage); } /** * Метод, который ищет последующие N записей * * @param sortBy - сортировка для выборки * @param previousPage - прошлая страница, из нее считается минимальный id и происходит keySet пагинация * @return PagedList - лист сущностей */ public PagedList findNextN(Sort sortBy, PagedList previousPage) { return this.findNextN(sortBy, previousPage, null); } /** * Метод, который собирает CriteriaBuilder для фильтрации и сортировки объектов * * @param sort - сортировка для выборки * @param specification - спецификация для фильтрации * @return CriteriaBuilder для построения запроса */ protected CriteriaBuilder sortedCriteriaBuilder(Sort sort, BlazeSpecification specification) { BlazeCriteriaBuilder cb = BlazeCriteria.get(criteriaBuilderFactory); BlazeCriteriaQuery query = cb.createQuery(getDomainClass()); BlazeRoot root = query.from(getDomainClass()); this.addFilterToQuery(specification, cb, query, root); CriteriaBuilder criteriaBuilder = query.createCriteriaBuilder(entityManager); this.makePageableOrDefaultSort(sort, criteriaBuilder); return criteriaBuilder; } /** * Метод, который создает CriteriaBuilder для поиска по id сущности * * @param sort - объект для сортировки * @param specification - спецификация для фильтрации * @return CriteriaBuilder - готовая критерия */ protected CriteriaBuilder getIdCriteriaBuilder(Sort sort, BlazeSpecification specification) { BlazeCriteriaBuilder cb = BlazeCriteria.get(criteriaBuilderFactory); BlazeCriteriaQuery query = cb.createQuery(getDomainIdClass()); BlazeRoot 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 criteriaBuilder = query.createCriteriaBuilder(entityManager); this.makePageableOrDefaultSort(sort, criteriaBuilder); return criteriaBuilder; } /** * Метод, который добавляет фильтрация в запрос * * @param specification - specification, по которой нужно отфильтровать * @param cb - BlazeCriteriaBuilder * @param query - сам запрос, куда нужно добавить фильтрацию * @param root - корневой объект сущности */ protected void addFilterToQuery(BlazeSpecification specification, BlazeCriteriaBuilder cb, BlazeCriteriaQuery query, BlazeRoot 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 - класс, объект которого получится в итоге * @return - PagedList следующая страница объектов */ protected PagedList getNextPagedList(CriteriaBuilder criteriaBuilder, PagedList 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 - объект сущности */ @FunctionalInterface public interface BlazeSpecification { /** * Метод для реализации функционального интерфейса для работы с фильтрами * @param root - корневой объект * @param query - запрос, в который добавляется фильтрация * @param criteriaBuilder - объект для сборки предиката * @return - предикат фильтра */ Predicate toPredicate(BlazeRoot root, BlazeCriteriaQuery query, BlazeCriteriaBuilder criteriaBuilder); static BlazeSpecification toBlazeSpecification(Specification specification) { return specification::toPredicate; } /** * Метод объединения спецификаций для получения общего объекта. Работает как 'и' сужая вариант выборки. * * @param addedSpec - спецификация, которую нужно связать с текущей * @return - Объединенная спецификация */ default BlazeSpecification and(BlazeSpecification 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 or(BlazeSpecification 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 currentBatch = userRepository.findTopN(pageable); while (!currentBatch.isEmpty()) { List 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 из-за скорости перехода на него. С моим решением вы можете это сделать гораздо быстрее. Всем спасибо за внимание и хорошего дня!)

Фильтры и сортировка