Hibernate позволяет не загружать связанные объекты из базы сразу — а подтянуть их позже, когда они реально понадобятся. Это удобно, но легко сделать неправильно и получить либо лишние запросы, либо загадочную ошибку.
Прокси — подставной объект с тем же набором методов: идентификатор в нём есть сразу, остальные поля он подтягивает при первом обращении. Пока сессия открыта, это просто лишний запрос; после её закрытия тот же вызов превращается в 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:
| Аннотация | Умолчание |
|---|---|
@ManyToOne | EAGER |
@OneToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
@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— это решение убрать сущности из контроллера: связи грузит сервисный слой, наружу уходит объект передачи данных.
Что почитать дальше
- N+1 проблема в Hibernate — как ленивая загрузка в цикле порождает лавину запросов и как её обнаружить.
- Persistence context и жизненный цикл сущности — как сессия Hibernate управляет состоянием объектов.
- Типичные ошибки с Hibernate — частые грабли при работе с ORM.
- Spring Data JPA — репозитории,
@EntityGraphи другие абстракции поверх Hibernate.