JavaRush /Курсы /Hibernate deep-dive /Пагинация и стабильная сортировка

Пагинация и стабильная сортировка

Hibernate deep-dive
9 уровень , 3 лекция
Открыта

1. Пагинация и projection: разные задачи

Когда вы впервые делаете списки в приложении, очень легко перепутать два слоя оптимизации. Кажется, что если «мы сделали Page», значит мы уже всё ускорили, и можно радостно возвращать Page<Product>. На практике пагинация и projection решают разные задачи, и именно поэтому они отлично дополняют друг друга, а не заменяют.

Projection отвечает на вопрос: какие колонки (и какие данные) мы читаем. Пагинация отвечает на вопрос: сколько строк мы читаем за один запрос и как «нарезаем» общий набор данных на страницы. Если вы сделаете только пагинацию, но будете возвращать entity, то вы ограничите количество строк, но каждая строка всё равно будет «толстой» — с managed-семантикой и потенциальным графом. Если сделаете только projection, но без пагинации, то вы будете честно читать нужные колонки… но можете прочитать 50 000 строк за один раз и положить сервер на лопатки.

Чтобы не путаться, полезно держать в голове такую мини-таблицу:

Инструмент Что ограничивает Что не ограничивает Типичный результат
Projection ширину данных (SELECT меньше колонок) количество строк List<ProductListRow>
Pagination (Pageable) количество строк (LIMIT/OFFSET) ширину данных Page<Product> или
Page<ProductListRow>

И маленькая «жизненная» формула для списков:

Сначала выбираем read-model (projection), потом добавляем пагинацию.

2. Pageable и Page: устройство

Внутри Spring Data JPA пагинация выглядит очень дружелюбно: добавили параметр Pageable — и получили Page<T>. Но чтобы предсказывать SQL и не удивляться поведению на проде, важно понимать, что это не магия, а очень конкретная математика LIMIT/OFFSET (в PostgreSQL) и отдельный COUNT(...) запрос, если вам нужна информация о количестве страниц.

Pageable — это «заявка» на страницу. В ней есть три важных вещи: номер страницы, размер страницы и сортировка. Page — это уже результат: список элементов + метаданные (сколько всего, какая текущая страница, есть ли следующая и т.д.). И да, страницы нумеруются с нуля. Это тот случай, когда программисты опять сделали «как удобнее компьютеру», а не человеку. Но зато формула оффсета становится простой:

# смещение (OFFSET) считается так:
offset = pageNumber * pageSize

Если pageNumber=0 и pageSize=20, то offset=0. Если pageNumber=1, то offset=20. Логично, но не интуитивно, если вы мыслите «первая страница — это 1». Поэтому в сервисах обычно либо прямо принимают 0-based номер страницы, либо делают адаптацию «внешний API даёт 1-based, а внутри мы приводим к 0-based». В рамках курса мы будем считать, что вы сами вызываете сервис и отдаёте page уже 0-based.

Посмотрим на маленький фрагмент кода, который создаёт Pageable:

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;

// page — индекс страницы (0-based), size — размер страницы
int page = 0;
int size = 20;

// Важно: сортировка должна быть стабильной (в конце уникальный tie-breaker, обычно id)
Sort sort = Sort.by("name").ascending()
        .and(Sort.by("id").ascending());

// Pageable содержит номер страницы, размер и сортировку — на базе этого будет LIMIT/OFFSET
PageRequest pageable = PageRequest.of(page, size, sort);

Здесь уже спрятаны два важнейших правила: размер страницы (чтобы не читать «всё») и стабильная сортировка (чтобы страницы не «перемешивались»). Про сортировку мы сейчас поговорим отдельно, потому что это место, где backend-разработчик взрослеет, перестаёт верить в «ну база сама как-нибудь отсортирует», и начинает задавать правильные вопросы.

А вот минимальный пример того, что лежит внутри Page:

import org.springframework.data.domain.Page;

Page<?> pageResult = /* вызов репозитория */;

// Номер страницы (0-based)
System.out.println(pageResult.getNumber());         // 0
// Размер страницы
System.out.println(pageResult.getSize());           // 20
// Общее число элементов во всём результате (обычно требует COUNT)
System.out.println(pageResult.getTotalElements());  // например 137
// Общее число страниц (тоже следует из COUNT)
System.out.println(pageResult.getTotalPages());     // например 7

Важный нюанс: getTotalElements и getTotalPages обычно означают, что где-то рядом был выполнен COUNT(...). Это не «плохо» и не «хорошо» — это просто цена знания «сколько всего строк». Если вам это знание не нужно, у Spring Data есть другие варианты, но мы сегодня держимся в рамках Page и Pageable, потому что именно они чаще всего используются в админских списках.

3. Стабильная сортировка: order by name недостаточно

Пагинация без сортировки — это как попытка разложить книги по полкам, пока кто-то постоянно трясёт стеллаж. Формально страницы будут «какие-то», но вы не сможете гарантировать, что при переходе на следующую страницу вы увидите то, чего не было на предыдущей. Поэтому правило звучит жёстко: пагинированный список обязан иметь определённый порядок.

Но тут есть ловушка новичка: «Я отсортировал по name — значит порядок определён». Увы, если name не уникально, то порядок не полностью определён. Две строки с одинаковым name база может отдавать в разном порядке в разных запросах (особенно при параллельных изменениях, разном плане выполнения или просто потому, что ей так удобнее). И вот здесь появляется термин, который спасает нервные клетки: стабильная сортировка.

Стабильная сортировка для пагинации означает, что ваш ORDER BY должен в итоге приводить к уникальному порядку строк. Самый простой способ — добавлять в конец сортировки уникальное поле, чаще всего id.

Представим очень жизненную ситуацию в нашем Commerce Persistence Lab: у вас есть два товара с названием "Cable". Если вы сортируете только по name, то оба товара — «одинаковые» с точки зрения сортировки. А дальше начинается веселье: вы открываете страницу 0, видите один "Cable", открываете страницу 1 — и иногда видите второго, а иногда снова первого. И вы думаете: «У меня баг в пагинации». На самом деле баг в том, что порядок не был уникальным.

Стабильное правило для списка товаров в админке обычно выглядит так:

import org.springframework.data.domain.Sort;

// name может быть не уникальным — поэтому замыкаем сортировку на уникальное поле id
Sort sort = Sort.by("name").ascending()
        .and(Sort.by("id").ascending());

Для списка заказов часто хочется сортировку «новые сверху», и там тоже важно замыкать уникальным полем:

import org.springframework.data.domain.Sort;

// createdAt тоже может совпадать (батчи, округления, одинаковые timestamps) — снова страхуемся id
Sort sort = Sort.by("createdAt").descending()
        .and(Sort.by("id").descending());

Даже если createdAt в среднем «уникален», на реальных системах он очень легко становится неуникальным (например, батч-импорт, миграции, округление времени, одинаковые timestamps при высокой нагрузке). id — простая страховка.

Чтобы визуально закрепить, почему это критично, можно представить такой поток:

flowchart TD
  %% Если порядок не уникальный, граница страницы "плавает", и элементы могут переезжать между страницами
  A["Запрос страницы 0: ORDER BY name LIMIT 20 OFFSET 0"] --> B["Строки с name='Cable' попали на границу страницы"]
  B --> C["Запрос страницы 1: ORDER BY name LIMIT 20 OFFSET 20"]
  C --> D["База может поменять порядок одинаковых name"]
  D --> E["Вы видите дубликаты/пропуски между страницами"]

Добавление id в сортировку делает порядок детерминированным, и этот сценарий схлопывается: одинаковые name всё равно будут упорядочены по id.

4. Page и DTO projection: список постранично

Теперь соберём всё вместе в том виде, в каком мы хотим видеть «взрослый» read use case: список должен быть тонким (projection), постраничным (pagination) и предсказуемым (stable sort). Хорошая новость: Spring Data JPA позволяет это сделать довольно прямо.

DTO для строки списка

DTO (read-model) для списка товаров в админке обычно очень простой. Нам, например, нужны id, sku, name. Покажем DTO-класс (геттеры можно сгенерировать в IDE, но в примере не будем раздувать код):

package com.example.commerce.catalog.dto;

public class ProductListRow {
    // DTO — это read-model: без managed-семантики, без ленивых связей, без "случайных UPDATE"
    private final Long id;
    private final String sku;
    private final String name;

    public ProductListRow(Long id, String sku, String name) {
        // Храним только то, что нужно для строки списка
        this.id = id;
        this.sku = sku;
        this.name = name;
    }
}

Ключевая мысль: это не entity, оно не managed, его нельзя «случайно изменить и получить UPDATE». Это просто форма результата.

Репозиторий: возвращаем Page<ProductListRow>

Теперь в репозитории сделаем метод, который возвращает страницу DTO. В Commerce Persistence Lab это логично положить в com.example.commerce.catalog.repository.ProductRepository.

package com.example.commerce.catalog.repository;

import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Query("""
           select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name)
           from Product p
           where p.status = :status
           """)
    // Важно: сортировку (ORDER BY) сюда не прибиваем намертво — она придёт из Pageable
    Page<ProductListRow> findListRowsByStatus(ProductStatus status, Pageable pageable);
}

Обратите внимание на важный момент: в запросе нет order by. Это сделано специально: сортировку мы передадим через Pageable, чтобы правило сортировки оставалось частью read use case. Это удобно для переиспользования (и для тестов), но требует дисциплины: вы обязаны задавать сортировку при построении PageRequest.

Сервис чтения: задаём стабильный сорт по умолчанию

В нашем проекте мы стараемся разделять «чтение» и «запись» хотя бы на уровне подхода, поэтому сделаем тонкий ProductQueryService. Важно, что это read-only операция: мы не хотим, чтобы чтение тащило за собой лишние механики.

import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.ProductStatus;
import com.example.commerce.catalog.repository.ProductRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;

public class ProductQueryService {

    private final ProductRepository productRepository;

    public ProductQueryService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional(readOnly = true) // Подсказываем инфраструктуре: это чистое чтение
    public Page<ProductListRow> findActiveProducts(int page, int size) {
        // Дефолтная стабильная сортировка: name + tie-breaker id
        Sort sort = Sort.by("name").ascending().and(Sort.by("id").ascending());

        // Pageable определяет и LIMIT/OFFSET, и ORDER BY (если репозиторий поддерживает сортировку)
        PageRequest pageable = PageRequest.of(page, size, sort);

        return productRepository.findListRowsByStatus(ProductStatus.ACTIVE, pageable);
    }
}

Да, тут снова id в сортировке. Да, это выглядит «как лишнее». И да, это то самое «лишнее», которое потом экономит часы отладки «почему у меня на странице то пусто, то дубликаты».

5. SQL при Page: LIMIT/OFFSET и COUNT

На уровне Java-кода пагинация выглядит лёгкой. На уровне базы данных это обычно два отдельных запроса. Первый запрос читает контент страницы через LIMIT/OFFSET. Второй запрос читает общее количество строк через COUNT(...), чтобы Spring Data смог построить Page с метаданными.

Это важно по двум причинам. Во‑первых, вы должны понимать, что «страница» — это не один запрос «в вакууме», а чаще всего два. Во‑вторых, вы должны уметь отличать проблему «мы читаем слишком много колонок» (лечится projection) от проблемы «мы делаем слишком дорогой count» (лечится уже другими подходами, но сегодня мы просто фиксируем, что count существует).

Очень условно SQL будет выглядеть так (это не точная копия вашего лога, а понятная форма):

-- content query (страница данных): LIMIT/OFFSET + ORDER BY для детерминированного порядка
select p.id, p.sku, p.name
from product p
where p.status = 'ACTIVE'
order by p.name asc, p.id asc
limit 20 offset 0;

-- count query (сколько всего строк): нужен, чтобы заполнить метаданные Page (totalElements/totalPages)
select count(p.id)
from product p
where p.status = 'ACTIVE';

И вот тут projection снова помогает: контентный запрос читает ровно три колонки, а не весь product (плюс потенциальные связи). Hibernate не создаёт managed entity, не раздувает persistence context, и вы получаете «тонкий» объект результата. Но count-запрос всё равно существует, потому что Page обещает вам getTotalElements.

Ещё один нюанс, который полезно проговорить словами: сортировка относится к read-case, а не к mapping. Если вы попробуете «вылечить» проблемы списка, добавляя @OrderBy на коллекциях или глобальные настройки в entity, вы быстро окажетесь в ситуации, когда один экран требует сортировку по имени, другой — по цене, а третий — по времени создания. Entity не обязана быть универсальным read-model, и сортировка — отличный пример, почему.

6. Типичные ошибки при пагинации и сортировке

Ошибка №1: “Я сделал Page<Product>, значит список оптимизирован”.
Очень частая история: разработчик честно добавил Pageable, увидел limit/offset в SQL и решил, что теперь всё хорошо. Но в реальности он продолжает читать managed-сущности, которые могут тащить за собой lazy-связи, dirty checking и вообще «слишком много жизни» для списка. В нашем курсе правильная стартовая мысль другая: сначала выбираем read-model (projection), и только потом режем его на страницы.

Ошибка №2: сортировать только по неуникальному полю и ловить “плавающие страницы”.
order by name выглядит красиво, пока не появляется два товара с одинаковым названием. После этого страницы могут дублировать элементы, пропускать их или менять местами при повторном запросе. Вылечивается это почти всегда одинаково: добавляем в конец сортировки уникальный “tie-breaker”, обычно id. Это не «хитрость», а обязательное условие детерминированного порядка.

Ошибка №3: путать нумерацию страниц и передавать page=1, ожидая “первую страницу”.
PageRequest.of(page, size) ждёт 0-based индекс. Поэтому page=0 — это первая страница. Если вы делаете внутренний вызов из сервиса или теста и передаёте 1, вы получите вторую страницу, и затем долго будете подозревать “баг в запросе”. Самый простой способ избежать этого — называть параметры честно (pageIndex) и печатать входные значения в логах во время лабораторных сценариев.

Ошибка №4: считать, что Page — это всегда один SQL-запрос.
Почти всегда Page означает не только SELECT ... LIMIT/OFFSET, но и SELECT COUNT(...). Когда вы смотрите SQL trace и внезапно видите “почему два запроса?”, ответ часто именно здесь. И это нормально: Page покупает вам метаданные о количестве строк ценой дополнительного запроса. Это не повод отказаться от Page везде, но повод помнить, что цена существует.

Ошибка №5: “Сделаю пагинацию, а сортировку пусть пользователь задаёт как угодно”.
Сортировка через Pageable действительно может быть динамической, но если вы не задаёте хотя бы стабильный дефолт, вы рискуете получить недетерминированный порядок “по умолчанию”. Для админских списков обычно лучше иметь явно заданный базовый порядок, а уже потом разрешать дополнительные варианты. Даже когда сортировка динамическая, правило “замыкать на уникальное поле” остаётся в силе.

1
Задача
Hibernate deep-dive, 9 уровень, 3 лекция
Недоступна
Страница активных товаров через projection
Страница активных товаров через projection
1
Задача
Hibernate deep-dive, 9 уровень, 3 лекция
Недоступна
Страница заказов с countQuery и стабильным порядком
Страница заказов с countQuery и стабильным порядком
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ