Page в Spring Data JPA: повноцінна пагінація з COUNT — Spring Boot 3

Actualizado:
Page в Spring Data JPA: повноцінна пагінація з COUNT — Spring Boot 3

Page<T> — найпопулярніший тип пагінації в Spring Data JPA, і водночас найчастіше використовуваний там де він не потрібен. Він виконує два SQL-запити при кожному виклику: основний SELECT і COUNT(*). Ключове питання не «як використовувати Page», а «коли він виправданий і як уникнути його типових пасток у продакшені».

⚡ Коротко

  • Page виправданий коли UI показує totalPages, totalElements або numbered pagination
  • COUNT(*) виконується завжди — його не можна відключити, тільки оптимізувати
  • countQuery в @Query — головний інструмент оптимізації для складних запитів
  • ⚠️ JOIN FETCH + Page — небезпечна комбінація, Hibernate може завантажити все в пам'ять
  • 👇 Нижче — механіка, EXPLAIN ANALYZE і production-нюанси

📚 Зміст

Інші статті серії:
📌 List в Spring Data JPA — коли безпечно і коли це ризик
📌 Slice в Spring Data JPA — infinite scroll без COUNT(*)
📌 Specification — динамічні фільтри без combinatorial explosion
📌 List vs Page vs Slice vs Specification — повний гід у Spring Data JPA

🎯 Коли Page виправданий — і коли ні

Коротка відповідь: тільки коли UI використовує totalPages або totalElements

Page<T> виправданий рівно в одному випадку: коли інтерфейс відображає інформацію яка вимагає загальної кількості рядків. Якщо UI не показує «Сторінка 3 з 47», «Знайдено 1 234 результати» або numbered pagination — COUNT(*) виконується даремно.

Page вирішує конкретну задачу: дати відповідь на питання «скільки всього елементів і сторінок». Якщо це питання не стоїть — інструмент обраний неправильно.

Сценарії де Page є правильним вибором

Адмін-панель з numbered pagination. Класичний сценарій: таблиця з користувачами, замовленнями або продуктами, де адміністратор бачить «Показано 41–60 з 234» і може перейти на конкретну сторінку. Тут totalElements і totalPages — обов'язкова частина UI.

Пошукова видача з лічильником. Google показує «Приблизно 1 230 000 результатів». Ваш внутрішній пошук по продуктах або документах може показувати «Знайдено 47 відповідностей» — це вимагає COUNT.

Звіти і таблиці з навігацією. Будь-який звіт де користувач може перейти на останню сторінку або на конкретний номер — вимагає знання загальної кількості сторінок.

Сценарії де Page надлишковий

  • Infinite scroll — потрібен тільки hasNext(), використовуйте Slice
  • «Завантажити ще» — кнопка без лічильника, достатньо Slice
  • Мобільний API — клієнт рідко відображає загальну кількість
  • Стрічка новин або товарів — той самий infinite scroll патерн

🎯 Як працює Page — два запити під капотом

Spring Data завжди генерує два SQL-запити для Page<T>

При кожному виклику методу що повертає Page<T> Hibernate

генерує основний SELECT з LIMIT/OFFSET і окремий COUNT-запит.

target="_blank">За документацією Spring Data, COUNT-запит автоматично

прибирає ORDER BY, відкидає fetch joins і виводить count з основного предикату.

Але для складних запитів ця автоматична оптимізація часто недостатня.

Від Repository до SQL — повний стек

Entity:

@Entity

@Table(name = "orders")

public class Order {

@Id

@GeneratedValue(strategy = GenerationType.IDENTITY)

private Long id;

@ManyToOne(fetch = FetchType.LAZY)

@JoinColumn(name = "user_id")

private User user;

private String status;

private BigDecimal total;

@Column(name = "created_at")

private LocalDateTime createdAt;

}

Repository:

public interface OrderRepository extends JpaRepository<Order, Long> {

Page<Order> findAllByStatusOrderByCreatedAtDesc(

String status,

Pageable pageable

);

}

Service:

@Service

@RequiredArgsConstructor

public class OrderService {

private final OrderRepository orderRepository;

public Page<Order> getOrders(String status, int page, int size) {

Pageable pageable = PageRequest.of(

page, size,

Sort.by("createdAt").descending()

);

return orderRepository.findAllByStatusOrderByCreatedAtDesc(status, pageable);

}

}

SQL що генерує Hibernate — два запити:

-- Запит 1: основні дані

SELECT o.id, o.user_id, o.status, o.total, o.created_at

FROM orders o

WHERE o.status = 'PENDING'

ORDER BY o.created_at DESC

LIMIT 20 OFFSET 0;

-- Запит 2: COUNT — виконується завжди

SELECT COUNT(o.id)

FROM orders o

WHERE o.status = 'PENDING';

-- ORDER BY автоматично прибирається — це правильна оптимізація Spring Data

Controller + DTO:

@RestController

@RequestMapping("/api/admin/orders")

@RequiredArgsConstructor

public class OrderAdminController {

private final OrderService orderService;

@GetMapping

public PageResponse<Order> getOrders(

@RequestParam(defaultValue = "PENDING") String status,

@RequestParam(defaultValue = "0") int page,

@RequestParam(defaultValue = "20") @Max(100) int size) {

Page<Order> result = orderService.getOrders(status, page, size);

return new PageResponse<>(

result.getContent(),

result.getTotalElements(), // для лічильника в UI

result.getTotalPages(), // для numbered pagination

result.getNumber(),

result.hasNext(),

result.hasPrevious()

);

}

}

public record PageResponse<T>(

List<T> content,

long totalElements,

int totalPages,

int currentPage,

boolean hasNext,

boolean hasPrevious

) {}

JSON-відповідь яку отримує фронтенд:

{

"content": [...],

"totalElements": 234, // "Знайдено 234 замовлення"

"totalPages": 12, // для numbered pagination

"currentPage": 0,

"hasNext": true,

"hasPrevious": false

}

Що всередині PageImpl

Spring Data повертає PageImpl<T> — реалізацію інтерфейсу

Page<T>. Конструктор приймає три аргументи:

// Спрощено — що відбувається всередині Spring Data

List<Order> content = // результат SELECT з LIMIT/OFFSET

long totalElements = // результат COUNT(*)

Pageable pageable = // переданий PageRequest

return new PageImpl<>(content, pageable, totalElements);

// totalPages обчислюється як Math.ceil(totalElements / pageSize)

🎯 COUNT(*) під мікроскопом — EXPLAIN ANALYZE

Коротка відповідь: COUNT деградує з фільтрами і JOIN — потрібні індекси і countQuery

Поведінка COUNT суттєво відрізняється залежно від складності запиту.

Простий COUNT по первинному ключу майже безкоштовний.

COUNT з WHERE по неіндексованому полю — повний table scan.

COUNT з JOIN — потенційний cartesian product якщо Spring Data

не може правильно спростити запит.

Сценарій 1: простий COUNT без фільтрів

EXPLAIN ANALYZE SELECT COUNT(o.id) FROM orders o;

-- Index Only Scan using orders_pkey on orders

-- Heap Fetches: 0

-- Planning Time: 0.1 ms

-- Execution Time: 12 ms -- 1M рядків, index-only scan

PostgreSQL використовує index-only scan по первинному ключу.

При 1M рядків — 12ms. Прийнятно для більшості сценаріїв.

Сценарій 2: COUNT з фільтром по неіндексованому полю

EXPLAIN ANALYZE

SELECT COUNT(o.id) FROM orders o

WHERE o.status = 'PENDING';

-- Seq Scan on orders

-- Filter: (status = 'PENDING')

-- Rows Removed by Filter: 850000

-- Planning Time: 0.2 ms

-- Execution Time: 340 ms -- повний table scan при 1M рядків

340ms на COUNT при кожному запиті сторінки. Рішення — індекс на status:

CREATE INDEX idx_orders_status ON orders(status);

-- Після індексу:

-- Bitmap Index Scan on idx_orders_status

-- Index Cond: (status = 'PENDING')

-- Execution Time: 8 ms -- в 42 рази швидше

Сценарій 3: COUNT з JOIN — небезпечна автогенерація

Це найпроблемніший сценарій.

Якщо основний запит містить JOIN FETCH, Spring Data намагається

автоматично спростити COUNT-запит — але не завжди успішно:

// Repository з JOIN FETCH

@Query("SELECT o FROM Order o JOIN FETCH o.user u WHERE u.role = :role")

Page<Order> findByUserRole(@Param("role") String role, Pageable pageable);

-- Автогенерований COUNT (проблемний):

SELECT COUNT(DISTINCT o.id)

FROM orders o

INNER JOIN users u ON o.user_id = u.id

WHERE u.role = 'ADMIN';

-- JOIN залишається в COUNT — дорожче ніж потрібно

-- Попередження в логах Hibernate:

-- HHH90003004: firstResult/maxResults specified with collection fetch;

-- applying in memory — Hibernate може завантажити ВСЕ в пам'ять

Останнє попередження критичне: при певних комбінаціях JOIN FETCH і Page,

Hibernate застосовує LIMIT/OFFSET в пам'яті, а не в SQL.

Це означає завантаження всіх рядків з БД і фільтрацію в JVM.

Активуйте property щоб отримати exception замість мовчазної деградації:

# application.properties

spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true

🎯 Оптимізація Page в продакшені

countQuery, індекси і обмеження OFFSET — три головних інструменти

Для складних запитів автоматично згенерований COUNT часто неоптимальний.

countQuery в @Query дозволяє написати спрощений

COUNT вручну — без JOIN і зайвих умов. Це найефективніша оптимізація

для запитів з кількома таблицями.

countQuery — ручна оптимізація COUNT

@Query(

value = """

SELECT o FROM Order o

JOIN FETCH o.user u

JOIN FETCH o.items i

WHERE u.role = :role

AND o.status = :status

""",

countQuery = """

SELECT COUNT(o.id) FROM Order o

JOIN o.user u

WHERE u.role = :role

AND o.status = :status

"""

// JOIN FETCH замінений на JOIN

// o.items прибраний — не потрібен для COUNT

)

Page<Order> findByRoleAndStatus(

@Param("role") String role,

@Param("status") String status,

Pageable pageable

);

Порівняння EXPLAIN для автогенерованого vs кастомного COUNT:

-- Автогенерований COUNT з JOIN FETCH items:

SELECT COUNT(DISTINCT o.id)

FROM orders o

JOIN users u ON o.user_id = u.id

JOIN order_items i ON o.id = i.order_id -- зайвий JOIN

WHERE u.role = 'ADMIN' AND o.status = 'PENDING';

-- Execution Time: 280 ms

-- Кастомний countQuery:

SELECT COUNT(o.id)

FROM orders o

JOIN users u ON o.user_id = u.id -- тільки потрібний JOIN

WHERE u.role = 'ADMIN' AND o.status = 'PENDING';

-- Execution Time: 45 ms -- в 6 разів швидше

PageableHandlerMethodArgumentResolver — обмеження з коробки

Spring MVC автоматично резолвить Pageable з HTTP-параметрів.

Без налаштувань клієнт може передати size=100000.

Обмежуйте через конфігурацію:

@Configuration

public class WebConfig implements WebMvcConfigurer {

@Override

public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {

PageableHandlerMethodArgumentResolver resolver =

new PageableHandlerMethodArgumentResolver();

resolver.setMaxPageSize(100); // максимум 100 елементів

resolver.setFallbackPageable(

PageRequest.of(0, 20) // дефолт якщо не передано

);

resolvers.add(resolver);

}

}

@QueryHints для readonly-запитів

Адмін-панелі зазвичай тільки читають дані. Позначте це явно —

Hibernate пропустить dirty checking і зменшить overhead:

@QueryHints(value = {

@QueryHint(name = HINT_READONLY, value = "true"),

@QueryHint(name = HINT_FETCH_SIZE, value = "50")

})

Page<Order> findAllByStatusOrderByCreatedAtDesc(String status, Pageable pageable);

Ліміт на OFFSET — захист від деградації

При page=1000, size=20 PostgreSQL генерує OFFSET 20000

— читає і відкидає 20 000 рядків. Для адмін-панелей це рідкісний сценарій,

але варто захиститись:

@GetMapping

public PageResponse<Order> getOrders(

@RequestParam(defaultValue = "0") @Max(500) int page,

@RequestParam(defaultValue = "20") @Max(100) int size) {

// page * size максимум 500 * 20 = OFFSET 10000

// якщо потрібно більше — пошук або фільтр, не пагінація

}

🎯 Page vs Slice vs List — чіткі критерії

Один критерій — чи показує UI загальну кількість

Якщо UI відображає кількість елементів або сторінок — потрібен Page.

Якщо UI показує тільки «є ще / немає» — достатньо Slice.

Якщо дані завжди маленькі і завантажуються повністю — List.

КритерійListSlicePage
COUNT(*) запит✅ завжди
totalElements / totalPages
hasNext()
Numbered pagination (1..2..3)
Infinite scroll⚠️ без ліміту✅ оптимально⚠️ зайвий COUNT
Адмін-панель
Пошукова видача з лічильником
Мобільний API⚠️⚠️
Ризик OutOfMemory⚠️ без LIMIT✅ захищений✅ захищений
JOIN FETCH сумісність⚠️ потребує countQuery

❓ FAQ

Чи можна відключити COUNT(*) для Page?

Ні — COUNT є частиною контракту Page<T> і не може бути відключений

через конфігурацію. Якщо COUNT не потрібен — використовуйте Slice<T>.

Єдина можливість впливати на COUNT — написати оптимізований countQuery

в @Query, але він все одно виконається.

Чому Spring Data прибирає ORDER BY з COUNT-запиту?

Це свідома оптимізація: ORDER BY не впливає на результат COUNT і лише

додає overhead. Spring Data автоматично видаляє сортування з COUNT-запиту.

Якщо ви пишете кастомний countQuery — також не включайте ORDER BY.

Page з нативним SQL (@Query nativeQuery=true) — є нюанси?

Так, і суттєві. При нативних запитах Spring Data не може автоматично

згенерувати коректний COUNT — він часто виконує SELECT * FROM (...) AS count_query

що призводить до повного виконання основного запиту заради COUNT.

При 600K+ рядків це може займати 20+ секунд.

Для нативних запитів завжди вказуйте countQuery явно.

@Query(

value = "SELECT * FROM orders WHERE status = :status",

countQuery = "SELECT COUNT(*) FROM orders WHERE status = :status",

nativeQuery = true

)

Page<Order> findByStatusNative(@Param("status") String status, Pageable pageable);

Як передати Pageable через REST API?

Spring MVC автоматично резолвить Pageable якщо він є параметром

контролера. Параметри за замовчуванням: ?page=0&size=20&sort=createdAt,desc.

Сортування можна передавати кількома полями: sort=status,asc&sort=createdAt,desc.

Обмежуйте максимальний size через PageableHandlerMethodArgumentResolver

або @PageableDefault.

// Через анотацію — швидше для одного endpoint

@GetMapping

public PageResponse<Order> getOrders(

@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)

Pageable pageable) { ... }

PageImpl vs Page — яка різниця і коли потрібен PageImpl вручну?

Page<T> — інтерфейс, PageImpl<T> — його реалізація.

Spring Data повертає PageImpl автоматично. Вручну створювати PageImpl

потрібно в двох випадках: при unit-тестах де потрібно замокати результат пагінації,

і при ручній трансформації результату (наприклад, маппінг Entity → DTO зі збереженням

метаданих пагінації).

// Маппінг Entity → DTO зі збереженням пагінації

Page<Order> orders = orderRepository.findAll(pageable);

Page<OrderDto> dtos = orders.map(order -> new OrderDto(order));

// або

Page<OrderDto> dtos = new PageImpl<>(

orders.getContent().stream().map(OrderDto::from).toList(),

pageable,

orders.getTotalElements() // зберігаємо оригінальний COUNT

);

✅ Висновки

Page<T> — правильний інструмент для numbered pagination

і будь-якого UI що показує загальну кількість елементів або сторінок.

Він виконує два SQL-запити при кожному виклику — це не баг, це контракт.

  • COUNT(*) виконується завжди — відключити неможливо, тільки оптимізувати через countQuery
  • Для складних запитів з JOIN пишіть countQuery вручну — автогенерація часто неоптимальна
  • JOIN FETCH + Page — небезпечна комбінація, активуйте fail_on_pagination_over_collection_fetch
  • Нативні запити завжди вимагають явного countQuery
  • Обмежуйте максимальний size і page на рівні контролера
  • Якщо UI не показує загальну кількість — використовуйте Slice

Наступні статті серії:

📌 Spring Data JPA — Pageable and Page documentation

Vlad Mihalcea — JOIN FETCH and Pagination with Spring

Spring Boot Pagination Performance in REST Endpoints

PostgreSQL — EXPLAIN документація

🌟 З повагою

Вадим Харовюк

☕ Java розробник, засновник WebCraft Studio

Останні статті

Читайте більше цікавих матеріалів

Claude Opus 4.7 для RAG: як я тестував модель на реальних документах

Claude Opus 4.7 для RAG: як я тестував модель на реальних документах

Коротко про що ця стаття: 17 квітня я взяв свіжий Claude Opus 4.7 і прогнав його через свою RAG-систему AskYourDocs на тестовому наборі з ~400 публічних юридичних документів (зразки договорів, нормативні акти, шаблони з відкритих джерел). Порівняв з Llama 3.3 70B, на якій у мене зараз...

Claude Opus 4.7: детальний огляд моделі Anthropic у 2026

Claude Opus 4.7: детальний огляд моделі Anthropic у 2026

TL;DR за 30 секунд: Claude Opus 4.7 — новий флагман Anthropic, який вийшов 16 квітня 2026 року. Головне: +10.9 пунктів на SWE-bench Pro (64.3% проти 53.4% у Opus 4.6), вища роздільна здатність vision (3.75 MP), нова memory на рівні файлової системи та новий рівень міркування xhigh. Ціна...

Gemma 4 26B MoE: підводні камені і коли це реально виграє

Gemma 4 26B MoE: підводні камені і коли це реально виграє

Коротко: Gemma 4 26B MoE рекламують як "якість 26B за ціною 4B". Це правда щодо швидкості інференсу — але не щодо пам'яті. Завантажити потрібно всі 18 GB. На Mac з 24 GB — свопінг і 2 токени/сек. Комфортно працює на 32+ GB. Читай перш ніж завантажувати. Що таке MoE і чому 26B...

Reasoning mode в Gemma 4: як вмикати, коли потрібно і скільки коштує — 2026

Reasoning mode в Gemma 4: як вмикати, коли потрібно і скільки коштує — 2026

Коротко: Reasoning mode — це вбудована здатність Gemma 4 "думати" перед відповіддю. Увімкнений за замовчуванням. На M1 16 GB з'їдає від 20 до 73 секунд залежно від задачі. Повністю вимкнути через Ollama не можна — але можна скоротити через /no_think. Читай коли це варто робити, а коли...

Gemma 4: повний огляд — розміри, ліцензія, порівняння з Gemma 3

Gemma 4: повний огляд — розміри, ліцензія, порівняння з Gemma 3

Коротко: Gemma 4 — нове покоління відкритих моделей від Google DeepMind, випущене 2 квітня 2026 року. Чотири розміри: E2B, E4B, 26B MoE і 31B Dense. Ліцензія Apache 2.0 — можна використовувати комерційно без обмежень. Підтримує зображення, аудіо, reasoning mode і 256K контекст. Запускається...

Gemma 4 на M1 16 GB — реальні тести: код, текст, швидкість

Gemma 4 на M1 16 GB — реальні тести: код, текст, швидкість

Коротко: Встановив Gemma 4 на MacBook Pro M1 16 GB і протестував на двох реальних задачах — генерація Spring Boot коду і текст про RAG. Порівняв з Qwen3:8b і Mistral Nemo. Результат: Gemma 4 видає найкращу якість, але найповільніша. Qwen3:8b — майже та сама якість коду за 1/4 часу. Читай якщо...