← назад к разделу

Hibernate позволяет не загружать связанные объекты из базы сразу — а подтянуть их позже, когда они реально понадобятся. Это удобно, но легко сделать неправильно и получить либо лишние запросы, либо загадочную ошибку.

вместо Customer подставлен прокси: идентификатор он знает, за остальным идёт в базу сессия Hibernate (@Transactional) Order id=1 total=990 Customer прокси, id=7 база orders customer SELECTOrder id=1total=9901. em.find(Order.class, 1L)заказ пришёл одним запросом, поле customer заполнено прокси без SQLCustomergetId() = 72. order.getCustomer().getId()идентификатор прокси знал с самого начала — запроса нет SELECTcustomerCustomeremail загружен3. order.getCustomer().getEmail()первое обращение к полю — здесь и уходит второй SELECT сессия закрытаCustomeremail = ?4. тот же вызов, но сессия уже закрытаLazyInitializationException: догружать данные уже нечем

Прокси — подставной объект с тем же набором методов: идентификатор в нём есть сразу, остальные поля он подтягивает при первом обращении. Пока сессия открыта, это просто лишний запрос; после её закрытия тот же вызов превращается в LazyInitializationException.

Два режима загрузки

При маппинге связей через @OneToMany, @ManyToOne, @OneToOne или @ManyToMany можно указать fetch:

@Entity
public class Order {

    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;

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

FetchType.EAGER — связанный объект загружается сразу вместе с родительским: либо JOIN в том же запросе, либо отдельный SELECT следом. Одним запросом это быть не обязано.

FetchType.LAZY — связь не загружается до первого обращения к ней. Hibernate подставляет вместо неё прокси — пустой объект-заглушку, который знает только идентификатор. Реальный SQL выполняется позже, при первом обращении к любому полю, кроме идентификатора: его прокси знает с самого начала, поэтому getId() в базу не сходит. Полезный приём: нужен только идентификатор связанной сущности — лишнего запроса не будет. Держится приём на одном условии: у сущности должен быть обычный геттер идентификатора, getId(). Именно по нему Hibernate узнаёт вызов и отвечает на него сам; поле без геттера так не обойти.

Короткая формула: LAZY — «грузи когда попросят», EAGER — «грузи сразу».

Как работает прокси

Для одиночных связей (@ManyToOne, @OneToOne) Hibernate создаёт подкласс-прокси вашей сущности. Внешне он ничем не отличается от обычного объекта:

Order order = entityManager.find(Order.class, 1L);
// SQL: SELECT o.id, o.status, o.customer_id FROM orders o WHERE o.id = 1
// customer ещё НЕ загружен

String email = order.getCustomer().getEmail();
// вот здесь уходит второй запрос:
// SELECT c.id, c.email, c.first_name FROM customers c WHERE c.id = ?

Для коллекций (@OneToMany, @ManyToMany) вместо List или Set Hibernate подставляет свою реализацию — PersistentBag, PersistentSet и другие. Они тоже пустые до первого обращения.

Механику прокси видно и без базы: объект знает идентификатор, за остальным идёт при первом обращении.

живой пример

public class LazyProxyDemo {

    static int queries = 0;

    static class CustomerProxy {
        private final long id;
        private String email;
        private boolean sessionOpen = true;

        CustomerProxy(long id) {
            this.id = id;
        }

        long getId() {
            return id;
        }

        String getEmail() {
            if (email == null) {
                if (!sessionOpen) {
                    throw new IllegalStateException("LazyInitializationException: no Session");
                }
                queries++;
                email = "user" + id + "@shop.example";
            }
            return email;
        }
    }

    public static void main(String[] args) {
        CustomerProxy customer = new CustomerProxy(7);
        System.out.println("после find(Order): запросов " + queries);
        System.out.println("getId() = " + customer.getId() + ", запросов " + queries);
        System.out.println("getEmail() = " + customer.getEmail() + ", запросов " + queries);

        CustomerProxy closed = new CustomerProxy(8);
        closed.sessionOpen = false;
        try {
            closed.getEmail();
        } catch (IllegalStateException e) {
            System.out.println("сессия закрыта: " + e.getMessage());
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Счётчик запросов и есть вывод: getId() его не трогает, getEmail() увеличивает на единицу, а на закрытой сессии тот же вызов падает.

Ленивыми бывают не только связи. Тяжёлое поле сущности — текст договора, содержимое файла — тоже можно не грузить: @Lob вместе с @Basic(fetch = FetchType.LAZY). Оговорка обязательная: для обычных полей ленивость работает только при включённом преобразовании байт-кода (плагин сборки Hibernate), иначе аннотация молча игнорируется и поле приезжает всегда. Проверяется по списку колонок в журнале запросов. Подробнее — в статье про маппинг сущностей.

LazyInitializationException: что это и почему возникает

Самая частая ошибка ленивой загрузки:

org.hibernate.LazyInitializationException:
  failed to lazily initialize a collection of role: Order.items,
  could not initialize proxy - no Session

Причина — вы обратились к ленивой связи после закрытия сессии Hibernate. Прокси знает, что нужно загрузить данные, но открытого соединения с базой уже нет.

Типичный сценарий в Spring:

// Транзакция открыта — загружаем Order
@Transactional
public Order getOrder(Long id) {
    return orderRepository.findById(id).orElseThrow();
}   // <-- транзакция закрыта здесь

// Позже, вне транзакции:
Order order = orderService.getOrder(1L);
order.getItems().size(); // ВЗРЫВ: LazyInitializationException

Сессия Hibernate живёт в рамках транзакции: метод с @Transactional завершился — сессия закрыта, прокси «мёртв».

Правда, в обычном веб-приложении на Spring Boot это сходу не воспроизвести: там по умолчанию включён Open Session In View, и обращение к ленивому полю в контроллере молча сработает, сделав лишний запрос. Ошибка вылезет в фоновой задаче, в тесте, в потребителе очереди — или в веб-слое, как только кто-нибудь выключит spring.jpa.open-in-view.

Как правильно лечить

1. Загружать нужные данные в транзакции через JOIN FETCH

Самый чистый способ — явно описать, что нужно загрузить, прямо в запросе:

@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);

Hibernate выполнит один SQL с JOIN и вернёт полностью инициализированный объект. Ленивая связь уже не нужна — данные есть.

Слово LEFT здесь не для красоты. Просто JOIN FETCH — это внутреннее соединение: заказ, у которого ещё нет ни одной позиции, из результата выпадет, и метод вернёт пустой Optional вместо существующего заказа. Внутреннее соединение тут уместно только тогда, когда пустые вам действительно не нужны, и это стоит написать осознанно.

2. Загружать через EntityGraph

Альтернатива JPQL-запросу — @EntityGraph из Spring Data JPA:

@EntityGraph(attributePaths = {"items"})
Optional<Order> findById(Long id);

Гибкий вариант, когда один метод репозитория нужен с разными наборами связей. Подробнее про репозитории — в статье Spring Data JPA.

3. Обращаться к связям внутри транзакции

Если бизнес-логика требует данных из связи — пусть это происходит в рамках той же транзакции:

@Transactional
public int countItems(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    return order.getItems().size(); // OK: сессия ещё открыта
}

Ещё два лекарства: Hibernate.initialize и проекция

Догрузить связь на месте. Бывает, что запрос уже выполнен, а связь понадобилась — и переписывать его под JOIN FETCH неудобно (запрос общий, вызывающих много). Тогда связь догружают явно, пока транзакция ещё открыта:

@Transactional(readOnly = true)
public OrderView load(long id) {
    Order order = orders.findById(id).orElseThrow();
    Hibernate.initialize(order.getLines());
    return OrderView.from(order);
}

Это честный дополнительный запрос — не бесплатно, но предсказуемо. Тот же эффект даёт обращение к коллекции (например, order.getLines().size()), но Hibernate.initialize говорит о намерении прямо, а не полагается на побочный эффект. Проверить, загружена ли связь, можно Hibernate.isInitialized(...).

Не брать сущность вовсе. Самый частый ответ в проде на LazyInitializationException — не догружать связь, а сразу запросить те данные, которые нужны экрану:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
        select new ru.shop.api.OrderRow(o.id, o.status, c.lastName, count(l))
        from Order o
          join o.customer c
          left join o.lines l
        where o.id = :id
        group by o.id, o.status, c.lastName
        """)
    Optional<OrderRow> loadRow(@Param("id") long id);
}

Проекция не имеет ни заместителей, ни контекста — её можно отдавать наружу и после закрытия транзакции. Заодно она решает вторую половину проблемы: из базы приезжает четыре поля, а не вся сущность со всеми связями.

Где EAGER всё-таки уместен

Запрет «никогда не ставьте EAGER» удобен как правило по умолчанию, но случаи есть, и их стоит назвать, чтобы отличать осознанный выбор от копирования.

Первый: маленький справочник, который нужен всегда. Валюта у платежа, статус с названием, тип документа — строк в такой таблице десятки, связь обязательна, и без неё объект всё равно бесполезен. Второй: связь @ManyToOne с optional = false, к которой обращаются в каждом сценарии, — заместитель тут только добавляет запрос. Третий: @OneToOne владельца, где ленивость всё равно не работает как ожидается (заместителя на необязательной стороне Hibernate построить не может и грузит связь сразу).

Общее правило простое: EAGER допустим на связи «к одному», которая маленькая, обязательная и нужна всегда. Для коллекций он не допустим почти никогда — одна такая связь превращает любой список в N+1 или в декартово произведение.

FETCH и LOAD у @EntityGraph

У графа есть два режима, и разница между ними практическая.

FETCH (по умолчанию в @EntityGraph) означает: перечисленные связи грузим жадно, все остальные считаем ленивыми, даже если в маппинге у них стоит EAGER. Это способ временно погасить чужой EAGER для одного запроса.

LOAD означает: перечисленные грузим жадно, остальные — как объявлено в маппинге. То есть EAGER-связи останутся жадными, и вместе с вашим графом приедет всё, что было прибито в аннотациях.

@EntityGraph(attributePaths = {"lines"}, type = EntityGraph.EntityGraphType.FETCH)
Optional<Order> findWithLinesById(long id);

Отсюда практический совет: при разборе «почему приехало больше, чем просили» первым делом смотрят тип графа.

Почему не стоит везде ставить EAGER

Первый порыв — «поставлю EAGER и забуду про ошибку». Это ловушка.

Проблема 1: лишние данные всегда. Даже когда вам нужен только идентификатор заказа, Hibernate загрузит все его позиции из базы — потому что EAGER означает «всегда».

Проблема 2: N+1 запросов или декартово произведение. Если у вас список из 100 заказов и у каждого EAGER-коллекция позиций — Hibernate либо выполнит 100 дополнительных SELECT (N+1), либо сделает JOIN и вернёт строки с повторами. Подробнее про N+1 — в статье N+1 проблема в Hibernate.

Проблема 3: неожиданные цепочки. EAGER на одной связи может потянуть EAGER-цепочку дальше по графу, и в итоге один find() загрузит половину базы.

Правило: оставляйте LAZY по умолчанию, а что именно нужно — указывайте в запросе явно.

Справка по умолчаниям JPA:

АннотацияУмолчание
@ManyToOneEAGER
@OneToOneEAGER
@OneToManyLAZY
@ManyToManyLAZY

@ManyToOne и @OneToOne по умолчанию EAGER — исторически так сложилось. Рекомендуется явно менять на LAZY:

@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;

Одно место, где этот совет молча не сработает, — обратная сторона @OneToOne(mappedBy = ...). Внешнего ключа там нет, и, чтобы решить, положить в поле прокси или null, Hibernate обязан сходить в базу прямо сейчас. Вы напишете LAZY, а запрос всё равно уйдёт: искать причину лишнего SQL в такой связи можно долго.

Open Session in View: не полагайтесь на него

В Spring Boot по умолчанию включён Open Session in View (OSIV) — паттерн, который держит сессию Hibernate открытой на всё время HTTP-запроса, включая рендеринг шаблона.

Это позволяет обращаться к ленивым связям даже за пределами @Transactional — ошибки нет, и кажется, что «всё работает». Но за это платите скрытыми SQL-запросами в слое представления: контроллер или шаблон, получая order.getItems(), незаметно идёт в базу.

Чтобы отключить OSIV:

spring:
  jpa:
    open-in-view: false

После отключения LazyInitializationException начнут вылезать там, где данные не были загружены явно — это хорошо, потому что делает поведение явным. Загружайте нужные связи в транзакции, а не полагайтесь на открытую сессию как «страховку».

Выключили — и что теперь. Отключение означает, что контекст закрывается вместе с транзакцией, то есть до того, как шаблон или сериализатор начнут читать объект. Значит, всё, что нужно наружу, обязан подготовить сервисный слой, пока транзакция открыта: загрузить нужные связи (через JOIN FETCH, граф или Hibernate.initialize) и собрать объект передачи данных.

Практически это выглядит так: метод сервиса помечен @Transactional, внутри он получает сущности и возвращает не их, а проекцию или объект передачи данных. Контроллер сущностей не видит вовсе — тогда ленивых связей за границей транзакции просто не бывает, и вопрос «а что если кто-то обратится к коллекции в шаблоне» снимается конструкцией.

Это же и есть переход, который многие откладывают: выключение open-in-view — не настройка, а решение убрать сущности из внешнего слоя. Пока сущности отдаются из контроллера, отключение будет ронять приложение в случайных местах; после перехода на объекты передачи данных оно перестаёт что-либо ломать.

Коротко

  • FetchType.LAZY — Hibernate подставляет прокси и грузит данные при первом обращении; EAGER — грузит сразу.
  • @ManyToOne и @OneToOne по умолчанию EAGER — явно меняйте на LAZY.
  • LazyInitializationException возникает при обращении к прокси после закрытия сессии (вне транзакции).
  • Правильное лечение — загрузить нужные связи внутри транзакции: через JOIN FETCH в JPQL или @EntityGraph.
  • Не ставьте EAGER везде: это скрытые лишние запросы и риск N+1.
  • OSIV скрывает проблему, но не решает её — отключайте и загружайте явно.
  • Кроме JOIN FETCH и графа связь догружают Hibernate.initialize внутри транзакции, а чаще всего правильный ответ — не сущность, а проекция.
  • EAGER уместен на маленькой обязательной связи «к одному», которая нужна всегда; для коллекций — почти никогда.
  • У @EntityGraph тип FETCH гасит чужой EAGER, тип LOAD его сохраняет; ленивость обычных полей требует преобразования байт-кода.
  • Отключение open-in-view — это решение убрать сущности из контроллера: связи грузит сервисный слой, наружу уходит объект передачи данных.

Что почитать дальше