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 DataList<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 ANALYZESELECT 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.propertiesspring.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.
Обмежуйте через конфігурацію:
@Configurationpublic 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 рядків. Для адмін-панелей це рідкісний сценарій,
але варто захиститись:
@GetMappingpublic 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.
| Критерій | List | Slice | Page |
|---|
| 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