Вопрос 2 · Раздел 16

В чём разница между Lazy и Eager загрузкой

FetchType определяет когда Hibernate загружает связанные сущности: сразу вместе с родительской (EAGER) или только при обращении (LAZY). Это фундаментальное решение, влияющее на...

Версии по языкам: English Russian Ukrainian

Обзор

FetchType определяет когда Hibernate загружает связанные сущности: сразу вместе с родительской (EAGER) или только при обращении (LAZY). Это фундаментальное решение, влияющее на производительность и архитекку приложения.

Аналогия: Представьте, что вы загружаете книгу. EAGER — это сразу загрузить ещё и все рецензии, комментарии, историю изменений. LAZY — загрузить только книгу, а рецензии подтянуть когда пользователь нажмёт на вкладку.


🟢 Junior Level

Определения

LAZY (ленивая загрузка) — связанные данные загружаются только при первом обращении к ним.

EAGER (жадная загрузка) — связанные данные загружаются сразу вместе с основным объектом.

// EAGER — загружает items сразу
@OneToMany(fetch = FetchType.EAGER)
private List<OrderItem> items;

Order order = entityManager.find(Order.class, 1L);
// items УЖЕ загружен!

// LAZY — загружает items только при обращении
@OneToMany(fetch = FetchType.LAZY)
private List<OrderItem> items;

Order order = entityManager.find(Order.class, 1L);
// items ЕЩЁ НЕ загружен — это proxy

order.getItems().size();  // ТОЛЬКО ЗДЕСЬ происходит запрос к БД

Значения по умолчанию

Аннотация FetchType по умолчанию
@ManyToOne EAGER
@OneToMany LAZY
@OneToOne EAGER
@ManyToMany LAZY

Значения по умолчанию указаны для JPA 2.x. В Hibernate 5/6 можно переопределить через @LazyToOne.

Когда что использовать

  • LAZY — почти всегда, особенно для коллекций
  • EAGER — только для маленьких обязательных связей, которые нужны в 100% случаев

🟡 Middle Level

LazyInitializationException

Самая частая проблема при работе с LAZY:

@Transactional
public Order getOrder(Long id) {
    return entityManager.find(Order.class, id);
}

// Вне транзакции:
Order order = service.getOrder(1L);
order.getItems().size();  // ❌ LazyInitializationException!
// Сессия Hibernate уже закрыта, proxy не может загрузить данные

Решения

// 1. JOIN FETCH — загрузить в транзакции
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Order findByIdWithItems(@Param("id") Long id);

// 2. Hibernate.initialize() — принудительная инициализация
@Transactional
public Order getOrderWithItems(Long id) {
    Order order = entityManager.find(Order.class, id);
    Hibernate.initialize(order.getItems());
    return order;
}

// 3. @EntityGraph — динамическая загрузка
@EntityGraph(attributePaths = {"items"})
Order findById(Long id);

// 4. Инициализация в сервисном слое
@Transactional(readOnly = true)
public OrderDto getOrderDto(Long id) {
    Order order = repository.findById(id).orElseThrow();
    order.getItems().size();  // force initialization
    return OrderDto.from(order);
}

Open Session In View — антипаттерн

# ❌ Не рекомендуется в production
spring.jpa.open-in-view: true

Этот режим держит сессию открытой до завершения рендеринга view, что приводит к:

  • Долгим транзакциям
  • Утечкам соединений
  • Скрытым проблемам производительности

Типичные ошибки

// ❌ EAGER для больших коллекций
@OneToMany(fetch = FetchType.EAGER)
private List<Order> orders;
// Загрузит ВСЕ заказы пользователя → OutOfMemoryError

// ❌ EAGER "на всякий случай"
@ManyToOne(fetch = FetchType.EAGER)
private User user;
// Даже когда user не нужен — лишний JOIN

🔴 Senior Level

Внутренняя реализация

Как работает LAZY loading:

1. Hibernate создаёт proxy для связанной сущности/коллекции
2. Для @ManyToOne — ByteBuddy proxy, расширяющий класс
   ByteBuddy — библиотека для создания подклассов на лету. Hibernate использует её чтобы создать подкласс вашей сущности, который перехватывает обращения к полям и загружает данные из БД при первом обращении.
3. Для @OneToMany — PersistentCollection (PersistentBag/PersistentSet)
4. При обращении к proxy:
   - Проверяет Session.isOpen()
   - Если открыт → выполняет SELECT
   - Если закрыт → LazyInitializationException
5. После загрузки proxy заменяется на реальный объект

Почему @ManyToOne по умолчанию EAGER:

@ManyToOne ссылается на ОДНУ сущность (например, Order -> User). Обычно вместе с заказом нужен и пользователь. @OneToMany ссылается на КОЛЛЕКЦИЮ (Order -> OrderItems), которая может содержать сотни записей — загружать их все всегда было бы расточительно.

Историческое решение в JPA спецификации, основанное на предположении, что родительская сущность обычно нужна вместе с дочерней. На практике это редко оправдано.

Архитектурные Trade-offs

LAZY EAGER
Гибче и производительнее Проще в разработке
Требует планирования загрузки Всегда доступно
Может вызвать LazyInitializationException Может вызвать N+1 проблему
Подходит для высоконагруженных систем Подходит для прототипов

Стратегия “LAZY по умолчанию”

ПРАВИЛО: Все связи объявлять как LAZY, загружать явно когда нужно.

Почему это правильно:
1. EAGER нельзя отменить в конкретном запросе
2. LAZY можно "превратить" в EAGER через JOIN FETCH
3. LAZY даёт максимальную гибкость

@Entity
public class Order {
    // Переопределяем дефолтный EAGER на LAZY
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    // LAZY — дефолт для коллекций
    @OneToMany(mappedBy = "order")
    private List<OrderItem> items;
}

DTO Projection вместо EAGER

// Вместо загрузки полных сущностей с EAGER связями
@Query("""
    SELECT new com.example.OrderSummaryDto(
        o.id, o.date, o.status, u.name, COUNT(i)
    )
    FROM Order o
    JOIN o.user u
    LEFT JOIN o.items i
    WHERE o.id = :id
    GROUP BY o.id, u.name
    """)
OrderSummaryDto findOrderSummary(@Param("id") Long id);

Best Practices

✅ LAZY по умолчанию для подавляющего большинства связей. EAGER — только когда вы точно измерили и подтвердили, что связь нужна в 100% запросов.
✅ Переопределение @ManyToOne и @OneToOne на LAZY
✅ JOIN FETCH когда связанные данные нужны
✅ @EntityGraph для динамической загрузки
✅ DTO projection для API ответов
✅ Инициализация внутри @Transactional методов

❌ EAGER для коллекций (@OneToMany, @ManyToMany)
❌ Open Session In View в production
❌ Доступ к LAZY полям вне транзакции
❌ EAGER "на всякий случай"

🎯 Шпаргалка для интервью

Обязательно знать:

  • LAZY — данные загружаются при первом обращении, EAGER — сразу
  • По умолчанию: @ManyToOne = EAGER, @OneToMany = LAZY, @OneToOne = EAGER, @ManyToMany = LAZY
  • LAZY по умолчанию — лучшая стратегия для всех связей
  • EAGER нельзя отменить в запросе, LAZY можно «превратить» в EAGER через JOIN FETCH
  • LAZY работает через proxy (ByteBuddy) и PersistentCollection
  • LazyInitializationException возникает при доступе к LAZY вне транзакции
  • DTO projection — лучшая альтернатива EAGER для API

Частые уточняющие вопросы:

  • Почему LAZY лучше EAGER? LAZY гибок — можно загрузить когда нужно; EAGER навязчив — нельзя отменить
  • Почему @ManyToOne по умолчанию EAGER? Историческое решение JPA — предполагалось что родительская сущность обычно нужна
  • Как решить LazyInitializationException? JOIN FETCH, EntityGraph, инициализация внутри @Transactional, DTO projection
  • Что такое Open Session In View? Сессия открыта до рендеринга view — антипаттерн для production

Красные флаги (НЕ говорить):

  • «EAGER проще — использую его» — создаёт N+1 и проблемы с памятью
  • «Решаю LazyInitializationException через EAGER» — лечит симптом, создаёт новую проблему
  • «LAZY только для больших коллекций» — LAZY для ВСЕХ связей
  • «Open Session In View — нормальная практика» — антипаттерн для production

Связанные темы:

  • [[3. Когда использовать Lazy, а когда Eager loading]]
  • [[4. Что такое LazyInitializationException и как её избежать]]
  • [[1. Что такое проблема N+1 и как её решить]]
  • [[5. Какие стратегии fetch существуют в Hibernate]]
  • [[29. Что такое projection в JPA]]