1. Когда нужен JOIN FETCH
Если вы только начали разбираться в Hibernate, легко попасть в ловушку: вы видите в коде один запрос, в логах — один запрос, и кажется, что чтение «дешёвое». А потом вы добавляете строчку order.getItems().size() (или просто логируете объект), и Hibernate неожиданно делает ещё один SQL. Это выглядит как мистическая подстава, хотя на самом деле это честное следствие LAZY: «я прочитаю связь, когда ты попросишь».
В Commerce Persistence Lab очень естественный сценарий — детально загрузить заказ, показать клиента и позиции заказа. Наивный код «прочитай заказ, а дальше как-нибудь» выглядит так:
import jakarta.persistence.EntityManager;
import java.util.List;
public class OrderReadExample {
public static void showOrder(EntityManager em, long orderId) {
// Читаем только сам заказ (без коллекций), потому что связь обычно LAZY
var order = em.find(PurchaseOrder.class, orderId);
// На этом месте Hibernate ещё может НЕ ходить в БД за items (зависит от настроек/состояния)
List<OrderItem> items = order.getItems(); // может триггернуть SQL
// А вот здесь уже почти наверняка произойдёт инициализация коллекции (и дополнительный SELECT)
System.out.println("items = " + items.size()); // items = 5
}
}
Сама строка order.getItems() (или items.size()) — это и есть момент «а теперь реально сходи в базу за коллекцией». Hibernate не вредничает: он просто честно выполняет контракт LAZY.
Теперь представьте не один заказ, а список из 20 заказов в админке. Вы читаете 20 заказов одним запросом, а потом в цикле у каждого заказа трогаете items — и получаете уже классический N+1. И в этот момент у нас появляется запрос: «Можно ли попросить Hibernate загрузить связь сразу, пока мы и так читаем заказ?»
Ответ: да. Именно для этого и нужен JOIN FETCH.
2. join vs join fetch
Слова join и join fetch в JPQL похожи настолько, что мозг автоматически думает: «Окей, join — значит данные соединятся и у меня всё будет загружено». Но Hibernate устроен хитрее (и честнее): обычный join — это способ связать таблицы для условий и сортировки, а join fetch — это явная команда «и заодно инициализируй ассоциацию в возвращаемой сущности».
Представьте бытовую аналогию. Обычный join — это как сказать в кафе: «Сравните, пожалуйста, все мои заказы и найдите те, где есть десерт». Вам принесли список заказов, но десерт в пакет не положили — вы просто использовали факт его существования, чтобы отфильтровать. А join fetch — это: «Принесите заказ и сразу положите десерт в пакет, чтобы я потом не бегал второй раз».
Небольшая таблица, чтобы закрепить:
| JPQL-фрагмент | Зачем обычно используют | Гарантирует ли инициализацию связи в entity? |
|---|---|---|
| join o.items i | фильтрация/условия по items, сортировка, проверка существования | нет, это не “fetch-plan” |
| join fetch o.items | загрузить items сразу вместе с o | да, это прямой смысл fetch |
Важно: JOIN FETCH — это не изменение аннотации fetch = EAGER. Это настройка конкретного запроса, то есть решение на уровне use case. Мы остаёмся в правильной философии курса: LAZY — базовая модель, а «подгрузить заранее» делаем точечно.
3. JOIN FETCH для to-one
Начинать лучше всего с to-one связей (ManyToOne, OneToOne), потому что они проще по форме результата. Один заказ связан с одним клиентом — значит, в SQL join обычно не раздувает количество строк радикально (об «раздувании» мы поговорим на коллекциях, то есть to-many, и особенно — в следующей лекции).
В нашем проекте PurchaseOrder почти наверняка имеет связь на Customer. В терминах кода это что-то вроде:
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
@Entity
public class PurchaseOrder {
// Важно: LAZY — значит Customer не грузится автоматически при чтении PurchaseOrder
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
// ...
}
Если мы читаем заказ через find, а затем трогаем order.getCustomer().getEmail(), Hibernate может сделать дополнительный запрос за Customer (зависит от того, был ли он уже в persistence context). Чтобы явно сказать «прочитай заказ сразу вместе с клиентом», мы пишем JPQL с join fetch:
import jakarta.persistence.EntityManager;
public class PurchaseOrderQuery {
private final EntityManager em;
public PurchaseOrderQuery(EntityManager em) {
this.em = em;
}
public PurchaseOrder findWithCustomer(long orderId) {
// JOIN FETCH здесь нужен не для фильтрации, а чтобы гарантированно инициализировать o.customer
return em.createQuery(
"""
select o from PurchaseOrder o
join fetch o.customer
where o.id = :id
""",
PurchaseOrder.class
)
// Параметр запроса: читаем конкретный заказ по id
.setParameter("id", orderId)
.getSingleResult();
}
}
Здесь важно не только то, что в SQL появится JOIN, но и то, что Hibernate инициализирует ассоциацию customer в возвращаемой сущности PurchaseOrder. То есть вы получаете PurchaseOrder, у которого customer уже загружен, и обращение к полям клиента в текущем контексте не должно запускать дополнительный SQL.
Если попробовать объяснить это максимально «физически», JOIN FETCH делает два дела одновременно. Он говорит базе «верни мне данные из двух таблиц одним запросом» и говорит Hibernate «когда будешь собирать объект PurchaseOrder, сразу положи в него Customer, не откладывай на потом».
4. inner vs left join fetch
Слово join в JPQL по умолчанию означает inner join. Это тонкий момент: inner join по смыслу говорит «верни только те строки, где связь существует». Для обязательных связей (например, order.customer всегда задан) это обычно нормально. Но как только связь может отсутствовать, inner join fetch начинает вести себя очень логично и очень неожиданно: он просто не вернёт “родителя”, у которого нет “ребёнка”.
Чтобы увидеть это проще, возьмём пример из каталога: Product и ProductDetails. В лабораторной модели детали могут быть не у всех товаров (например, тестовые данные, или товар ещё не заполнен контент-менеджером). Тогда Product.details — связь, которая может быть null.
И вот тут важно различать два запроса:
// INNER JOIN FETCH: если деталей нет — сам Product не вернётся из запроса
var product = em.createQuery(
"select p from Product p " +
"join fetch p.details " +
"where p.id = :id",
Product.class
)
.setParameter("id", productId)
.getSingleResult();
и
// LEFT JOIN FETCH: Product вернётся всегда, а details будет null, если строки нет
var product = em.createQuery(
"select p from Product p " +
"left join fetch p.details " +
"where p.id = :id",
Product.class
)
.setParameter("id", productId)
.getSingleResult();
В первом случае, если у товара нет details, запрос просто не вернёт Product вовсе (потому что inner join исключит строку). Во втором случае left join fetch скажет базе: «Верни товар в любом случае, а детали — если они есть». Для optional-связей это обычно то, что нужно.
Полезная ментальная подсказка: join fetch — это «связь обязана существовать, иначе мы выкидываем родителя», а left join fetch — «связь опциональна, родителя не выкидываем». В реальном проекте вы редко хотите «случайно потерять заказ», потому что у него нет чего-то необязательного, поэтому на optional-связях left join fetch — довольно частый выбор.
5. JOIN FETCH для to-many
Коллекции (OneToMany, ManyToMany) — это то место, где JOIN FETCH становится особенно соблазнительным, потому что именно коллекции чаще всего порождают N+1. Вы читаете список заказов и потом в цикле трогаете order.getItems() — и всё, вы в ловушке.
Для детального чтения одного заказа это обычно выглядит очень красиво: один SQL, внутри — join, и позиции заказа уже загружены.
Пример на PurchaseOrder.items:
import jakarta.persistence.EntityManager;
public class PurchaseOrderQuery {
private final EntityManager em;
public PurchaseOrderQuery(EntityManager em) {
this.em = em;
}
public PurchaseOrder findWithItems(long orderId) {
// LEFT JOIN FETCH: заказ вернётся даже если items пустые (коллекция будет просто пустой)
return em.createQuery(
"""
select distinct o from PurchaseOrder o
left join fetch o.items
where o.id = :id
""",
PurchaseOrder.class
)
.setParameter("id", orderId)
.getSingleResult();
}
}
И да, здесь лучше сразу привыкать к форме select distinct o. Для чтения одного заказа по id дубликаты корня легко не заметить, но join по коллекции уже размножает строки результата на каждую позицию. Сам red flag важно увидеть сразу: collection JOIN FETCH решает N+1, но начинает платить за это формой результата.
Почему здесь часто используют left join fetch, а не join fetch? Потому что заказ теоретически может существовать без позиций (черновик, пустой заказ в тестах, ошибка данных). left join fetch позволяет получить заказ даже если items пустые.
С точки зрения «что изменилось в SQL» — у вас теперь будет join с таблицей order_item, и база вернёт данные заказа и каждой позиции. Hibernate на основе этого результата соберёт один объект PurchaseOrder и заполнит коллекцию items.
На этом месте важно остановить внутреннего перфекциониста, который хочет тут же применить JOIN FETCH вообще везде. На to-many есть нюанс: join раздувает число строк результата, потому что одна строка заказа «повторяется» на каждую строку item’а. Это не ошибка, а математика. Здесь достаточно заметить сам red flag: JOIN FETCH коллекции — мощный инструмент, но у него есть цена в виде дубликатов корня, DISTINCT и чувствительности к пагинации.
6. JOIN FETCH в коде проекта
Когда вы поняли, что JOIN FETCH — это часть read use case, возникает практический вопрос: где это жить должно? В сервисе? В репозитории? В отдельном query-компоненте?
В нашем курсе проект организован package-by-feature, и у нас заранее есть идея: query-ориентированный код стоит держать отдельно, чтобы не превращать Repository в «всё обо всём». Поэтому два типичных варианта — это либо отдельный класс в orders.query с EntityManager, либо Spring Data метод с @Query (когда это уместно и читаемо). Важно, что в обоих случаях вы явно показываете, какой fetch-plan нужен.
Для такого detail-read удобно держать один узнаваемый шаблон: select distinct o, join fetch для обязательного customer, left join fetch для items, которые могут быть пустыми.
Вариант 1: query-класс (обычно самый прозрачный для deep-dive, потому что вы прямо видите JPQL):
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Repository;
@Repository
public class PurchaseOrderQueryRepository {
private final EntityManager em;
public PurchaseOrderQueryRepository(EntityManager em) {
this.em = em;
}
public PurchaseOrder getDetailed(long orderId) {
// Комбинация fetch-планов:
// - customer обычно обязателен -> join fetch
// - items может быть пустой коллекцией -> left join fetch
return em.createQuery(
"""
select distinct o from PurchaseOrder o
join fetch o.customer
left join fetch o.items
where o.id = :id
""",
PurchaseOrder.class
)
.setParameter("id", orderId)
.getSingleResult();
}
}
Обратите внимание на комбинацию: для customer мы используем join fetch (обычно обязательная связь), а для items — left join fetch (коллекция может быть пустой). Это уже «кандидат на нормальную детальную загрузку заказа».
Вариант 2: Spring Data репозиторий с @Query (тоже нормально, если запрос короткий и вы не прячете пол-логики в огромной строке):
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.Optional;
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
// Важно: fetch-план описан прямо в запросе, а не "магически" через обращение к lazy-полям
@Query("""
select distinct o from PurchaseOrder o
join fetch o.customer
left join fetch o.items
where o.id = :id
""")
Optional<PurchaseOrder> findDetailedById(long id);
}
Идея одна: fetch-plan описан там же, где описан запрос. Вы не рассчитываете на случайное поведение lazy loading и не заставляете сервис «подбирать» данные в несколько этапов. При этом вы не меняете аннотации LAZY в сущностях и не разрушаете модель под один read-case.
Небольшой момент про транзакцию. В идеале детальное чтение выполняется внутри @Transactional(readOnly = true), чтобы persistence context был активен и Hibernate мог спокойно материализовать граф. Даже если JOIN FETCH часто позволяет потом читать уже загруженные связи вне транзакции, это не повод строить архитектуру на «давайте вытащим entity наружу и будет жить». В нашем курсе мы учимся делать поведение предсказуемым, а не “как-нибудь переживёт”.
7. Проверяем JOIN FETCH по SQL trace
Очень хочется поверить запросу «на слово», но Hibernate — система, где вера быстро превращается в спор с логами. Поэтому мы действуем по привычному workflow из прошлых дней: поменяли fetch — посмотрели SQL — сравнили.
Если вы делаете чтение заказа без fetch join, вы часто увидите что-то похожее на:
select ... from purchase_order o where o.id = ?
-- Позже, при обращении к lazy-коллекции:
select ... from order_item i where i.order_id = ?
С JOIN FETCH для items SQL превращается в один запрос с join:
select ...
from purchase_order o
left join order_item i on i.order_id = o.id
where o.id = ?
-- Коллекция items будет собрана Hibernate из этого же результата
Не нужно пытаться запомнить точный SQL до последней запятой. Важно увидеть форму: было два отдельных SELECT, стало один SELECT с JOIN.
Хорошее упражнение для себя (прямо как чек на внимательность): после внедрения JOIN FETCH попробуйте в коде специально потрогать связь, которая раньше была ленивой, и убедиться, что нового SQL не появилось.
// Получаем заказ уже с подгруженными связями
var order = orderQueryRepository.getDetailed(orderId);
// Эти обращения не должны генерировать новый SQL, если fetch-план корректный
System.out.println(order.getCustomer().getEmail()); // уже загружено
System.out.println(order.getItems().size()); // уже загружено
Если в SQL trace при этих обращениях нет дополнительных запросов — вы добились цели.
И ещё одна маленькая психологическая подсказка: иногда после JOIN FETCH кажется, что всё стало «быстрее», потому что запросов меньше. Но правильная инженерная привычка — смотреть не только на количество запросов, но и на форму результата. На to-one обычно всё хорошо, а на to-many цена может быть в «раздувании» результата по строкам. Мы не игнорируем это — просто раскладываем тему по лекциям, и следующая как раз про эту цену.
8. Типичные ошибки при JOIN FETCH
Ошибка №1: написать join, ожидая поведение join fetch.
Это очень честная новичковая ошибка: «ну я же сделал join, значит связь должна быть в объекте». Но JPA так не обещает. Обычный join — это в первую очередь инструмент запроса (условия, сортировка), а join fetch — инструмент загрузки графа. Если вам важно гарантировать инициализацию связи — не стесняйтесь слова fetch, это не косметика, а смысл.
Ошибка №2: использовать join fetch как “новый EAGER по умолчанию”.
После первой победы над N+1 появляется эйфория: «Ого, один запрос вместо десяти! Надо везде так сделать». И именно в этот момент начинается обратная сторона — тяжеленные SQL с огромным количеством строк, непредсказуемые result set’ы и проблемы с пагинацией. JOIN FETCH — инструмент под конкретный read-case, особенно хорош для detail-read, но опасен как универсальный шаблон. Следующая лекция будет как раз про то, почему.
Ошибка №3: перепутать join fetch и left join fetch и “случайно потерять родительскую сущность”.
Если связь опциональна, join fetch (inner) исключит строки, у которых связи нет. В коде это выглядит как “заказ же есть, почему не нашли?”, а в SQL всё честно: inner join ничего не вернул. На optional-связях лучше начинать с left join fetch и только потом ужесточать, если вы уверены, что данные всегда есть.
Ошибка №4: думать, что JOIN FETCH чинит границы транзакции.
JOIN FETCH помогает загрузить данные в рамках конкретного чтения, но он не «делает архитектуру правильной автоматически». Если вы вообще читаете данные вне транзакции, или у вас сервисный слой разваливается на мелкие вызовы репозитория без понятной unit of work — JOIN FETCH может временно уменьшить боль, но не заменит дисциплину транзакционных границ. Мы лечим конкретный fetch-symptom, а не переписываем физику времени.
Ошибка №5: не проверить результат по SQL trace и решить, что “всё точно стало одним запросом”.
Иногда запрос переписали, JOIN FETCH добавили, а потом оказывается, что вторичный SELECT всё равно есть — потому что вы не ту связь подгрузили, или вы трогаете другую коллекцию, или логирование toString() вызывает ещё что-то ленивое. В мире Hibernate проверка только одна: включили sql-trace, сделали сценарий, посмотрели на SQL. И только потом радуемся, иначе радость будет короткой, как бесплатный пробный период.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ