сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Spring и Hibernate

Ленивая загрузка и N+1 в Hibernate

N+1: главная причина медленных ORM-приложений

Взял пять авторов, у каждого по три книги, и посчитал общее число книг обычным циклом. Включил счётчик запросов: к базе ушло ШЕСТЬ запросов. Переписал ту же выборку с явной подгрузкой связи - ОДИН запрос. Результат в обоих случаях одинаковый: 5 авторов, 15 книг.

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

// Формулировки: «что такое проблема N+1?», «как её обнаружить?», «чем лечить и в чём разница между способами?».

Откуда берутся лишние запросы

Механизм такой. Первый запрос достаёт список основных объектов. Их связи ленивые, то есть при загрузке не заполнены - вместо коллекции стоит заглушка. Как только цикл обращается к книгам первого автора, заглушка идёт в базу за данными. Потом за книгами второго. И так N раз, откуда и название: один запрос плюс N.

Коварство в том, что в коде этого не видно. Строка a.books.size() выглядит как обращение к полю в памяти, а на деле каждый её вызов - поход в базу по сети. Тесты на трёх записях проблему не показывают, она появляется вместе с реальным объёмом.

Ловят это счётчиком запросов. У Hibernate есть встроенная статистика - включается свойством generate_statistics, и мои числа получены именно ей. В приложении удобнее держать проверку в тестах: обработал запрос, посчитал обращения к базе, сравнил с ожидаемым числом. Тогда N+1 ловится до прода, а не после жалоб.

N+1
один запрос за списком плюс по запросу за связью каждого элемента
статистика Hibernate
встроенный счётчик запросов и кэш-попаданий

Чем лечить

Первый и основной способ - подгрузить связь тем же запросом: join fetch в языке запросов ORM. Мои шесть запросов превратились в один. Родственный вариант - EntityGraph: то же самое, но описанное аннотацией, чтобы не дублировать запрос ради разных наборов связей.

Второй способ - пакетная подгрузка: настройка batch size говорит «подтягивай связи не по одной, а пачками по N». Тогда вместо тысячи запросов будет тысяча делить на N. Помогает там, где join fetch неудобен.

// Третий способ - вообще не грузить сущности. Если нужны только несколько полей, запрос сразу в DTO (data transfer object, объект для передачи данных) избавляет и от связей, и от лишних колонок. Для страниц-списков это обычно самое честное решение.

// было: 6 запросов на 5 авторов
from Author

// стало: 1 запрос
select distinct a from Author a join fetch a.books
join fetch
подтянуть связь тем же запросом, а не отдельным
batch size
подгружать связи пачками, а не по одному объекту

Подводные камни самого фетча

Лечение имеет цену, и о ней спрашивают отдельно. Первое: join fetch по коллекции размножает строки. Пять авторов по три книги превращаются в пятнадцать строк результата, где данные автора повторяются трижды. Поэтому в моём запросе стоит distinct - иначе в списке окажутся дубликаты авторов.

Второе, и это уже боль: два join fetch по разным коллекциям в одном запросе дают декартово произведение. Десять книг и десять адресов превращаются в сто строк, из которых полезны двадцать. Hibernate такое либо запрещает, либо честно везёт весь этот объём.

// Третье: постраничная выдача вместе с join fetch по коллекции работает неправильно, потому что база нарезает страницы по строкам, а не по объектам. Hibernate в этом случае вытягивает всё в память и режет уже там, о чём предупреждает в логе. Решение - грузить страницу идентификаторов первым запросом, а связи вторым.

декартово произведение
два join fetch перемножают строки: 10 и 10 дают 100

Как отвечать: «Что такое N+1 и как его лечить?»

Это когда один запрос достаёт список объектов, а потом на каждый объект уходит отдельный запрос за его связью. У меня на пяти авторах с книгами получилось шесть запросов: один за авторами и пять за книгами. Опасность в том, что в коде это не видно - обращение к коллекции выглядит как работа с памятью, а на деле идёт в базу, и на тестовых трёх записях проблема не проявляется. Лечу в первую очередь подгрузкой связи тем же запросом через join fetch: те же шесть запросов стали одним. Если связей несколько, беру EntityGraph или пакетную подгрузку, потому что два join fetch по коллекциям дают декартово произведение. А для списков часто честнее вообще не грузить сущности и делать запрос сразу в DTO. И ставлю проверку числа запросов в тесты, чтобы ловить это до прода: у Hibernate для этого есть встроенная статистика.

Почему это сильный ответ: механизм объяснён с числами, названо, почему проблема не видна в коде и в тестах, перечислены три разных лечения с их границами и добавлена профилактика.

На чём валят

  • Считать обращение к ленивой коллекции работой с памятью. Каждое такое обращение - отдельный поход в базу.
  • Лечить N+1 переводом связей в EAGER. Тогда лишние запросы будут всегда, даже когда связь не нужна.
  • Два join fetch по коллекциям в одном запросе. Строки перемножаются: десять на десять дают сто.
  • join fetch по коллекции вместе с постраничной выдачей. Страница нарежется по строкам, и Hibernate утащит всё в память.
  • Проверять производительность только на тестовых данных. На трёх записях N+1 не виден вообще.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 15, остальные разбираются в тренажёре.

  1. #fetching_nplus11 / 5
    Что такое проблема N+1 запросов?
    A)Запрос, который случайно возвращает на одну строку больше, чем ожидалось, из-за ошибки в условии WHERE
    B)1 запрос грузит N сущностей, затем по 1 запросу на связь каждой — итого N+1
    C)Ситуация, когда для вставки N строк Hibernate выполняет N+1 INSERT вместо одного пакетного
    D)Ошибка, при которой один и тот же запрос по недосмотру выполняется в цикле ровно N плюс один раз
    показать ответ и разбор
    +B)1 запрос грузит N сущностей, затем по 1 запросу на связь каждой — итого N+1

    // разбор: N+1 возникает так: один запрос загружает СПИСОК из N сущностей (SELECT ... FROM orders), а затем при обращении к ЛЕНИВОЙ связи КАЖДОЙ из них (order.getItems()) Hibernate выполняет отдельный SELECT — ещё N запросов. Итого 1+N обращений к БД вместо одного-двух, что убивает производительность на росте данных, причём незаметно (код выглядит как простой цикл по объектам). Это самая частая проблема производительности в ORM. Лечится JOIN FETCH, @EntityGraph, @BatchSize или переносом в один запрос/проекцию.

  2. #fetching_nplus12 / 5
    Как устранить N+1 при загрузке коллекции?
    A)Пометить связь fetch=EAGER — это надёжно решает проблему N+1
    B)Увеличить размер пула соединений к БД, чтобы N дополнительных запросов выполнялись параллельно быстрее
    C)JOIN FETCH в запросе (или @EntityGraph) — подтянуть связь одним запросом
    D)Добавить индекс на внешний ключ — тогда N отдельных запросов станут настолько быстрыми, что проблема исчезнет
    показать ответ и разбор
    +C)JOIN FETCH в запросе (или @EntityGraph) — подтянуть связь одним запросом

    // разбор: Чинят N+1 на уровне ЗАГРУЗКИ: JOIN FETCH в JPQL (select o from Order o join fetch o.items) или @EntityGraph подтягивают связь тем же запросом — 1 SELECT вместо 1+N. Альтернатива — @BatchSize/hibernate.default_batch_fetch_size: Hibernate грузит связи ПАЧКАМИ (IN (...)), сводя N запросов к N/размер_пачки. Глобальный EAGER — НЕ решение: он сам создаёт N+1 при выборке списков и тянет лишнее. Осторожно с JOIN FETCH нескольких коллекций сразу (декартово произведение) и с пагинацией по fetch-джойну. Для чтения часто эффективнее проекция в DTO.

  3. #fetching_nplus13 / 5
    Когда возникает LazyInitializationException?
    A)Когда ленивая связь загружается слишком медленно и база данных разрывает соединение по таймауту
    B)Когда у ленивой связи не задан fetch-тип, из-за чего Hibernate не знает, как её инициализировать
    C)Когда сущность с ленивой связью сериализуется в JSON внутри всё ещё открытой транзакции контроллера
    D)При обращении к ленивой связи, когда сессия/контекст уже закрыты
    показать ответ и разбор
    +D)При обращении к ленивой связи, когда сессия/контекст уже закрыты

    // разбор: LazyInitializationException бросается, когда код обращается к ЛЕНИВОЙ связи, которая ещё НЕ была загружена, а контекст персистентности УЖЕ ЗАКРЫТ (сущность стала detached) — Hibernate негде выполнить дозагрузку. Классика: сервис вернул сущность, транзакция закрылась, а контроллер/сериализатор дёрнул lazy-коллекцию. Решения: загрузить нужные связи ВНУТРИ транзакции (JOIN FETCH/@EntityGraph), отдавать DTO (а не сущность) из транзакционного метода, либо (антипаттерн) Open-Session-In-View. НЕ лечить это глобальным EAGER.

  4. #fetching_nplus14 / 5
    Почему глобальный fetch=EAGER — плохая стратегия против LazyInitializationException?
    A)Он тянет связи всегда (лишние джойны/данные) и сам порождает N+1 при списках
    B)EAGER замедляет только запись данных, а на чтение он никак не влияет, поэтому его можно ставить везде
    C)EAGER полностью отключает кэш первого уровня, из-за чего каждый запрос идёт напрямую в базу заново
    D)EAGER запрещён спецификацией JPA для коллекций, поэтому его не получится указать на @OneToMany
    показать ответ и разбор
    +A)Он тянет связи всегда (лишние джойны/данные) и сам порождает N+1 при списках

    // разбор: EAGER «чинит» LazyInitializationException грубо: связь грузится ВСЕГДА, даже когда не нужна, — это лишние джойны и данные на каждый запрос сущности. Хуже: при выборке СПИСКА сущностей с EAGER-связями Hibernate порождает те же N+1 (или тяжёлые декартовы джойны), которых мы избегали. И нельзя выборочно «не грузить» связь там, где она не требуется. Правильный подход — держать связи LAZY по умолчанию и ЯВНО подтягивать нужное в конкретном запросе (JOIN FETCH/@EntityGraph) или работать с DTO-проекциями. Гибкость на стороне запроса, а не жёсткий EAGER на маппинге.

  5. #fetching_nplus15 / 5
    Что делает @EntityGraph при загрузке сущности?
    A)Строит визуальную диаграмму связей между всеми сущностями приложения для документации схемы БД
    B)Декларативно указывает, какие связи подгрузить (fetch) в конкретном запросе
    C)Заменяет реляционную модель графовой базой данных под капотом Hibernate при выборках
    D)Задаёт максимальную глубину рекурсивной загрузки связей, одинаковую для всех запросов приложения
    показать ответ и разбор
    +B)Декларативно указывает, какие связи подгрузить (fetch) в конкретном запросе

    // разбор: @EntityGraph (или программный EntityGraph) — способ ДЕКЛАРАТИВНО задать, какие связи подгрузить жадно в КОНКРЕТНОМ запросе/методе репозитория, не меняя fetch-тип в маппинге. Это гибкая альтернатива JOIN FETCH: в одном сценарии тянем orders с items, в другом — только orders. Так борются и с N+1, и с LazyInitializationException точечно. Бывает fetch graph (грузить только указанное) и load graph (указанное EAGER, остальное по своим дефолтам). Поддерживается Spring Data через @EntityGraph над методом репозитория.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.