Spring Data JPA пропонує чотири принципово різних підходи до отримання даних.
Кожен з них генерує різний SQL, по-різному поводиться з пам'яттю
і вирішує різний клас задач. Неправильний вибір — це або зайвий
COUNT(*) при кожному scroll-івенті, або OOM при зростанні таблиці,
або 64 derived methods замість одного. Ця стаття — повний гід
для того щоб обирати правильно з першого разу.
⚡ Коротко — якщо немає часу читати все
- 📋 List — довідники, агрегація, дані з гарантованим верхнім обмеженням
- 📄 Page — адмін-панель, пошук з лічильником, numbered pagination
- 🔄 Slice — infinite scroll, «завантажити ще», мобільний API
- 🔍 Specification — динамічні фільтри з 3+ опціональними умовами
- 👇 Нижче — деталі, таблиця порівняння і decision tree
📚 Зміст
🎯 Чому вибір типу повернення важливіший ніж здається
Тип повернення визначає скільки SQL-запитів виконається і скільки пам'яті буде використано
Більшість розробників сприймають List, Page,
Slice як різні «обгортки» навколо одних і тих самих даних.
Насправді — це принципово різні контракти з базою даних і JVM.
Вибір між ними визначає кількість SQL-запитів, наявність LIMIT,
споживання heap і поведінку при зростанні даних.
Змінити тип повернення з Page на Slice —
одна зміна в сигнатурі методу. Ефект — вдвічі менше SQL-запитів
при кожному scroll-івенті. Це не мікрооптимізація, це архітектурне рішення.
Що кожен тип робить під капотом
List<T> — виконує один SELECT без LIMIT.
Всі відповідні рядки завантажуються в heap.
Розмір результату визначається даними, а не кодом.
Безпечний тільки коли розмір обмежений природою домену.
Page<T> — виконує два SQL-запити: основний SELECT
з LIMIT/OFFSET і окремий COUNT(*). Повертає totalElements
і totalPages. Виправданий тільки коли UI ці значення відображає.
Якщо не відображає — COUNT виконується даремно.
Slice<T> — виконує один SELECT з LIMIT = pageSize + 1.
Визначає hasNext() без COUNT. Оптимальний вибір для
infinite scroll і будь-якого UI де потрібна тільки відповідь «є ще / немає».
Specification<T> — не тип повернення, а механізм
побудови WHERE-умов. Використовується разом з Page або
Slice коли фільтри динамічні і опціональні.
Вирішує проблему combinatorial explosion у derived methods.
Реальна вартість неправильного вибору
Page замість Slice для infinite scroll.
При 100 scroll-івентах за хвилину — 100 зайвих COUNT(*) запитів.
При таблиці 5M рядків з фільтрами кожен COUNT може займати
200–500ms. Тобто 20–50 секунд зайвої роботи БД за хвилину —
тільки щоб порахувати те що UI не показує.
List без LIMIT для таблиці що зростає.
При 100 рядках — непомітно. При 1M рядках — OOM або
2–3 секундний запит що блокує connection pool для всіх інших запитів.
Код однаковий, поведінка кардинально різна залежно від розміру таблиці.
Derived methods замість Specification для 4+ фільтрів.
4 опціональних фільтри = 16 методів у Repository і 16 відповідних
if/else блоків у сервісі. Кожен новий фільтр подвоює складність.
📊 Швидке порівняння — таблиця з 10 критеріями
Повне порівняння за технічними і практичними параметрами.
Актуально для Spring Boot 3.x / Spring Data JPA 3.x.
| Критерій | List<T> | Slice<T> | Page<T> | Specification<T> |
|---|
| SQL LIMIT | ❌ відсутній | ✅ pageSize+1 | ✅ pageSize | Залежить від типу повернення |
| COUNT(*) запит | ❌ | ❌ | ✅ завжди | При використанні з Page |
| totalElements / totalPages | ❌ | ❌ | ✅ | При використанні з Page |
| hasNext() | ❌ | ✅ | ✅ | При використанні з Slice |
| Захист від OOM | ❌ | ✅ | ✅ | ✅ (через Slice/Page) |
| Динамічні фільтри | ❌ | ❌ | ❌ | ✅ основна задача |
| JOIN FETCH сумісність | ✅ | ✅ | ⚠️ потребує countQuery | ⚠️ потребує fetch у Specification |
| @Cacheable паттерн | ✅ стандартний | ⚠️ overhead | ⚠️ overhead | ❌ |
| Infinite scroll / «ще» | ❌ | ✅ оптимально | ⚠️ зайвий COUNT | ✅ з Slice |
| Адмін-панель з лічильником | ❌ | ❌ | ✅ оптимально | ✅ з Page |
| Batch-обробка великих даних | ⚠️ тільки з LIMIT | ✅ | ⚠️ зайвий COUNT | ✅ з Slice |
| Довідники / конфігурація | ✅ оптимально | ⚠️ | ⚠️ | ❌ |
🎯 Сценарії — що обрати для конкретної задачі
Тип визначається тим що відображає UI і як зростають дані
Абстрактні правила («використовуйте Page для пагінації») не дають відповіді
на конкретні задачі. Розглянемо шість реальних product-сценаріїв
і обґрунтований вибір для кожного.
Сценарій 1: Каталог товарів інтернет-магазину з infinite scroll
Що потрібно: підвантаження наступної порції товарів при прокрутці.
UI не показує «Сторінка 3 з 47» — тільки нові товари знизу.
Вибір: Slice. Один SQL без COUNT. hasNext()
повідомляє чи підвантажувати ще. При 10 000 товарів і 50 scroll-івентах
за сесію — Slice генерує 50 запитів, Page — 100.
Для динамічних фільтрів (категорія + ціна + бренд) — Specification + Slice.
Сценарій 2: Адмін-панель замовлень з numbered pagination
Що потрібно: таблиця замовлень з навігацією «1 2 3 ... 47»
і лічильником «Показано 41–60 з 1 234».
Вибір: Page. totalElements і totalPages
відображаються в UI — COUNT виправданий. Для фільтрів по статусу, даті,
клієнту — Specification + Page.
Сценарій 3: Мобільний API для стрічки новин
Що потрібно: endpoint що повертає наступну порцію постів.
Мобільний клієнт показує кнопку «Завантажити ще» або автоматичний scroll.
Загальна кількість постів не відображається.
Вибір: Slice. Мінімальний JSON у відповіді:
content, hasNext, currentPage.
Жодного COUNT. Для cursor-based підходу (стабільніший при активній стрічці) —
Slice з createdAtBefore замість page number.
Сценарій 4: Завантаження довідників при старті додатку
Що потрібно: завантажити список країн, валют, категорій
при старті і закешувати на весь час роботи.
Вибір: List + @Cacheable. Дані стабільні, розмір обмежений
природою домену (195 країн, ~170 валют). Page і Slice тут надлишкові —
вони додають overhead для даних що завантажуються один раз.
Сценарій 5: Batch-job для щонічної обробки замовлень
Що потрібно: обробити всі замовлення зі статусом PENDING
що надійшли за останню добу. Їх може бути 10K–100K.
Вибір: Slice. Обробка пакетами по 100–500 замовлень.
hasNext() визначає чи продовжувати.
List з таким об'ємом — ризик OOM.
Page — зайвий COUNT при кожному пакеті.
Для великих обсягів (1M+) — Stream<T> з курсором.
Сценарій 6: Пошук публікацій в адмін-панелі з 5 фільтрами
Що потрібно: фільтрація по статусу, автору, категорії,
даті створення і наявності зображення — всі фільтри опціональні.
UI показує numbered pagination і лічильник результатів.
Вибір: Specification + Page.
5 опціональних фільтрів = 32 комбінації = 32 derived methods без Specification.
З Specification — один findAll(spec, pageable).
Page виправданий бо UI показує totalElements.
⚠️ Антипатерни — типові помилки і їх наслідки
Три антипатерни зустрічаються в більшості Spring-проектів
Більшість помилок у виборі типу — не через незнання існування альтернатив,
а через неусвідомлення наслідків. Ось три найпоширеніші антипатерни
з конкретними наслідками в продакшені.
Антипатерн 1: Page для infinite scroll
Як виглядає: розробник реалізує infinite scroll і обирає
Page бо «Page — це стандартна пагінація».
Наслідок: кожен scroll-івент генерує COUNT(*).
При таблиці 5M рядків з фільтрами кожен COUNT — 200–500ms.
При 50 активних користувачах що одночасно гортають стрічку —
50 паралельних COUNT-запитів кожні 2–3 секунди.
БД перевантажена роботою що не приносить жодної користі UI.
Рішення: замінити Page<T> на
Slice<T> в сигнатурі Repository-методу.
Одна зміна, нуль змін у бізнес-логіці.
Антипатерн 2: List без LIMIT для таблиці що зростає
Як виглядає: findByStatus("ACTIVE") або
findAll() без явного LIMIT на таблиці замовлень,
транзакцій або будь-якій іншій що зростає з часом.
Наслідок: при 100 рядках — непомітно.
При 500K — 800MB heap і 3-секундний запит.
При 2M — OOM і падіння сервісу. Код не змінювався — змінились дані.
Це latent bug що проявляється при масштабуванні бізнесу.
Рішення: для всіх таблиць що зростають — тільки
Slice або Page. List допустимий тільки
для довідників і агрегованих результатів з гарантованим верхнім обмеженням.
Антипатерн 3: Derived methods для динамічних фільтрів
Як виглядає: Repository з десятками методів для
різних комбінацій фільтрів. Або один @Query з
IS NULL OR ... для всіх опціональних умов.
Наслідок для derived methods: 4 фільтри = 16 методів,
16 if/else в сервісі. Кожен новий фільтр подвоює складність.
Підтримувати неможливо, тестувати — 16 окремих тест-кейсів мінімум.
Наслідок для IS NULL OR: PostgreSQL фіксує query plan
при першому виконанні — і цей план може бути неоптимальним для інших
комбінацій фільтрів. При великих таблицях це suboptimal execution plans
для більшості запитів.
Рішення: Specification + JpaSpecificationExecutor.
Один метод Repository, будь-яка комбінація фільтрів, оптимальний SQL
для кожної комбінації.
Антипатерн 4: JOIN FETCH + Page без countQuery
Як виглядає: @Query з JOIN FETCH що повертає
Page<T> без явного countQuery.
Наслідок: Hibernate може застосувати LIMIT/OFFSET
в пам'яті замість SQL — тобто завантажити всі рядки
з JOIN і потім відфільтрувати в JVM. Попередження в логах:
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory.
При великих таблицях — OOM або катастрофічна деградація продуктивності.
Рішення: завжди вказувати явний countQuery
для @Query з JOIN FETCH. Активувати
spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true
щоб отримати exception замість мовчазної деградації.
🔀 Decision tree — алгоритм вибору
Три питання визначають правильний тип у більшості випадків
Замість запам'ятовування правил — алгоритм з питань.
Відповідайте послідовно і отримаєте обґрунтований вибір.
Крок 1: Чи може кількість рядків перевищити ~1000 при зростанні даних?│
├── НІ (довідники, агрегація, конфігурація)
│ └── → LIST + @Cacheable якщо дані стабільні
│
└── ТАК (будь-яка таблиця що зростає)
│
Крок 2: Чи є динамічні опціональні фільтри (3+)?
│
├── ТАК
│ └── Крок 3: Чи показує UI загальну кількість?
│ ├── ТАК → SPECIFICATION + PAGE
│ └── НІ → SPECIFICATION + SLICE
│
└── НІ (фільтри фіксовані або відсутні)
│
Крок 3: Чи показує UI totalElements або totalPages?
│
├── ТАК (numbered pagination, лічильник результатів)
│ └── → PAGE
│
└── НІ (infinite scroll, «ще», мобільний API)
└── → SLICE
Швидка шпаргалка по сценаріях
| Сценарій | Рекомендація | Чому |
|---|
| Infinite scroll в каталозі | Slice | COUNT не потрібен, hasNext достатньо |
| Адмін-панель з «1 з 47» | Page | UI потребує totalPages |
| Мобільний API стрічки | Slice | Мінімальний JSON, без COUNT |
| Завантаження країн/валют | List + @Cacheable | Стабільний розмір, кешується |
| Batch-job по 500 записів | Slice | COUNT при кожному пакеті — зайво |
| Пошук з 5 фільтрами | Specification + Page/Slice | Динамічні умови, один метод |
| Статистика по місяцях | List | GROUP BY, максимум 12 рядків |
| «Знайдено 234 результати» | Page | totalElements в UI |
📚 Серія статей — детальні розбори кожного типу
Ця стаття — огляд і гід для вибору. Кожен тип має окремий детальний розбір
з EXPLAIN ANALYZE, production-нюансами, антипатернами і повним кодом:
Slice в Spring Data JPA — infinite scroll без COUNT(*)
Механіка pageSize+1, порівняння SQL з Page, cursor-based pagination
для великих таблиць, три типові production-помилки і як їх уникнути.
Якщо реалізуєте infinite scroll або мобільний API — почніть звідси.
Page в Spring Data JPA — пагінація з COUNT і countQuery
Три EXPLAIN ANALYZE сценарії для COUNT (простий, з фільтрами, з JOIN),
небезпечна комбінація JOIN FETCH + Page, оптимізація через countQuery
і @QueryHints. Все що потрібно знати щоб Page не став bottleneck.
List в Spring Data JPA — ризики без LIMIT і коли безпечно
Як розраховується реальне споживання heap (в 3–5 разів більше ніж рядок у БД),
таблиця деградації від 100 рядків до OOM, безпечні і небезпечні сценарії
з прикладами ❌/✅. Stream<T> як альтернатива для batch-обробки.
Specification в Spring Data JPA — динамічні фільтри без combinatorial explosion
Математика combinatorial explosion (2ⁿ методів для N фільтрів),
механіка Criteria API, JpaSpecificationExecutor, повний приклад
адмін-панелі з 4 динамічними фільтрами і N+1 вирішення через fetch у Specification.
❓ FAQ
Чи можна комбінувати Specification з List замість Page або Slice?
Технічно так — findAll(Specification) без Pageable повертає
List<T>. Але для більшості сценаріїв де потрібна Specification
(адмін-панель, пошук) дані можуть бути великими — і List без LIMIT небезпечний.
Виняток: якщо Specification використовується для завантаження малого
фіксованого набору даних (наприклад, конфігурація з динамічними умовами) —
List прийнятний. В усіх інших випадках — Specification + Slice або Page.
Page extends Slice — чи можна завжди використовувати Page замість Slice?
Технічно Page є Slice і надає всі його методи включаючи hasNext().
Але «можна» не означає «варто». Page завжди виконує COUNT — навіть якщо
ви використовуєте тільки hasNext() з його Slice-частини.
Це зайвий SQL-запит при кожному виклику. Slice обирають свідомо
саме щоб уникнути цього overhead.
Як мігрувати з List на Slice у існуючому проекті?
Міграція мінімальна на рівні Repository — змінити тип повернення
з List<T> на Slice<T> і додати
Pageable параметр. На рівні сервісу — передавати
PageRequest.of(page, size). На рівні контролера —
змінити тип відповіді і додати hasNext в DTO.
Найскладніша частина — визначити правильний pageSize
і переконатись що клієнт коректно обробляє hasNext = false.
Specification сповільнює запити порівняно з @Query?
Ні, якщо є правильні індекси. Specification генерує такий самий SQL
що і еквівалентний @Query — різниця тільки в тому як SQL будується
(програмно через Criteria API vs рядком). На рівні виконання PostgreSQL
обробляє їх однаково. Єдиний нюанс: кожна унікальна комбінація Specification
генерує новий query plan — при дуже великій кількості комбінацій це може
вплинути на Hibernate query plan cache. На практиці для 4–6 фільтрів несуттєво.
Stream<T> — де він у цьому порівнянні?
Stream<T> — окремий тип повернення для batch-обробки
великих даних. На відміну від List, він завантажує рядки поступово
через cursor без матеріалізації всіх об'єктів в heap одночасно.
Він не замінює Page або Slice для API і UI — він для серверної
обробки коли потрібно пройтись по мільйонах записів не завантажуючи
їх всі в пам'ять. Вимагає @Transactional і явного закриття.
✅ Висновки
Вибір між List, Page, Slice і Specification — це не питання переваг
чи стилю. Це архітектурне рішення що визначає кількість SQL-запитів,
споживання пам'яті і поведінку системи при зростанні даних.
- List — тільки для даних з гарантованим верхнім обмеженням. Для всього іншого — ризик
- Slice — дефолтний вибір для пагінації коли UI не показує загальну кількість
- Page — тільки коли UI дійсно відображає
totalElements або totalPages - Specification — як тільки опціональних фільтрів стає 3+ і derived methods перестають масштабуватись
- Specification не є самостійним — завжди використовується разом з Page або Slice
- JOIN FETCH + Page без countQuery — небезпечна комбінація яку варто активно виявляти
Джерела:
Spring Data JPA — офіційна документація
Vlad Mihalcea — Spring Data JPA Specifications
PostgreSQL — EXPLAIN документація