JavaRush /Курси /Spring Data JPA /join fetch: один зап...

join fetch: один запит замість N + 1

Spring Data JPA
Рівень 22 , Лекція 1
Відкрита

1. join fetch після N+1

Якщо ви щойно побачили N+1 у SQL-логах, у вас може виникнути велика спокуса почати «закручувати гайки» в мапінгу: десь поставити EAGER, десь зробити зв’язки однонапрямними, десь узагалі видалити колекцію й зберігати лише productId. Це емоційно зрозуміло, але зазвичай призводить до нових проблем: зайві дані, важкі читання, дивні графи об’єктів і раптові гальма вже в інших місцях. join fetch гарний тим, що це не зміна моделі, а точкове налаштування читання під конкретний сценарій використання.

Уявіть типовий сценарій із нашого проєкту mini-shop. Нам потрібна картка замовлення: номер, статус, сума та список позицій, а всередині позиції — товар (хоча б sku і name). У моделі сутностей це виглядає як CustomerOrder -> items -> product. Якщо ми читаємо замовлення звичайним findByOrderNumber(...), а потім уже в сервісі або десь «ззовні» починаємо ходити по order.getItems() і item.getProduct(), Hibernate чесно виконуватиме роботу: «Гаразд, хочеш зв’язок — тримай запит». І так багато разів.

Ідея join fetch проста: прочитати базову сутність і потрібні зв’язки одразу, в одному SQL, через JOIN. Тоді ми не «лікуємо» lazy loading, ми керуємо ним: залишаємо зв’язки LAZY, але для конкретного читання кажемо ORM: «У цьому сценарії завантаж зв’язок одразу».

2. join vs join fetch

У JPQL (і в HQL) слово join звучить знайомо, тому що воно схоже на SQL. Але є важливий нюанс: у JPQL join може використовуватися просто для логіки запиту — умов, фільтрації, сортування, — а join fetch уже є командою ORM: «не лише з’єднай таблиці, а й ініціалізуй асоціацію в результаті читання». Це виглядає як невелике додавання fetch, але сенс у нього дуже дорослий: ви змінюєте план завантаження.

Зафіксуймо різницю в компактній таблиці. Вона не робить із вас DBA, але допомагає мозку перестати плутати «join як умову» і «join як завантаження».

Що пишемо в JPQL Навіщо Що отримує Hibernate Що ви отримуєте в Java-об’єктах
join p.category c виразити умову, фільтр або сортування join у SQL може бути, але зв’язок не зобов’язаний стати завантаженим p.getCategory() може залишитися lazy-проксі й підвантажитися пізніше
join fetch p.category завантажити зв’язок у тому самому читанні join у SQL + матеріалізація графа p.getCategory() уже готова всередині результату

Важливо не переплутати: join fetch — це не «оптимізація заради оптимізації», а інструмент контролю. Він корисний рівно тоді, коли ви можете чесно сказати: «У цьому сценарії читання цей зв’язок потрібен одразу». В інших випадках він перетворюється на «підвантажимо все, раптом знадобиться», а це вже дорога до зайвих даних і важких запитів.

3. join fetch для ManyToOne: Product + Category

Картка товару — один із найзручніших сценаріїв для join fetch. У нас є Product, у нього є Category (ManyToOne). У типовому сценарії читання категорія потрібна хоча б за назвою або кодом, тому що UI чи звіт хоче показати «Товари в категорії X». І якщо ми точно знаємо, що категорія потрібна, то простіше один раз завантажити її разом із товаром.

Нижче — акуратний метод репозиторію. Зверніть увагу: ми не змінюємо FetchType на EAGER. Ми залишаємо мапінг як є — найімовірніше, LAZY, — але для конкретного методу додаємо fetch join.

package com.example.shopdatajpa.catalog.repository;

import com.example.shopdatajpa.catalog.entity.Product;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Картка товару: одразу підвантажуємо category, щоб не отримати N+1 під час звернення до p.getCategory()
    // Важливо: це не змінює мапінг на EAGER, це точкова стратегія завантаження під конкретний сценарій використання
    @Query("""
           select p
           from Product p
           join fetch p.category
           where p.id = :id
           """)
    Optional<Product> findCardById(Long id);
}

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

package com.example.shopdatajpa.catalog.service;

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogReadService {

    private final ProductRepository productRepository;

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

    // readOnly=true: ми явно показуємо, що це читання, а не зміна даних
    // У межах транзакції сутність і асоціації, підвантажені fetch join, будуть доступні без додаткового SELECT
    @Transactional(readOnly = true)
    public Optional<Product> loadProductCard(Long id) {
        // Метод репозиторію вже налаштований під картку: category буде підвантажено одним SQL
        return productRepository.findCardById(id);
    }
}

Якщо увімкнені SQL-логи (ми робили це раніше), ви побачите приблизно один запит із join. Приблизно так: точний SQL залежить від назв таблиць і колонок, а також від діалекту.

-- Один запит замість ланцюжка SELECT'ів по lazy-зв’язках
select
  p.*, c.*
from product p
join category c on c.id = p.category_id
-- Умова для конкретної картки товару
where p.id = ?

Психологічно це дуже приємний момент: ми перестаємо гадати, «скільки запитів вилетить», і починаємо проєктувати читання так, щоб запит був один і зрозумілий. Для картки одного товару join fetch зазвичай працює передбачувано і без сюрпризів.

4. Collection join fetch у картці замовлення

На колекціях join fetch стає схожим на потужний інструмент, з яким треба поводитися обережно. Він усе ще вирішує N+1, але ціна помилки вища: ви легко можете отримати дублікати, роздутий результат і дуже дивну поведінку під час пагінації. Тому ми використовуємо collection fetch join там, де це справді «картка», тобто читання однієї сутності зі зв’язаними даними, а не «список із тисячі замовлень».

У нашому проєкті картка замовлення зазвичай означає саме замовлення (CustomerOrder), його позиції (OrderItem) і товари в позиціях (Product). Якщо читати це наївно, виникає N+1 на items, а потім ще N+1 на item.product. У підсумку одне замовлення перетворюється на серію запитів, особливо якщо позиція за позицією потребує підвантаження товару.

JPQL із fetch join тут виглядає так:

package com.example.shopdatajpa.ordering.repository;

import com.example.shopdatajpa.ordering.entity.CustomerOrder;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface CustomerOrderRepository extends JpaRepository<CustomerOrder, Long> {

    // Картка замовлення: витягуємо замовлення + позиції + товари в позиціях одним запитом
    // left join fetch: замовлення повернемо навіть якщо items раптом порожні (отримаємо порожню колекцію)
    // distinct: захищаємося від логічних дублікатів кореневої сутності через JOIN по колекції
    @Query("""
           select distinct o
           from CustomerOrder o
           left join fetch o.items i
           left join fetch i.product
           where o.orderNumber = :orderNumber
           """)
    Optional<CustomerOrder> findCardByOrderNumber(String orderNumber);
}

Тут одразу видно кілька важливих речей.

По-перше, left join fetch, а не join fetch. Це свідомо безпечніший варіант: навіть якщо у замовлення з якоїсь причини немає позицій (в ідеальному світі так не буває, але навчальні дані іноді живуть своїм життям), ми все одно зможемо завантажити замовлення. Для картки зазвичай хочеться «отримати замовлення й порожній список», а не «не отримати нічого».

По-друге, ми використовуємо distinct. Це не спроба прикрасити запит, а захист від логічних дублікатів, які з’являються під час завантаження колекції.

Звідки беруться дублікати під час завантаження колекції через fetch join

Коли ви робите join таблиці замовлення і таблиці позицій замовлення, база даних повертає рядки виду «замовлення + позиція». Якщо в одного замовлення 3 позиції, то в результаті буде 3 рядки, і дані замовлення повторяться 3 рази. На SQL-рівні це нормально: реляційна модель саме так і працює.

Проблема починається на рівні Java-об’єктів: ми хочемо отримати один CustomerOrder, усередині якого колекція items із трьох елементів. А SQL повертає три рядки. Hibernate вміє зібрати це назад в об’єктний граф, але при цьому в результаті JPQL-запиту можуть з’явитися «логічні дублікати» кореневої сутності, якщо ви повертаєте список.

Якщо ви повертаєте Optional<CustomerOrder> за orderNumber, дублікати кореня не такі страшні: Hibernate все одно збере один CustomerOrder у persistence context, а позиції додасть до колекції. Але distinct тут усе одно корисний, тому що він допомагає прибрати зайву «повторювану» видачу кореня на рівні JPQL-результату та робить поведінку передбачуванішою.

Важливо також розуміти, що distinct у JPQL — це не завжди «SQL DISTINCT у лоб». Hibernate може застосовувати distinct як на SQL-рівні, так і як de-duplication на рівні об’єктів. Для нас, як для студентів, головне практичне правило просте: під час fetch join колекції distinct часто потрібен, щоб не отримати дивний результат, особливо якщо ви повертаєте List<CustomerOrder>.

Картка vs список: де collection fetch join небезпечний

У картці замовлення ми читаємо одне замовлення. Навіть якщо в нього 20 позицій, це все ще прийнятний обсяг. Так, SQL буде ширшим, так, рядків буде 20, але це очікувано, і це рівно те, що потрібно сценарію використання. Ми заплатили одну зрозумілу ціну замість N+1.

А от якщо ви спробуєте зробити «список замовлень» і fetch join на items для кожного — дуже швидко отримаєте лавину даних: не N+1 запитів, а один запит, який повертає величезне простирадло рядків. Іноді це навіть гірше, ніж N+1, тому що ви почнете вантажити купу позицій, які користувачеві на екрані списку взагалі не потрібні.

5. Pageable vs collection join fetch

Пагінація — це контракт: «Дайте мені 20 замовлень на сторінку». База даних робить це через LIMIT/OFFSET або схожу механіку, і все виглядає красиво… доки ви не перетворюєте «20 замовлень» на «20 замовлень × позиції кожного замовлення». З точки зору SQL ви пагінуєте вже по рядках join-результату, а не по кореневих замовленнях. І це ламає очікування: ви можете отримати менше замовлень, ніж просили, або отримати замовлення з неповною колекцією, або Hibernate буде змушений «дочитувати» дані в пам’яті.

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

package com.example.shopdatajpa.ordering.repository;

import com.example.shopdatajpa.ordering.entity.CustomerOrder;
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 CustomerOrderRepository extends JpaRepository<CustomerOrder, Long> {

    // Увага: це типовий приклад «виглядає красиво, але ламає очікування пагінації»
    // Причина: JOIN по колекції розмножує рядки, а LIMIT/OFFSET застосовується вже до join-результату
    @Query("""
           select distinct o
           from CustomerOrder o
           left join fetch o.items
           """)
    Page<CustomerOrder> findAllWithItems(Pageable pageable);
}

Проблема тут не в тому, що запит «поганий як текст». Проблема в тому, що ви намагаєтеся одним запитом одночасно вирішити дві задачі, які погано поєднуються: «посторінково вибрати корені» й «у цей самий момент витягнути колекцію кожного кореня». На рівні JPA це майже завжди або заборонено, або нестабільно, або працює «якось», але не так, як ви думаєте.

Як це розпізнати на практиці: ви запускаєте метод і раптом бачите попередження в логах, дивний розмір сторінки, неочікувану кількість об’єктів у результаті або величезне навантаження на пам’ять. І це той момент, де правильна реакція — не «підкрутити анотацію», а переформулювати сценарій використання: можливо, у списку замовлень вам не потрібні позиції, а потрібен лише summary.

Але про альтернативні інструменти ми сьогодні поговоримо пізніше; у межах цієї лекції достатньо зафіксувати межу: collection join fetch — це, як правило, інструмент для карток, а не для посторінкових списків.

6. Перевіряємо, що join fetch прибрав N+1

Після додавання join fetch у новачків буває дуже людська помилка: «Код став красивішим, отже проблема вирішена». На жаль, Hibernate не оцінює красу коду; він читає мапінг і запити. Тому обов’язковий ритуал зрілого розробника — подивитися SQL-логи до та після.

Уявімо, що раніше ви робили так:

package com.example.shopdatajpa.ordering.service;

import com.example.shopdatajpa.ordering.entity.CustomerOrder;
import com.example.shopdatajpa.ordering.repository.CustomerOrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderReadService {

    private final CustomerOrderRepository orderRepository;

    public OrderReadService(CustomerOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true)
    public int countItems(String orderNumber) {
        // Наївне читання: отримуємо замовлення без підвантаження колекції items
        CustomerOrder order = orderRepository.findByOrderNumber(orderNumber).orElseThrow();

        // Якщо items = LAZY, виклик size() може тригерити додатковий SELECT (класичний N+1)
        return order.getItems().size();
    }
}

Якщо itemsLAZY, то order.getItems().size() у межах транзакції зазвичай спрацює, але викличе додатковий SQL. Якщо далі ви полізете в item.getProduct(), отримаєте ще більше запитів. З join fetch ви хочете бачити, що все потрібне приїхало одним SQL, і додаткових запитів немає.

Після заміни на findCardByOrderNumber(...) у логах має стати помітно тихіше. Приблизний «здоровий» патерн логів: один select ... join ... join ... where order_number = ?, і все. Якщо ви й далі бачите окремі select по order_item або product після основного запиту — значить, ви не підвантажили потрібний зв’язок, або підвантажили не той, або читаєте вже інший об’єкт іншим способом.

Тут добре працює просте правило: якщо сценарій використання називається «картка замовлення», ви повинні бути здатні показати рівно один або максимум два SQL-запити та пояснити, чому саме стільки. Якщо ви не можете це пояснити, то, швидше за все, знову живете у світі «воно якось само».

7. Типові помилки під час використання join fetch

Помилка № 1: перетворити join fetch на кнопку «підвантаж усе одразу».
Дуже легко почати додавати join fetch усюди, тому що після N+1 хочеться «ніколи більше не бачити зайвий SELECT». Проблема в тому, що ви лікуєте страх, а не сценарій використання. У підсумку запити починають тягнути купу зв’язків, які не потрібні, дані роздуваються, і застосунок стає повільнішим уже з іншої причини — тому що він читає зайве.

Помилка № 2: плутати join і join fetch та чекати, що зв’язок «сам стане завантаженим».
Звичайний join у JPQL допомагає виразити умови, але не гарантує, що асоціація буде ініціалізована в об’єкті. Новачки пишуть join, бачать у SQL JOIN, а потім дивуються, чому все одно були додаткові SELECT під час звернення до зв’язку. Якщо ваша мета — завантаження зв’язку, слово fetch має бути в запиті явно.

Помилка № 3: забути про distinct на колекції та отримати дивний результат.
Коли ви завантажуєте колекцію через fetch join, коренева сутність з’являється в рядках результату багаторазово. Це нормальна природа JOIN. Але на рівні JPQL-результату ви можете отримати логічні дублікати. distinct часто робить поведінку стабільнішою. І так, це той рідкісний випадок, коли «зайве слово» в запиті реально рятує нервову систему.

Помилка № 4: використовувати collection join fetch разом із Pageable, а потім пояснювати баги «дивинами Hibernate».
З точки зору розробника це виглядає як «я просто хочу сторінку замовлень, і щоб там були позиції». З точки зору ORM і SQL — ви просите зробити дві погано сумісні речі одним махом. Якщо ви бачите, що список і пагінація почали поводитися підозріло, перший підозрюваний — fetch join колекції.

Помилка № 5: робити fetch join на все підряд, а потім дивуватися, що запит став «товстим» і повільним.
Fetch join усуває N+1, але не скасовує базову фізику: один запит може бути важким, якщо він тягне занадто багато колонок і рядків. ORM не скасовує обмежень мережі, пам’яті та диска. Тому хороший join fetch — це завжди вузький запит під конкретну картку, а не універсальне «вивантаж базу даних».

1
Задача
Spring Data JPA, 22 рівень, 1 лекція
Недоступна
Картка товару через `join fetch` для зв'язку `Product -> Category`
Картка товару через `join fetch` для зв'язку `Product -> Category`
1
Задача
Spring Data JPA, 22 рівень, 1 лекція
Недоступна
Картка замовлення через collection `join fetch`
Картка замовлення через collection `join fetch`
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ