Питання 28 · Розділ 16

Як використовувати JOIN FETCH для вирішення проблеми N+1

JOIN FETCH — найпоширеніший спосіб вирішення проблеми N+1 в JPA. Він дозволяє завантажити пов'язані сутності в одному SQL-запиті замість N окремих запитів.

Мовні версії: English Russian Ukrainian

Огляд

JOIN FETCH — найпоширеніший спосіб вирішення проблеми N+1 в JPA. Він дозволяє завантажити пов’язані сутності в одному SQL-запиті замість N окремих запитів.


🟢 Junior Level

Що таке JOIN FETCH

JOIN FETCH — завантажує пов’язані сутності в одному запиті замість N+1.

// Проблема N+1:
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class)
    .getResultList();  // 1 запит
for (Order o : orders) {
    o.getItems().size();  // N запитів
}

// Рішення з JOIN FETCH:
List<Order> orders = em.createQuery(
    "SELECT DISTINCT o FROM Order o JOIN FETCH o.items", Order.class)
    .getResultList();  // 1 запит з JOIN!

Навіщо DISTINCT

// JOIN може дублювати Order (по одному на кожен OrderItem)
// DISTINCT прибирає дублікати в пам'яті
SELECT DISTINCT o FROM Order o JOIN FETCH o.items

🟡 Middle Level

Один JOIN FETCH на запит

// ✅ Один JOIN FETCH
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Order findByIdWithItems(@Param("id") Long id);

// ❌ Багато JOIN FETCH — Cartesian Product
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.items " +
       "JOIN FETCH o.payments " +
       "JOIN FETCH o.shippings")
// Якщо 10 замовлень × 5 items × 3 payments × 2 shippings = 300 рядків!

Альтернатива — @BatchSize

// Замість кількох JOIN FETCH:
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();

// + @BatchSize для items
@OneToMany(mappedBy = "order")
@BatchSize(size = 25)
private List<OrderItem> items;

Типові помилки

// ❌ Без DISTINCT — дублікати
@Query("SELECT o FROM Order o JOIN FETCH o.items")
List<Order> findAll();  // дублікати Order!

// ❌ JOIN FETCH з pagination
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items")
Page<Order> findAll(Pageable pageable);  // ❌ може не працювати

// ✅ З окремим count query
@Query(value = "SELECT DISTINCT o FROM Order o JOIN FETCH o.items",
       countQuery = "SELECT COUNT(o) FROM Order o")
Page<Order> findAll(Pageable pageable);

🔴 Senior Level

JOIN FETCH та pagination — проблема

Чому JOIN FETCH + pagination проблематична: Hibernate не може коректно застосувати LIMIT/OFFSET до запиту з JOIN — результат містить дублікати root-сутностей.

// Рішення 1: окремий count query
@Query(value = "SELECT DISTINCT o FROM Order o JOIN FETCH o.items",
       countQuery = "SELECT COUNT(DISTINCT o) FROM Order o")
Page<Order> findAllWithPage(Pageable pageable);

// Рішення 2: два запити
@Query("SELECT o.id FROM Order o")
Page<Long> findOrderIds(Pageable pageable);

@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findByIds(@Param("ids") List<Long> ids);

Best Practices

✅ Один JOIN FETCH на запит
✅ DISTINCT для усунення дублікатів
✅ @BatchSize для додаткових зв'язків
✅ EntityGraph для динамічного завантаження
✅ Різні методи для різних use cases

❌ Багато JOIN FETCH (Cartesian product)
❌ Без DISTINCT
❌ JOIN FETCH з pagination без countQuery

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • JOIN FETCH завантажує пов’язані сутності в одному SQL-запиті — рішення N+1
  • DISTINCT обов’язковий — прибирає дублікати root-об’єктів
  • Один JOIN FETCH на запит — кілька → Cartesian product
  • Для pagination з JOIN FETCH потрібен окремий countQuery
  • Альтернатива для додаткових зв’язків — @BatchSize замість кількох JOIN FETCH

Пов’язані теми:

  • [[1. Що таке проблема N+1 і як її вирішити]]
  • [[6. Що робить анотація @BatchSize]]
  • [[5. Які стратегії fetch існують в Hibernate]]
  • [[29. Що таке projection в JPA]]