Получить доступ

ORM, persistence context и N+1

Что делает ORM, lazy/eager и proxy, механизм N+1, способы лечения и типичные грабли.

Что делает ORM

ORM (Hibernate/JPA) сопоставляет объекты со строками таблиц и генерирует SQL для типовых операций. В рамках транзакции работает persistence context (кэш первого уровня) с identity map: одна и та же строка — один объект, а изменения отслеживаются и выгружаются на flush (dirty checking).

Цена удобства — потеря прямой видимости SQL: легко случайно породить дорогой сценарий, не заметив этого.

Lazy, eager и proxy

Связи бывают lazy и eager. По умолчанию в JPA коллекции (@OneToMany, @ManyToMany) ленивые, а @ManyToOne и @OneToOne — жадные: такая связь подгружается всегда, даже когда не нужна, и в списке сущностей тоже даёт N+1. Поэтому для связей «к одному» часто явно ставят fetch = FetchType.LAZY. Lazy-связь Hibernate отдаёт как proxy, а реальный запрос выполняется при первом обращении к данным.

Если обратиться к lazy-связи уже вне транзакции/сессии, получите LazyInitializationException. Поэтому решение «что грузить» принимают на этапе запроса, а не глобальной аннотацией.

Откуда берётся N+1

N+1 возникает, когда один запрос получает список из N сущностей, а затем для каждой выполняется ещё один запрос за её связью — итого 1 + N запросов вместо одного-двух.

Обнаруживают по SQL-логам (show_sql), p6spy или счётчику запросов в тестах. Симптом — рост числа запросов пропорционально размеру списка.

// 1 запрос за заказами + по запросу на orders.getItems() каждой строки
for (Order o : orderRepository.findAll()) {
    total += o.getItems().size(); // lazy → N запросов
}
Проблема N+1: один запрос-список плюс по одному запросу на каждую сущность против одного запроса с JOIN.
N+1: список + N отдельных запросов на сущности; JOIN решает ту же задачу одним запросом.

Как лечить

JOIN FETCH или @EntityGraph подгружают связь одним запросом. Batch fetching (hibernate.default_batch_fetch_size) грузит связи пачками (1 + N/размер_пачки). DTO-проекция тянет только нужные поля одним запросом без полной загрузки сущностей.

@EntityGraph vs JOIN FETCH — это одно и то же по эффекту (превращают lazy-связь в один JOIN), но разные по форме: JOIN FETCH пишется прямо в JPQL/HQL, а @EntityGraph задаётся декларативно (аннотацией над репозиторием/именованным графом) и накладывается поверх обычного метода, не переписывая запрос. EntityGraph удобнее, когда один и тот же запрос нужен с разной «жадностью» загрузки.

Важная ловушка: JOIN FETCH коллекции вместе с пагинацией (setMaxResults/Pageable) Hibernate не может сделать на стороне БД — JOIN размножает строки, и чтобы отдать корректную «страницу сущностей», он тянет ВСЁ в память и режет там (в логах появляется предупреждение 'firstResult/maxResults specified with collection fetch; applying in memory'). На больших таблицах это OOM. Решения: пагинировать по id отдельным запросом, а затем дозагружать связи; или грузить коллекцию через batch fetching/EntityGraph без JOIN.

Выбор зависит от сценария: для отчётов чаще выгодны проекции, для навигации по графу — JOIN FETCH/EntityGraph.

@Query("select o from Order o join fetch o.items where o.userId = :uid")
List<Order> findWithItems(@Param("uid") Long uid);

Жизненный цикл сущности

Половина «магии» Hibernate объясняется четырьмя состояниями сущности. Transient — объект создан через new, персистентный контекст о нём не знает. Managed (persistent) — привязан к контексту: за ним следят, изменения полей попадут в БД. Detached — контекст закрыт, объект жив, но за ним больше никто не следит. Removed — помечен на удаление.

Отсюда вытекает dirty checking: у managed-сущности достаточно вызвать сеттер — никакого save() не нужно. На flush Hibernate сравнивает текущее состояние со снимком, сделанным при загрузке, и сам генерирует UPDATE. Обратная сторона: случайное изменение поля внутри транзакции молча уедет в БД.

Flush происходит не когда попало: перед выполнением запроса, затрагивающего изменённые сущности, и на commit транзакции. Важно не путать flush (SQL отправлен в БД, но транзакция не завершена) с commit (изменения зафиксированы и видны другим).

Кэш первого уровня (сам персистентный контекст) работает как identity map в границах транзакции: повторный find по тому же id не пойдёт в БД и вернёт ТОТ ЖЕ объект. Поэтому внутри одной транзакции сущности корректно сравнивать по ссылке, а вот equals/hashCode по изменяемым полям или по ещё не присвоенному id — источник трудноуловимых багов с коллекциями.

Detached-объекты возвращают в контекст через merge — он не делает объект managed, а КОПИРУЕТ его состояние в управляемый экземпляр и возвращает уже его. Игнорировать возвращённое значение merge — классическая ошибка: изменения уйдут в никуда.

@Transactional
void renameUser(Long id, String name) {
    User user = repository.findById(id).orElseThrow(); // managed
    user.setName(name);   // dirty checking: UPDATE сгенерируется на flush
}                          // save() вызывать не нужно

Грабли

Не лечите N+1 глобальным EAGER — это переносит проблему в другие сценарии и утяжеляет запросы. Не делайте JOIN FETCH двух коллекций сразу — получите декартово произведение строк. Помните про LazyInitializationException вне сессии.

Ещё три частые: отдавать сущности прямо из контроллера (сериализация тронет lazy-связи и породит запросы или исключение), писать equals/hashCode по сгенерированному id (до сохранения он null, после — объект «меняет» хеш внутри HashSet), и удалять по одной сущности в цикле там, где нужен один bulk-запрос.

Как отвечать на собеседовании

N+1 объясняйте механизмом, а не определением: один запрос вернул N сущностей, обращение к lazy-связи каждой из них выполняет ещё по запросу — итого 1 + N. Сразу скажите, как обнаруживаете проблему (show_sql, p6spy, счётчик запросов в тесте), это отличает практика от теоретика.

Перечислите средства с их профилем: JOIN FETCH и @EntityGraph — одним запросом (одно и то же по эффекту, но EntityGraph декларативен и не переписывает запрос); batch fetching — пачками, 1 + N/размер; DTO-проекция — только нужные поля, лучший вариант для отчётов. И подчеркните: EAGER не решение.

Сильный ход — назвать ловушку JOIN FETCH коллекции вместе с пагинацией: Hibernate не может сделать это на стороне БД, тянет всё в память и режет там (предупреждение firstResult/maxResults specified with collection fetch). Решение — пагинировать по id, а связи дозагружать отдельно.

Если спросят про внутренности — разложите четыре состояния (transient/managed/detached/removed), объясните dirty checking через снимок при загрузке и разведите flush и commit. Завершите общим тезисом: ORM экономит рутину, но не освобождает от обязанности знать, какой именно SQL уходит в базу.

Вопросы по теме

Читайте также

Закрытая бета

Знать тему — половина дела

На живом собеседовании sotto слышит вопрос интервьюера и показывает короткий ответ и план. Оставьте контакт — пришлём доступ и промокод.

  • Промокод на 2 часа — сверх пробных 30 минут
  • Программа для Windows — скачать сразу, войти — через Яндекс ID или VK ID
  • Прямая связь с разработчиком: что улучшить — сделаем