List<T> — найпростіший тип повернення в Spring Data JPA.
Саме тому він найчастіше використовується там де не повинен.
Відсутність LIMIT у згенерованому SQL означає: розмір результату нічим не обмежений.
При таблиці на 100K+ рядків це пряма дорога до OutOfMemory або
деградації продуктивності — якщо не розуміти коли List безпечний.
⚡ Коротко
- ✅ List безпечний для довідників, малих таблиць і даних з гарантованим верхнім обмеженням
- ⚠️ List небезпечний для будь-якої таблиці що зростає з часом без явного LIMIT
- ⚠️ OutOfMemory — результат завантажується повністю в JVM heap перед поверненням
- ✅ Єдиний безпечний List для великих таблиць — тільки з явним LIMIT у
@Query
- 👇 Нижче — механіка, ризики і чіткі критерії коли List виправданий
📚 Зміст
Інші статті серії:
📌 Slice в Spring Data JPA — infinite scroll без COUNT(*)
📌 Page в Spring Data JPA — пагінація з COUNT і countQuery
📌 Specification — динамічні фільтри без combinatorial explosion
📌 List vs Page vs Slice vs Specification — повний гід у Spring Data JPA
🎯 Що повертає List — і чим це відрізняється від пагінації
List завантажує всі рядки що відповідають запиту — без жодного обмеження
Коли Spring Data JPA виконує метод що повертає List<T>,
Hibernate генерує SQL без LIMIT і без OFFSET.
Всі рядки що відповідають умові WHERE завантажуються з бази даних,
матеріалізуються в Java-об'єкти і зберігаються в пам'яті JVM
до того як метод повертає результат. Це фундаментальна відмінність від
Page і Slice — ті завжди мають LIMIT.
Пагінація — це контракт між додатком і БД: «поверни не більше N рядків».
List такого контракту не має. Розмір результату визначається даними,
а не кодом.
Три варіанти List у Spring Data — і що вони генерують
Варіант 1: findAll() без умов — найнебезпечніший.
Генерує SELECT * FROM table без жодних умов і обмежень.
При таблиці на 1M рядків — завантажує 1M об'єктів в heap.
❌ Як не треба робити:
// Небезпечно для будь-якої таблиці що зростає
@RestController
public class OrderController {
@GetMapping("/orders")
public List<Order> getAllOrders() {
return orderRepository.findAll(); // SELECT * FROM orders — без LIMIT
}
}
Чому це погано: сьогодні замовлень 5 000 — і все працює.
Через рік замовлень 2 000 000 — і цей endpoint покладе сервер.
Жодних попереджень, жодних помилок компіляції. Просто OOM в продакшені
о 3 годині ночі. Таблиця orders — це класичний приклад
таблиці що зростає з часом без верхнього обмеження.
Варіант 2: derived methods з WHERE — краще, але все одно без LIMIT.
Наприклад, findByStatus(String status) генерує
SELECT * FROM orders WHERE status = 'PENDING'.
Умова звужує вибірку — але не обмежує її розмір.
❌ Як не треба робити:
// Виглядає безпечно — але це ілюзія
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByStatus(String status); // SELECT ... WHERE status = ?
}
// У сервісі:
List<Order> pendingOrders = orderRepository.findByStatus("PENDING");
// Якщо PENDING-замовлень 300K — всі 300K в пам'яті
Чому це погано: розробник бачить WHERE-умову і думає
що запит «обмежений». Але WHERE фільтрує рядки — не обмежує їх кількість.
Розподіл статусів залежить від бізнесу: якщо обробка замовлень
сповільнилась і PENDING накопичились — один запит обваляє сервіс.
Варіант 3: @Query з явним LIMIT — єдиний безпечний варіант для великих таблиць.
Розробник явно контролює максимальний розмір вибірки.
✅ Як треба робити (якщо List дійсно потрібен):
// Явний LIMIT — розмір результату контрольований кодом
@Query("""
SELECT o FROM Order o
WHERE o.status = :status
ORDER BY o.createdAt DESC
LIMIT 50
""")
List<Order> findTopByStatus(@Param("status") String status);
// Завжди максимум 50 рядків — незалежно від розміру таблиці
Але тут варто задати собі питання: якщо потрібен LIMIT і є ймовірність
що даних більше — чи не краще Slice з Pageable?
Він дає ті самі гарантії і додатково повідомляє чи є ще дані.
Чому List ≠ пагінація — архітектурна різниця
Пагінація — це не просто «повернути частину даних».
Це архітектурний контракт що захищає додаток від неконтрольованого
зростання навантаження. Page і Slice гарантують:
незалежно від кількості рядків у таблиці, в пам'ять потрапить
не більше pageSize + 1 об'єктів за один запит.
Ця гарантія не залежить від розподілу даних, бізнес-логіки
або поведінки користувачів.
List такої гарантії не має. Ось наочна різниця того
що відбувається всередині при однаковому запиті:
// List — немає захисту від розміру даних
List<Order> orders = repository.findByStatus("PENDING");
// SQL: SELECT * FROM orders WHERE status = 'PENDING'
// Може повернути 10 рядків. Може 10 000 000. Код однаковий.
// Slice — завжди обмежено pageSize
Slice<Order> orders = repository.findByStatus("PENDING", PageRequest.of(0, 20));
// SQL: SELECT * FROM orders WHERE status = 'PENDING' LIMIT 21
// Завжди максимум 20 рядків в результаті. Гарантовано.
Якщо сьогодні в таблиці 1 000 рядків і List працює нормально —
це не означає що буде нормально через рік коли рядків стане 1 000 000.
Це latent bug що чекає свого часу.
🎯 Ризики: OutOfMemory і повний table scan
Два незалежних ризики — навантаження на БД і на JVM heap
List без LIMIT створює проблеми на двох рівнях одночасно.
На рівні бази даних — повний table scan або index scan без обмеження
кількості рядків що читаються. На рівні JVM — всі ці рядки
матеріалізуються в об'єкти і займають heap до завершення запиту.
При великих таблицях обидва ризики реалізуються одночасно.
OutOfMemory від List — це не теоретичний ризик.
Це реальна причина production-інцидентів коли таблиця
виростає до розмірів що не передбачались при написанні коду.
Як розраховується реальне споживання пам'яті
Рядок у PostgreSQL і Java-об'єкт в heap — це не одне й те саме.
Рядок у БД зберігається компактно: тільки дані і мінімальний overhead.
Java-об'єкт несе значно більший тягар:
- Object header — 16 байт на кожен об'єкт (mark word + class pointer)
- Поля entity — самі дані, але з Java-типами (String, LocalDateTime, BigDecimal — кожен є окремим об'єктом з власним header)
- Посилання на зв'язані entities — навіть LAZY-зв'язки зберігають proxy-об'єкт
- Hibernate identity map — кожен завантажений entity реєструється в Session cache для dirty checking
Практичний приклад з реальними цифрами:
// Entity Order з типовими полями@Entity
public class Order {
private Long id; // 8 байт + Long object overhead
private String orderNumber; // посилання + char[] масив
private BigDecimal total; // посилання + BigDecimal overhead ~100 байт
private String status; // посилання + char[]
private LocalDateTime createdAt; // посилання + LocalDateTime overhead
@ManyToOne(fetch = LAZY)
private User user; // proxy-об'єкт навіть без завантаження
// ... ще поля
}
// Рядок у PostgreSQL: ~150-200 байт
// Java Order об'єкт в heap: ~800-1200 байт (в 5-6 разів більше)
// 500K рядків в БД: ~100MB
// 500K Order об'єктів в JVM: ~500MB - 600MB тільки для даних
// + Hibernate Session cache: ще ~200MB
// Разом: ~700-800MB тільки від одного findAll() виклику
При типовому heap Spring Boot додатку в 512MB або 1GB —
один такий запит може вичерпати всю доступну пам'ять.
❌ Як не треба: findAll() у методі що викликається регулярно
// ПОГАНО — викликається при кожному запиті до API@Service
public class ReportService {
public Map<String, Long> getOrderCountByStatus() {
List<Order> allOrders = orderRepository.findAll(); // ❌
// Завантажує ВСІ замовлення щоб порахувати їх по статусах
return allOrders.stream()
.collect(Collectors.groupingBy(Order::getStatus, Collectors.counting()));
}
}
Чому це катастрофічно погано: задача — порахувати кількість
замовлень по статусах. Для цього завантажуються всі замовлення в пам'ять,
потім ітеруються в Java. БД вміє робити це в одному SQL-запиті без
передачі даних по мережі і без завантаження в heap:
// ДОБРЕ — агрегація на рівні БД@Query("SELECT o.status, COUNT(o) FROM Order o GROUP BY o.status")
List<Object[]> countByStatus();
// SQL: SELECT status, COUNT(*) FROM orders GROUP BY status
// Повертає максимум стільки рядків скільки різних статусів — зазвичай 3-5
Cascading effect: один запит блокує весь додаток
OutOfMemory — найгірший сценарій, але не єдиний спосіб як великий
List руйнує продуктивність:
GC pressure і stop-the-world паузи.
Завантаження 500K об'єктів створює величезне навантаження на garbage collector.
Коли GC не встигає звільняти пам'ять — відбувається Full GC: додаток
зупиняється на сотні мілісекунд або навіть секунди.
В цей час всі HTTP-запити чекають — включаючи прості запити
що зайняли б 5ms без GC-паузи.
❌ Прихована проблема: List у методі що здається безпечним
// Виглядає як просте завантаження налаштувань@Scheduled(fixedDelay = 60_000) // кожну хвилину
public void syncActiveUsers() {
List<User> activeUsers = userRepository.findByActiveTrue(); // ❌
// Якщо активних користувачів 200K — кожну хвилину 200K об'єктів в heap
// GC спрацьовує після кожного sync — деградація для всього додатку
activeUsers.forEach(this::processUser);
}
Чому це погано: scheduled задача виглядає нешкідливо,
але якщо findByActiveTrue() повертає сотні тисяч записів —
вона регулярно створює GC-тиск. Правильний підхід — обробка через
Slice пакетами або Stream з курсором.
З'єднання з БД зайняте на весь час читання.
Поки Hibernate читає мільйон рядків з БД, з'єднання з connection pool
зайняте. При типовому пулі HikariCP в 10–20 з'єднань —
якщо кілька таких «важких» запитів виконуються одночасно,
пул вичерпується. Нові запити отримують Connection timeout
навіть якщо самі по собі прості і швидкі.
Як проблема з'являється непомітно
Найнебезпечніше в List без LIMIT — це те що проблема невидима
при розробці і на старті проекту. Типовий lifecycle:
| Час | Рядків у таблиці | Час запиту | Heap | Статус |
|---|
| День 1 (розробка) | ~100 | <1ms | ~1MB | ✅ OK |
| Місяць 3 (реліз) | ~5 000 | ~10ms | ~50MB | ✅ OK |
| Місяць 12 | ~200 000 | ~400ms | ~800MB | ⚠️ Повільно |
| Місяць 24 | ~1 000 000 | ~2000ms | OOM | ❌ Інцидент |
Код не змінювався жодного разу. Змінились тільки дані.
І саме тому це latent bug — він не проявляється в тестах
де 100 рядків, і вистрілює в продакшені при масштабуванні.
🎯 Коли List безпечний — чіткі критерії
List безпечний коли розмір даних обмежений природою домену
Єдина умова безпечного List — гарантія що кількість рядків
що повертаються не може перевищити розумного ліміту
незалежно від зростання бізнесу. Якщо ця гарантія є —
List прийнятний. Якщо ні — потрібна пагінація або явний LIMIT.
Запитайте себе: «Чи може кількість рядків у цьому запиті
зрости до 10K через рік?». Якщо відповідь «так» або «не знаю» —
List без LIMIT є ризиком.
✅ Безпечні сценарії — з поясненням чому вони безпечні
Довідники і словники.
Таблиці з фіксованим або майже фіксованим набором значень:
країни (195 записів), валюти (~170), мови, типи документів.
Ці дані безпечні бо їх розмір обмежений зовнішньою реальністю —
кількість країн не збільшиться в 100 разів через рік.
// ✅ ДОБРЕ — довідник країн, розмір стабільний@Cacheable("countries")
public List<Country> getAllCountries() {
return countryRepository.findAll(); // 195 рядків, кешується
}
// findAll() тут виправданий і оптимальний:
// - розмір обмежений природою домену (країн у світі ~195)
// - @Cacheable означає SQL виконується рідко — тільки при cache miss
// - немає сенсу ускладнювати Slice для 195 статичних записів
Конфігурація що завантажується при старті.
Feature flags, налаштування, маппінги кодів — зазвичай десятки записів,
завантажуються один раз і кешуються на весь час роботи додатку.
// ✅ ДОБРЕ — конфігурація при старті додатку@Component
public class ConfigLoader {
@PostConstruct
public void loadConfig() {
List<AppConfig> configs = configRepository.findAll();
// Завантажується один раз — ризику немає
configs.forEach(config -> configMap.put(config.getKey(), config.getValue()));
}
}
Агрегація з GROUP BY де кількість груп обмежена.
Статистика по місяцях (12 рядків), по статусах (3–5 рядків),
по категоріях якщо їх кількість фіксована — List цілком прийнятний.
// ✅ ДОБРЕ — агрегація повертає обмежену кількість рядків@Query("""
SELECT MONTH(o.createdAt) as month, COUNT(o) as count
FROM Order o
WHERE YEAR(o.createdAt) = :year
GROUP BY MONTH(o.createdAt)
""")
List<Object[]> getMonthlyStats(@Param("year") int year);
// Максимум 12 рядків — незалежно від кількості замовлень в таблиці
❌ Небезпечні сценарії — з поясненням чому вони небезпечні
Таблиці що зростають без верхнього обмеження.
Замовлення, транзакції, події, логи, повідомлення — їх кількість
необмежена і залежить від активності бізнесу.
// ❌ ПОГАНО — таблиця що зростаєpublic List<Transaction> getTransactionsForReport() {
return transactionRepository.findByCreatedAtAfter(startOfYear);
// За рік транзакцій може бути 5 000 000
// findByCreatedAtAfter без LIMIT завантажить всі 5M в пам'ять
}
// ✅ ДОБРЕ — обмежуємо або агрегуємо
@Query("""
SELECT DATE(t.createdAt) as date, SUM(t.amount) as total
FROM Transaction t
WHERE t.createdAt > :startOfYear
GROUP BY DATE(t.createdAt)
ORDER BY date
""")
List<Object[]> getDailyTotals(@Param("startOfYear") LocalDateTime startOfYear);
// Повертає максимум 365 рядків незалежно від кількості транзакцій
Фільтри де розподіл даних непередбачуваний.
Особливо небезпечний патерн — коли розробник додає фільтр
і думає що це «обмежує» вибірку:
// ❌ ПОГАНО — виглядає обмежено, але не єList<Notification> findByReadFalse();
// Здається: «ненрочитаних повідомлень небагато»
// Реальність: якщо сервіс відправки повідомлень запрацював агресивніше
// або bulk-розсилка — за тиждень 2M непрочитаних повідомлень
// Один запит — 2M об'єктів в пам'яті
// ✅ ДОБРЕ — явний ліміт або пагінація
Slice<Notification> findByReadFalseOrderByCreatedAtDesc(Pageable pageable);
// PageRequest.of(0, 50) — завжди максимум 50 повідомлень
API endpoints без захисту.
Будь-який публічний або внутрішній endpoint що повертає List
без обмеження — вразливий до навмисного або випадкового abuse:
// ❌ ПОГАНО — endpoint без захисту@GetMapping("/api/products/search")
public List<Product> searchProducts(@RequestParam String query) {
return productRepository.findByNameContaining(query);
// query = "a" → може повернути 100K продуктів що містять букву "а"
// Один запит може покласти сервер
}
// ✅ ДОБРЕ — завжди захищаємо API пагінацією
@GetMapping("/api/products/search")
public SliceResponse<Product> searchProducts(
@RequestParam String query,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") @Max(100) int size) {
Pageable pageable = PageRequest.of(page, size);
Slice<Product> result = productRepository
.findByNameContainingOrderByNameAsc(query, pageable);
return new SliceResponse<>(result.getContent(), result.hasNext(), page);
}
🎯 SQL під капотом — три сценарії
List генерує SELECT без LIMIT — і це завжди потенційний full scan
Відсутність LIMIT означає що PostgreSQL повертає всі рядки що відповідають
умові. З індексом це index scan всіх відповідних рядків.
Без індексу — seq scan всієї таблиці. В обох випадках кількість
оброблених рядків не обмежена кодом.
Сценарій 1: findAll() — повний table scan
-- Spring Data генерує:SELECT c.id, c.name, c.code FROM countries c; -- для довідника — OK
SELECT o.id, o.status, o.total FROM orders o; -- для orders — небезпечно
-- EXPLAIN ANALYZE (таблиця countries, 200 рядків):
Seq Scan on countries (cost=0.00..4.00 rows=200 width=40)
Planning Time: 0.1 ms
Execution Time: 0.3 ms ✅ прийнятно для довідника
-- EXPLAIN ANALYZE (таблиця orders, 1M рядків):
Seq Scan on orders (cost=0.00..28000.00 rows=1000000 width=120)
Planning Time: 0.2 ms
Execution Time: 2800 ms ❌ 2.8 секунди + 1M об'єктів в heap
Та сама операція findAll(), кардинально різний результат.
Ключова різниця — не в методі, а в природі таблиці:
countries не зростає, orders зростає нескінченно.
Сценарій 2: derived method з WHERE — часткова ілюзія безпеки
Найчастіша помилка: розробник додає WHERE-умову і вважає що запит
«обмежений». Але WHERE фільтрує рядки — не обмежує їх кількість.
❌ Як не треба думати:
// «Активних користувачів не так багато» — небезпечне припущенняList<User> findByActiveTrue();
-- EXPLAIN при 50K активних користувачів:
Bitmap Index Scan on idx_users_active
Index Cond: (active = true)
Bitmap Heap Scan on users
Recheck Cond: (active = true)
Rows Removed by Recheck: 0
rows=50000 -- 50K рядків без обмеження
Execution Time: 180 ms
-- Плюс: 50K User об'єктів в heap ≈ 200-300MB
-- Плюс: 50K записів в Hibernate identity map
Чому це небезпечніше ніж здається: розробник бачить
швидкий EXPLAIN (180ms) і вирішує що все нормально. Але він не враховує
що 50K об'єктів в heap — це 200MB пам'яті, і при 10 паралельних
запитах це вже 2GB тільки від цієї операції.
-- Після додавання LIMIT (правильно):SELECT u.id, u.email, u.active FROM users u
WHERE u.active = true
ORDER BY u.created_at DESC
LIMIT 21; -- Slice або явний LIMIT
Index Scan Backward using idx_users_created_at on users
Filter: (active = true)
rows=21
Execution Time: 0.4 ms ✅ 21 рядок замість 50K
Сценарій 3: @Query з явним LIMIT — правильний підхід для List
Якщо List дійсно потрібен (а не Slice), явний LIMIT в запиті —
єдиний спосіб гарантувати розмір результату:
// ✅ ДОБРЕ — явний LIMIT, розмір контрольований@Query("""
SELECT o FROM Order o
WHERE o.status = :status
ORDER BY o.createdAt DESC
LIMIT 100
""")
List<Order> findTopByStatus(@Param("status") String status);
-- SQL:
SELECT o.id, o.status, o.total, o.created_at
FROM orders o
WHERE o.status = 'PENDING'
ORDER BY o.created_at DESC
LIMIT 100;
Index Scan Backward using idx_orders_created_at on orders
Filter: (status = 'PENDING')
rows=100
Execution Time: 0.8 ms ✅ завжди максимум 100 рядків
Але перш ніж писати List з явним LIMIT —
задайте собі питання: чи є ще дані за цим лімітом?
Якщо так — користувач або клієнт API не знатиме про це.
Slice вирішує обидві задачі одночасно: обмежує розмір
і повідомляє про наявність наступної порції через hasNext().
// Порівняння: коли List з LIMIT, коли Slice// List з LIMIT — підходить коли:
// - потрібні топ-N записів для внутрішньої обробки
// - клієнту не потрібно знати чи є ще дані
// - фіксована вибірка: «останні 10 активностей для логу»
@Query("SELECT e FROM Event e ORDER BY e.createdAt DESC LIMIT 10")
List<Event> findLatestEvents();
// Slice — підходить коли:
// - клієнт може запросити наступну порцію
// - потрібен контроль і над розміром, і над наявністю наступних даних
// - infinite scroll, «завантажити ще», API з пагінацією
Slice<Product> findAllByOrderByCreatedAtDesc(Pageable pageable);
🎯 List vs Page vs Slice — правило вибору
Три запитання визначають правильний тип
Чи може кількість рядків перевищити розумний ліміт? → Якщо так — не List.
Чи потрібна загальна кількість елементів або сторінок? → Якщо так — Page.
Потрібна тільки наступна порція? → Slice.
Дані гарантовано малі і стабільні? → List.
| Критерій | List | Slice | Page |
|---|
| LIMIT в SQL | ❌ відсутній | ✅ pageSize+1 | ✅ pageSize |
| COUNT(*) запит | ❌ | ❌ | ✅ завжди |
| Захист від OOM | ❌ | ✅ | ✅ |
| Довідники / словники | ✅ оптимально | ⚠️ overhead | ⚠️ overhead |
| Infinite scroll / «ще» | ❌ | ✅ | ⚠️ зайвий COUNT |
| Адмін-панель з лічильником | ❌ | ❌ | ✅ |
| Таблиці що зростають | ❌ ризик | ✅ | ✅ |
| Batch-обробка з LIMIT | ✅ з @Query | ✅ | ⚠️ зайвий COUNT |
| Агрегація / GROUP BY | ✅ | ⚠️ | ⚠️ |
Правило вибору одним реченням
List — коли розмір результату обмежений природою домену
і не залежить від зростання даних.
Slice — коли потрібна наступна порція даних
і загальна кількість не важлива.
Page — коли UI потребує загальної кількості
елементів або сторінок.
Детальний розбір кожного типу — в окремих статтях серії:
❓ FAQ
findAll() завжди небезпечний?
Ні — залежить від таблиці. findAll() на таблиці
countries (195 рядків) або currencies (~170 рядків) —
абсолютно прийнятний і типовий паттерн, особливо з @Cacheable.
findAll() на таблиці orders або transactions
що зростає з часом — ризик який реалізується при масштабуванні.
Питання не в методі, а в таблиці до якої він застосовується.
Чи є різниця між List<T> і Collection<T> як типом повернення?
З точки зору генерованого SQL — ніякої. Обидва типи повернення
призводять до виконання запиту без LIMIT. Різниця тільки в Java-контракті:
List гарантує порядок і доступ по індексу,
Collection — ні. Для Spring Data JPA обидва еквівалентні
з точки зору поведінки.
Stream<T> як альтернатива List для великих даних?
Так, і це важлива альтернатива для batch-обробки. Stream<T>
в Spring Data JPA завантажує рядки поступово (через cursor або fetch size),
а не всі одразу в heap. Це дозволяє обробляти мільйони записів
без ризику OOM — але вимагає явного закриття stream і обов'язково
транзакційного контексту (@Transactional).
Для batch-jobs де потрібно пройтись по всіх записах — Stream<T>
краще за List<T>.
Як захиститись від випадкового findAll() на великій таблиці?
Кілька підходів в залежності від контексту. По-перше, code review policy:
будь-який метод що повертає List без @Query з LIMIT
або без Pageable-параметра має бути явно обґрунтований.
По-друге, моніторинг повільних запитів: запити без LIMIT на великих таблицях
будуть видні в slow query log PostgreSQL (log_min_duration_statement).
По-третє, integration tests з реалістичним обсягом даних —
тест на 1M рядків покаже проблему до продакшену.
List і @Cacheable — правильна комбінація?
Так, для довідників це стандартний і правильний паттерн.
@Cacheable на методі що повертає List
означає: перший виклик виконує SQL і зберігає результат в кеші,
всі наступні — повертають кешований List без SQL-запиту.
Це особливо ефективно для даних що рідко змінюються:
країни, категорії, конфігурація. Важливо: кеш має мати
розумний TTL і інвалідуватись при змінах у довіднику.
✅ Висновки
List<T> — не «погана» абстракція.
Це правильний інструмент для конкретного класу задач:
довідники, агрегація, дані з гарантованим верхнім обмеженням.
Проблема виникає коли List використовується для таблиць що зростають —
і тоді це latent bug що чекає свого часу.
- List не має LIMIT — розмір результату визначається даними, а не кодом
- OOM від List — це не баг запиту, це наслідок зростання даних яке не було передбачено
- Безпечний List — тільки для даних де розмір обмежений природою домену
- Таблиці що зростають з часом завжди вимагають Slice або Page
Stream<T> — правильна альтернатива List для batch-обробки великих даних- Запитання «чи може цих рядків стати 10K через рік» — достатній критерій для рішення
Джерела:
Spring Data JPA — Query Methods documentation
Spring Data JPA — Streaming Query Results
PostgreSQL — EXPLAIN документація