JavaRush /Курсы /Spring Data JPA /От N+1 к плану загрузки

От N+1 к плану загрузки

Spring Data JPA
22 уровень , 0 лекция
Открыта

1. N+1: симптом, не диагноз

Когда вы впервые видите N+1 в логах, очень хочется сделать два действия: во‑первых, обидеться на Hibernate, во‑вторых, поставить везде EAGER, «чтобы оно наконец перестало лениться». Это нормальная человеческая реакция (мы же живём в мире, где “ленивый” обычно звучит как оскорбление). Но в ORM “lazy” — это не лень, а контроль цены: вы не платите за загрузку связи, пока не убедились, что она нужна.

N+1 почти всегда появляется не потому, что вы «плохо написали запрос», а потому, что вы не спроектировали чтение как отдельный продукт. Вы написали «дай мне заказы», а потом в другом месте кода вдруг выяснили: «ой, а ещё позиции бы… и товары… и категории…». ORM честно исполнил ваши желания, но сделал это в формате “много маленьких походов”, а не “один большой поход”. И он не мог угадать иначе — у него нет телепатии, только ваш код.

Полезная мысль здесь простая: прежде чем выбирать конкретный инструмент, нужно научиться формулировать read-use-case и превращать его в план загрузки: какие данные должны быть доступны внутри сервисного чтения, до выхода наружу.

Read-use-case: сначала «что», потом «как»

Слово “use case” иногда пугает новичков так же, как слово “рефакторинг” пугает дедлайны. Кажется, что это что-то из мира архитектурных диаграмм, где люди рисуют квадратики и становятся очень серьёзными. На практике read-use-case — это просто ответ на вопрос: зачем я читаю эти данные прямо сейчас и в каком виде они мне нужны.

Если говорить совсем по‑простому, у вас в проекте нет абстрактного «чтения заказов». У вас есть сценарии вроде «показать список заказов в админке», «открыть карточку одного заказа», «показать краткую статистику по заказам в статусе NEW». Эти сценарии отличаются не только набором полей, но и тем, какие связи реально нужны. И вот тут появляется ключевой момент: ORM будет вести себя по‑разному в зависимости от того, читаете ли вы “объект для работы” (entity в транзакции) или “результат для показа” (лёгкое представление/summary).

Чтобы не превращать репозитории в свалку, мы делаем важный шаг: read-use-case формулируется на уровне сервиса (где живёт смысл), а репозиторий получает метод, который выражает намерение. Тогда ваш код начинает читаться как книга, а не как загадка: findCardByOrderNumber заметно честнее, чем findByOrderNumber, если под капотом вы подгружаете связи для карточки.

2. Три формы чтения в mini-shop

Если держать в голове все возможные варианты чтения, можно перегореть ещё до первого @Query. Поэтому мы введём очень простую, «джуновскую», но рабочую классификацию. Она не претендует на философскую глубину, зато спасает от хаоса, когда вы проектируете репозитории и сервисы.

Ниже — таблица, которая помогает быстро отделить «что мне нужно» от «что я случайно дочитал».

Форма чтения Пример read-use-case Что реально нужно Типичный результат
Карточка одной сущности «Открыть товар по id» Поля Product, возможно 1–2 простые связи Часто entity (если дальше будет работа в транзакции)
Карточка сущности со связями «Открыть заказ по orderNumber и показать позиции» CustomerOrder + items + иногда item.product entity, но с заранее определённым планом загрузки
Список / summary «Показать список заказов: номер + статус + сумма» Несколько полей, без графа сущностей Лёгкая read-модель (projection/DTO/record)

Заметьте важную штуку: N+1 чаще всего рождается именно тогда, когда вы хотели “список/summary”, а читаете “карточку сущности со связями”, причём по частям и случайно. Или наоборот: вы хотели “карточку со связями”, а реально прочитали “карточку без связей”, и потом начали дочитывать связи уже вне границы чтения.

Именно поэтому сегодня мы будем постоянно задавать один и тот же вопрос: какая форма результата нужна use-case? От этого зависит всё остальное.

3. План загрузки: fetch plan

План загрузки (fetch plan) — это не «ещё одна аннотация». Это договор между вами и вашим кодом: какие данные должны быть готовы к моменту, когда сервисный метод вернул результат. Если вы любите бытовые аналогии, то план загрузки — это список покупок, с которым вы идёте в магазин. Вы можете, конечно, ходить без списка и «вспоминать по дороге», но тогда вы гарантированно забудете сыр, купите лишний хлеб и дважды вернётесь за молоком. N+1 — это как раз “дважды вернуться”.

Хороший fetch plan формулируется сверху вниз. Сначала вы определяете форму ответа. Затем вы решаете, какие связи (если это entity) должны быть доступны внутри транзакции. И наконец вы фиксируете границу: всё, что нужно — подготавливаем внутри сервиса, а снаружи уже никто не «дочитывает» по объекту “на удачу”.

Ниже — простая блок-схема, которая помогает не перепрыгивать через шаги:

flowchart TD
    A["Сформулировать read-use-case"] --> B["Определить форму результата: entity или read-модель"]
    B --> C["Если entity: какие связи нужны внутри чтения?"]
    C --> D["Зафиксировать границу: сервисный метод readOnly"]
    D --> E["Репозиторий выражает намерение use-case отдельным методом"]

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

4. Граница чтения: сервис как контейнер смысла

У новичка часто есть естественное желание: «Ну мне же просто надо вернуть заказ. Давайте вернём CustomerOrder из сервиса, а в контроллере (или где-то ещё) уже посмотрим, что там внутри». С точки зрения Java это выглядит нормально: объект же у вас в руках, поля доступны, методы вызываются. Но с точки зрения JPA это ловушка, потому что объект — это не просто “POJO”, а часть ORM-модели, где некоторые связи живут лениво и требуют активного persistence context.

Поэтому граница чтения в нашем курсе почти всегда фиксируется на сервисе, и обычно выглядит так: @Transactional(readOnly = true) + метод, который возвращает либо подготовленную entity, либо готовую read-модель. Это делает чтение предсказуемым: вы точно знаете, где происходит SQL, и не размазываете его по всему приложению.

Мини-пример того, как это выглядит в коде, без лишней “обвязки”:


import java.util.Optional;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true) // Фиксируем границу чтения: SQL должен происходить внутри сервиса
public Optional<CustomerOrder> loadOrderCard(String orderNumber) {
    // Название метода — это контракт: «сейчас читаем карточку заказа»
    // Репозиторий должен вернуть заказ уже в той форме, которая подходит этому use-case
    return orderRepository.findCardByOrderNumber(orderNumber);
}

Обратите внимание на смысл, а не на синтаксис. Сервисный метод — это место, где вы говорите: “сейчас я читаю карточку заказа”. Если после этого наружный слой попытается лазить по связям и «дочитывать» — это уже нарушение контракта. Оно либо приведёт к LazyInitializationException, либо начнёт зависеть от того, открыт ли persistence context “где-то ещё”. И это уже архитектурная зыбучая почва.

5. Репозиторий как словарь read-use-case

Репозиторий в Spring Data JPA легко превратить в музей случайных методов. Просто потому, что IDE подсказывает: “а давай сделаем findBy...And...Or...”, и рука тянется добавить ещё одну ветку. Но сегодня важнее другое: методы репозитория должны выражать намерение чтения, а не быть универсальными “дай что-нибудь, а дальше разберёмся”.

Это особенно заметно на заказах. У нас есть минимум два разных смысла чтения, и они плохо уживаются в одном методе. Карточка заказа — это один объект со связями. А список заказов — это обычно набор коротких строк.

Сигнатуры могут выглядеть так (пока без реализации, потому что реализацию мы будем обсуждать в следующих лекциях дня):


import java.util.List;
import java.util.Optional;

public interface CustomerOrderRepository {
    // Use-case «карточка заказа»: обычно означает «один заказ + заранее определённые связи»
    Optional<CustomerOrder> findCardByOrderNumber(String orderNumber);

    // Use-case «табличный список»: это не граф сущностей, а лёгкий summary-результат
    List<OrderSummaryRow> findOrderSummariesByStatus(OrderStatus status);
}

Да, это выглядит как “слишком много методов”. Но на самом деле это выглядит как “у нас есть два разных use-case”. И это намного лучше, чем один метод findAll() и десять мест в коде, где потом случайно трогают getItems().

Кстати, отдельная маленькая радость: когда метод называется “как use-case”, дебажить проще. Логи и стектрейсы перестают быть криптограммой.

6. Мини-демо: один невинный цикл и N+1

Сейчас будет тот самый пример, который выглядит безобидно, пока не включишь SQL-логи. Он почти дословно повторяет типичный «джуновский» код: читаем заказы и печатаем количество позиций.


import java.util.List;

// Опасное место: findAll() возвращает сущности, а связи (часто) остаются LAZY
List<CustomerOrder> orders = orderRepository.findAll();

for (CustomerOrder order : orders) {
    // Каждый вызов getItems() может инициировать отдельный SQL-запрос на items
    // В итоге: 1 запрос на заказы + N запросов на позиции = N+1
    System.out.println(order.getItems().size());
}

На уровне Java всё выглядит честно. Но на уровне SQL это превращается в последовательность: один запрос на список заказов, а потом ещё по запросу на order_item для каждого заказа. Если заказов 50, вы получите 51 запрос. И вот тут начинается «сюрприз производительности»: разработчик думал, что он просто “прошёлся по списку”, а база данных внезапно получила нагрузку как от маленького шторма.

Теперь самое важное: исправление N+1 начинается не с “поставить EAGER” и даже не с “применить инструмент”. Исправление начинается с честного вопроса: а какой у нас read-use-case? Если это экран “список заказов”, то нам, возможно, вообще не нужна коллекция items как объекты. Нам нужен, например, orderNumber, status, totalAmount и, может быть, itemsCount. Это уже не «карточка заказа» — это summary. Значит, правильное решение — сделать read-модель и читать ровно то, что нужно.

А если use-case — “карточка заказа”, то мы говорим: “для одного заказа мне нужны позиции и товары”. Тогда план загрузки другой: мы заранее подготавливаем связи внутри границы чтения, чтобы не было неожиданностей после возвращения из сервиса.

Смысл лекции в том, что эти два use-case — разные. И попытка решить их одним способом почти всегда приводит либо к N+1, либо к избыточной загрузке (когда вы тащите половину базы ради маленького списка).

7. Где ломается дисциплина чтения

Есть один особенно коварный сценарий, который выглядит как удобная экономия времени. Вы делаете метод сервиса findById(), возвращаете entity, а дальше уже в контроллере (или в другом сервисе, или в маппере) начинаете «рассматривать» объект: getItems(), getCategory(), getProductDetails(). И вроде бы всё даже работает… иногда.

Проблема в том, что так вы создаёте код, где SQL начинает происходить “где-то там”. Сегодня это может быть контроллер. Завтра — сериализация. Послезавтра — логгер, который в toString() случайно прошёлся по связи (да, такое бывает, и это отдельный вид комедии). И в этот момент у вас нет ни плана загрузки, ни контроля над тем, сколько запросов улетит в базу.

Правильная дисциплина звучит скучно, зато работает: внешний слой не должен принимать решение “а давай дочитаем вот это”. Решение о том, что нужно загрузить, принимается внутри read-use-case. То есть в сервисе, в его транзакционной границе, в понятном методе с названием, отражающим смысл.

8. Типичные ошибки при планировании чтения

Ошибка №1: начинать с инструмента, а не с read-use-case.
Очень хочется сразу спросить: “А что мне писать: join fetch или @EntityGraph?” Но это вопрос второго шага. Если вы не понимаете, что именно вы читаете (карточку, список, summary), вы легко выберете «правильный» инструмент и получите неправильный результат: например, загрузите огромный граф там, где нужна одна строчка.

Ошибка №2: возвращать entity для табличного списка «потому что так проще».
Entity кажется удобной, потому что “в ней есть всё”. Но «в ней есть всё» — это именно то, что делает её дорогой. Для списка почти всегда нужен ограниченный набор полей. Если вы возвращаете entity, вы создаёте соблазн (или случайность) дочитывать связи и получать N+1. Намного здоровее сразу вернуть форму результата, которая соответствует таблице.

Ошибка №3: пытаться сделать один универсальный repository method «на все случаи жизни».
Универсальный метод обычно превращается в компромисс: он или слишком тяжёлый для списков, или недостаточный для карточек. В итоге его начинают «допиливать» снаружи, и вы получаете N+1, LazyInitializationException и путаницу в контракте. Разные read-use-case — разные методы, и это нормально.

Ошибка №4: думать, что FetchType.LAZY сам всё оптимизирует.
LAZY — это не оптимизация в смысле “сделает быстрее”. Это механизм “отложить загрузку”. Если вы всё равно полезете в связь в цикле, вы просто отложите N+1 на пару строк кода вправо. Поэтому LAZY работает в паре с планом загрузки: сначала решаем, что нужно, затем загружаем это осознанно.

Ошибка №5: позволять внешнему слою «дочитывать» связи после выхода из сервиса.
Даже если это “пока работает”, вы теряете контроль над SQL и начинаете зависеть от окружения. В одном профиле это может привести к LazyInitializationException, в другом — к тихому шквалу запросов. В обоих случаях это ломает предсказуемость системы, а предсказуемость для data-layer — почти как кислород: без неё очень быстро начинает “душить”.

1
Задача
Spring Data JPA, 22 уровень, 0 лекция
Недоступна
Карточка товара и строка каталога как два разных read-use-case
Карточка товара и строка каталога как два разных read-use-case
1
Задача
Spring Data JPA, 22 уровень, 0 лекция
Недоступна
Карточка заказа и список кратких summary по статусу
Карточка заказа и список кратких summary по статусу
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ