💾 Раздел 16 · Вопрос #3

Когда использовать Lazy, а когда Eager loading

В современной корпоративной разработке на Java/Spring действует строгое инженерное правило:


🟢 Junior Level

Главный архитектурный вердикт

В современной корпоративной разработке на Java/Spring действует строгое инженерное правило:

«Всегда и безусловно объявляйте FetchType.LAZY для всех связей в Entity. Статический FetchType.EAGER в аннотациях связей использовать запрещено».

Если связанные данные действительно нужны в конкретном сценарии (например, при формировании детального чека заказа), они должны подтягиваться динамически в месте вызова с помощью JOIN FETCH, @EntityGraph или DTO-проекций, а не статически на уровне сущности.

Маппинг в Entity: ВСЕГДА LAZY
@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY) // Переопределен дефолт
    private User user;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items;
}

Запрос в репозитории: ДИНАМИЧЕСКИЙ EAGER при необходимости
// Сценарий 1: Простой список заказов (User и Items НЕ нужны -> экономим ресурсы)
List<Order> findByStatus(String status);

// Сценарий 2: Детализация заказа (User и Items нужны -> подтягиваем через JOIN FETCH)
@Query("SELECT o FROM Order o JOIN FETCH o.user JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithDetails(@Param("id") Long id);

Сравнительная матрица применения

Критерий FetchType.LAZY (по умолчанию) FetchType.EAGER (исключительные случаи)
Коллекции (@OneToMany, @ManyToMany) 100% случаев Никогда (риск OutOfMemoryError)
Одиночные связи (@ManyToOne, @OneToOne) 99% случаев (стандарт) Крайне редкие мелкие связки 1-к-1 с общим ключом
Встраиваемые компоненты (@Embedded) Не поддерживается (всегда в той же строке) Всегда загружаются вместе (часть той же таблицы)
Справочники (Enums, статусы) Лучше маппить через @Enumerated Если справочник — отдельная таблица, все равно LAZY

🟡 Middle Level

Дерево решений (Decision Matrix) архитектора

Когда разработчик решает, как спроектировать загрузку данных для бизнес-сценария, используется следующий алгоритм:

Нужны ли связанные сущности для выполнения текущей операции?
│
├── НЕТ ──> Используется базовый метод findById() / findAll() с LAZY.
│           (Связанные данные не запрашиваются, сетевой трафик минимален).
│
└── ДА  ──> Какова цель сценария?
            │
            ├── Операция ТОЛЬКО НА ЧТЕНИЕ (Read-Only: вывод в API, UI, отчет):
            │   └── Использовать DTO Projection / Spring Data Interface Projection.
            │       (Выбираются только нужные колонки, Entity в память не поднимаются).
            │
            └── Операция НА МОДИФИКАЦИЮ (Изменение бизнес-состояния Aggregate):
                │
                ├── Загружается ОДИН родительский объект по ID:
                │   └── Использовать JOIN FETCH или @EntityGraph.
                │
                └── Загружается СПИСОК родителей (особенно с пагинацией Pageable):
                    └── Использовать пакетную загрузку @BatchSize / default_batch_fetch_size.

Почему EAGER для «маленьких справочников» — это самообман

Частый аргумент разработчиков: «Категория товара — это справочник из 5 записей. Почему бы не поставить над @ManyToOne аннотацию EAGER? Ведь она весит копейки!».

В чем скрытая опасность:

  1. Предположим, сущность Product имеет category с FetchType.EAGER.
  2. Когда выполняется выборка 100 товаров через JPQL:
    List<Product> products = productRepository.findAll();
    
  3. Парсер JPQL не добавляет JOIN автоматически. Hibernate выполняет:
    • 1 запрос: SELECT * FROM products (вернул 100 товаров).
    • А затем 100 дополнительных запросов: SELECT * FROM categories WHERE id = ?.
  4. Даже если все 5 категорий закэшированы в L1-кэше сессии, Hibernate обязан проверить наличие внешнего ключа для каждого из 100 товаров.
  5. Итог: попытка сэкономить привела к классической проблеме N+1 на ровном месте.

🔴 Senior Level

Принцип Aggregate Boundary в Domain-Driven Design (DDD)

На сеньор-уровне выбор между Lazy и Eager диктуется не техническими особенностями фреймворка, а границами транзакционной целостности (Aggregate Boundary):

+───────────────────────────────────────────────────────────────+
|                 ORDER AGGREGATE (Root: Order)                 |
|                                                               |
|   Order (Корень агрегата)                                     |
|     │                                                         |
|     ├── List<OrderItem> (Внутренние сущности агрегата)        |
|     │   (Жизненный цикл строго привязан к Order;              |
|     │    загружаются вместе через JOIN FETCH при мутациях)    |
|     │                                                         |
|     └── DeliveryAddress (Value Object, @Embedded)             |
|         (Физически хранится в той же таблице orders)          |
+───────────────────────────────────────────────────────────────+
                               │
                Ссылка строго по ID (или LAZY)
                               ▼
+───────────────────────────────────────────────────────────────+
|                 CUSTOMER AGGREGATE (Root: Customer)           |
|                                                               |
|   Customer (Другой независимый агрегат)                       |
|   (ЗАПРЕЩЕНО загружать EAGER внутри Order!                   |
|    Связь моделируется как LAZY либо просто Long customerId)   |
+───────────────────────────────────────────────────────────────+
  1. Внутри границы агрегата: Сущности, обеспечивающие бизнес-инвариант (например, Order и OrderItem), при операциях модификации должны быть согласованы. Но даже внутри агрегата их связь объявляется LAZY, а в репозитории корня агрегата создается метод findByIdWithItems().
  2. Между разными агрегатами: Прямые ссылки с EAGER категорически запрещены. Агрегаты должны быть слабо связаны. В идеале Order должен хранить только внешний идентификатор Long customerId, либо строго LAZY-ссылку @ManyToOne(fetch = FetchType.LAZY) Customer customer.

Сравнительный анализ альтернатив динамической загрузки

Когда данные действительно нужны, вместо EAGER выбирается один из трех инструментов:

Инструмент Число SQL-запросов Декартово умножение Поддержка пагинации Сложность поддержки
JOIN FETCH Ровно 1 Да (при нескольких коллекциях) Опасно (HHH000104, OOM) Минимальная
@EntityGraph Ровно 1 (LEFT JOIN) Да Опасно для коллекций Низкая (декларативно)
@BatchSize / default_batch_fetch_size $1 + \lceil N / \text{batchSize} \rceil$ НЕТ (раздельные SQL) Идеально (LIMIT/OFFSET в БД) Нулевая (одна настройка)
@Fetch(SUBSELECT) Ровно 2 НЕТ Небезопасно при сложных WHERE Средняя

Золотой стандарт Senior Engineer:

  • Для выборки одного объекта по ID со связями — JOIN FETCH или @EntityGraph.
  • Для списков и пагинации с дочерними коллекциями — spring.jpa.properties.hibernate.default_batch_fetch_size = 50.
  • Для чтения на фронтенд / API — проекции в DTO / Java Records.

4 Tricky Questions

1. Можно ли сделать поля внутри @Embeddable объекта или сам @Embedded объект ленивым (LAZY)?

Ответ: По умолчанию в стандартном JPA — нет.

  1. @Embeddable классы не имеют собственного жизненного цикла и первичного ключа (@Id). Их поля хранятся как колонки в той же самой таблице родительской сущности.
  2. Когда Hibernate выполняет SELECT для родительской строки, база данных возвращает все колонки строки одновременно. Поскольку байты колонок встроенного объекта уже вычитаны из JDBC ResultSet, делать их ленивыми не имеет практического смысла.
  3. Исключение: Если внутри @Embeddable объекта объявлена связь с другой таблицей (например, @ManyToOne(fetch = FetchType.LAZY)), эта внешняя связь будет ленивой. Также при включении плагина Bytecode Enhancement (hibernate-enhance-maven-plugin) можно сделать ленивой группу колонок через @Basic(fetch = FetchType.LAZY).

2. В чем разница между FetchType в JPA-аннотациях и аннотацией @Fetch(FetchMode.JOIN) в Hibernate?

Ответ: Это частая путаница между стандартом JPA и специфичными расширениями Hibernate:

  • FetchType (JPA): Определяет когда данные должны быть загружены (LAZY — при обращении, EAGER — немедленно).
  • FetchMode (Hibernate): Определяет как (какой SQL-конструкцией) данные должны быть загружены (JOIN — через внешний джойн, SELECT — отдельным дополнительным селектом, SUBSELECT — через подзапрос).
  • Критический нюанс: Если над связью указать @Fetch(FetchMode.JOIN), Hibernate принудительно переопределит FetchType в EAGER, даже если вы явно написали fetch = FetchType.LAZY! Это скрытый способ случайно сломать ленивую загрузку.

3. Почему в микросервисной архитектуре наличие EAGER-связей наносит больший ущерб, чем в монолите?

Ответ:

  1. В микросервисах базы данных обычно оптимизированы под узкие точечные сценарии и горизонтальное масштабирование. Лишние JOIN и N+1 запросы забивают пул соединений и процессор СУБД.
  2. При передаче данных через сеть (REST / gRPC / Kafka) сущности сериализуются в JSON/Protobuf. Если в объекте присутствуют EAGER-связи, сериализатор Jackson рекурсивно вычитает все связанные графы, раздувая полезную нагрузку HTTP-ответа с 2 КБ до сотен килобайт.
  3. Это приводит к росту задержки межсервисного взаимодействия (p99 latency) и лавинообразному потреблению памяти микросервисами при сериализации/десериализации.

4. В каком единственном сценарии использование FetchType.EAGER технически оправдано и не вызовет проблем с производительностью?

Ответ: Единственный архитектурно чистый сценарий:

  • Двусторонняя связь @OneToOne, у которой родитель и ребенок делят один и тот же первичный ключ через @MapsId:
    @Entity
    public class OrderReceipt {
        @Id
        private Long id;
    
        @OneToOne(fetch = FetchType.EAGER)
        @MapsId
        @JoinColumn(name = "id")
        private Order order;
    }
    
  • В этом случае отношение строго обязательное ($1:1$), запись гарантированно существует, у них общий идентификатор, и декартово произведение физически невозможно. Загрузка через единый INNER JOIN здесь естественна и безопасна. Во всех остальных случаях следует выбирать LAZY.

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

30-секундный ответ (Elevator Pitch)

«В современной архитектуре действует безусловный стандарт: все связи в JPA Entity объявляются как FetchType.LAZY. Статический EAGER опасен тем, что его невозможно отключить в JPQL-запросах, он провоцирует проблему N+1 и декартово умножение при объединении связей. Вместо статического EAGER в сущностях применяется динамическая загрузка по требованию: для детальной выборки одного объекта используется JOIN FETCH или @EntityGraph, для списков и пагинации — пакетная загрузка @BatchSize (hibernate.default_batch_fetch_size), а для сценариев только на чтение (API, отчеты) — легковесные DTO-проекции».

Ключевые метрики для Production-мониторинга

  • Среднее время выполнения SQL-запросов (SQL Query Execution Time).
  • hibernate.stat.entity_load_count: число загруженных объектов на один веб-запрос.
  • Использование оперативной памяти (JVM Heap Allocation Rate) в моменты выгрузки списков.

Красные флаги (Чего говорить НЕЛЬЗЯ)

  • ❌ «Для справочников и связей @ManyToOne лучше ставить EAGER, чтобы не писать лишний код» — EAGER приведет к выполнению N+1 селектов при любом запросе через findAll().
  • ❌ «EAGER ускоряет приложение, потому что делает меньше обращений к базе» — Наоборот, он замедляет приложение, вытягивая ненужные графы данных и провоцируя N+1.
  • ❌ «Выбор между Lazy и Eager зависит от настроения разработчика» — Это фундаментальное архитектурное решение, определяющее масштабируемость системы; дефолтом всегда должен быть LAZY.

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