1. Контроль: EAGER, OSIV, репозитории
EAGER, OSIV и рефлекторные repository-вызовы объединяет одна вещь: все они передают контроль за формой данных, моментом SQL и границей работы не use case’у, а инфраструктуре. Снаружи это выглядит как упрощение, а внутри это превращает систему в лотерею: один и тот же метод может вести себя по-разному при другом размере данных, другом порядке вызовов или «безобидном» логировании.
После entity leakage, oversized transaction и chatty repository это следующий слой проблемы. Отдельный сервис можно подчистить, но если потеря контроля зашита в EAGER, open-in-view или привычку доверять любому CRUD-методу, SQL всё равно уедет в неожиданное место.
Hibernate — штука очень мощная, но он не экстрасенс. Он не знает, какой экран вы сейчас обслуживаете: список заказов на 50 строк или карточку одного заказа, экспорт в CSV или фоновую проверку статусов. Когда мы включаем EAGER «на всякий случай», когда оставляем OSIV «чтобы не падало», когда вызываем saveAndFlush() «чтобы точно сохранилось» — мы по сути говорим: «дорогая инфраструктура, пожалуйста, придумай за меня архитектуру». И, как любой хороший джинн из бутылки, инфраструктура выполняет просьбу буквально. Потом мы удивляемся, что желание было сформулировано странно.
В сквозном проекте Commerce Persistence Lab это особенно видно: домен вроде простой (товары, заказы, остатки), но связей хватает, чтобы любая «универсальная» настройка превращалась в снежный ком запросов. И самое неприятное: ком растёт не в том месте, где вы его ждёте.
2. EAGER по умолчанию: «всё и сразу»
EAGER выглядит как очень человеческая идея: «Если связь мне нужна часто, давайте грузить её сразу, чтобы потом не было сюрпризов». Это как взять на пикник весь холодильник, потому что «вдруг захочется и сыр, и колбаса, и соус, и ещё арбуз». Формально вы подготовились ко всему. Практически вы теперь несёте холодильник.
Важно помнить базовую механику: fetch = EAGER означает не «Hibernate обязательно сделает JOIN», а «к моменту, когда сущность считается загруженной, связь должна быть доступна без дополнительных ленивых обращений». Как именно это будет достигнуто — уже решение провайдера. И вот здесь начинается магия… не всегда добрая.
В JPA есть ещё один неприятный «подарок»: @ManyToOne и @OneToOne по умолчанию EAGER. То есть даже если вы не писали слово EAGER, оно уже рядом, просто в сером плаще и с капюшоном. Поэтому хороший инженерный default в Hibernate-heavy проекте — делать to-one связи LAZY явно и поднимать нужный fetch-plan на уровне конкретного use case.
Посмотрите на типичную точку, где всё начинается. Допустим, разработчик делает заказу «удобное» поле customer и решает «а чего там, пусть всегда будет загружен»:
package com.example.commerce.orders.entity;
import jakarta.persistence.*;
@Entity
public class PurchaseOrder {
// Важно: это "глобальное" решение на уровне маппинга,
// оно повлияет на списки, экспорты и любые чтения этой сущности.
@ManyToOne(fetch = FetchType.EAGER) // выглядит как "чтоб было проще"
private Customer customer;
// На практике чаще безопаснее делать to-one связи LAZY,
// а нужную форму данных поднимать запросом/EntityGraph под конкретный use case.
}
На маленьких данных это почти всегда «нормально». Но затем вы делаете список заказов. И тут появляется коварный момент: список заказов — это не сценарий «мне нужен весь клиент», это сценарий «мне нужны номер заказа, статус, сумма и, возможно, email клиента». И именно «возможно» — ключевое слово. EAGER не умеет понимать «возможно». Он понимает только «всегда».
Дальше вы включаете SQL trace и видите, что «всегда» может выглядеть по-разному. Иногда Hibernate действительно сделает join. Иногда — серию secondary select. Иногда — и то, и другое, потому что провайдер выбирает компромисс между шириной JOIN’ов и количеством запросов. И главное: вы уже не управляете этим на уровне use case, потому что вы закрепили поведение в маппинге навсегда.
Чтобы зафиксировать мысль без философии, вот полезная табличка, которая помогает «протрезветь» после слова EAGER:
| Подход | Что вы на самом деле фиксируете | Где “больно” | Почему это анти-паттерн как default |
|---|---|---|---|
| fetch = EAGER в entity | Глобальное правило «всегда грузить» | Везде: списки, фоновые задачи, любые чтения | Один сценарий диктует цену всем сценариям |
| fetch = LAZY + явный fetch-plan (JOIN FETCH / EntityGraph) | Локальное правило «грузить здесь и сейчас под этот кейс» | Только там, где вы это явно выбрали | Управление остаётся у use case |
| Projection (DTO/record) | «Читать только нужные колонки» | Там, где вы хотели «универсальную entity» | Зато исчезают giant graphs и dirty checking overhead |
Обратите внимание: в этой таблице нет «правильного навсегда». Есть «правильное под задачу». EAGER как инструмент существует, но как дефолт мышления он почти всегда превращается в налог на каждый запрос.
Ещё одна классическая проблема EAGER: он как бы обещает «никакой ленивой загрузки», но на практике он часто просто переносит запросы в другое место. А перенос — это не решение. Это как сказать: «Я не буду прокрастинировать вечером, я буду прокрастинировать утром». Работа всё равно не сделана, просто кофе ещё не подействовал.
3. EAGER и secondary selects
Здесь полезно сделать маленькую паузу и аккуратно разложить термин secondary select человеческим языком. Secondary select — это когда вы ожидаете, что “всё загрузилось одним запросом”, а на деле Hibernate делает сначала запрос за root-сущностью, а затем дополнительные запросы, чтобы догрузить EAGER-связи. Это может выглядеть невинно — “ну подумаешь, ещё один SELECT” — пока это не становится “ещё один SELECT на каждую строку списка”.
Представим, что вы делаете «обычное» чтение заказа:
package com.example.commerce.orders.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderReadService {
// Транзакция здесь задаёт ожидаемую границу Unit of Work для чтения.
// Внутри неё Hibernate имеет право лениво инициализировать связи (если они LAZY).
@Transactional(readOnly = true)
public PurchaseOrder loadOrder(Long id) {
// findById() возвращает реальные данные (в отличие от reference/proxy).
return orderRepository.findById(id).orElseThrow();
}
}
Если customer у вас EAGER, то в SQL логе вы можете увидеть что-то вроде этого (упрощённо, но по смыслу верно):
-- 1) Загружаем сам заказ
select o.id, o.order_number, o.customer_id
from purchase_order o
where o.id = ?;
-- 2) Догружаем EAGER customer отдельным запросом
select c.id, c.email, c.first_name, c.last_name
from customer c
where c.id = ?;
Почему не JOIN? Потому что Hibernate выбирает стратегию выполнения, исходя из своих внутренних правил, особенностей запроса, уже загруженного контекста, и иногда просто из того, что “так безопаснее для корректности и минимизации дублей”. И да: это решение может отличаться между разными запросами и разными формами доступа.
На списках это особенно заметно. Когда вы делаете “дай мне 50 заказов”, secondary select превращается в “дай мне 50 клиентов”. И вот вы уже получили классический N+1, только он пришёл не из LAZY, а из “спасительного EAGER”. И это очень обидно психологически: вы же включили EAGER, чтобы “не было проблем с загрузкой”, а получили проблему с загрузкой, только с другим ароматом.
Кстати, на @OneToMany включать EAGER обычно ещё веселее: вы рискуете раздуть result set, получить дубли root-сущностей, сломать пагинацию, а иногда ещё и столкнуться с ограничениями по одновременному fetch коллекций. Это не значит, что EAGER “запрещён законом”. Это значит, что ставить его “по умолчанию и везде” — всё равно что в машине заменить ремни безопасности на подушки из бетона. Формально “безопаснее”, но ездить перестанете.
4. OSIV: persistence context живёт слишком долго
OSIV (Open Session in View) — это очень старый и очень соблазнительный паттерн. Он делает так, что EntityManager (и, соответственно, persistence context) живёт не только внутри сервисной транзакции, а почти весь web-request. И вот тогда доступ к LAZY связям в контроллере, сериализаторе JSON, логировании и других “внешних” местах внезапно начинает работать. Люди видят: “ошибка пропала” — и ставят галочку “починили”.
Проблема в том, что OSIV чаще всего не чинит, а маскирует. Он делает границу ответственности размытой: вместо “сервис решил, какие данные нужны” получаем “данные догружаются там, где кто-то случайно вызвал геттер”. Это превращает SQL в неуправляемое побочное действие.
В Spring Boot это выглядит примерно так. Включение OSIV (на практике это обычно “оставили дефолт”) можно увидеть в конфиге:
spring:
jpa:
open-in-view: true
А теперь сравним жизненный цикл запроса без OSIV и с OSIV. Схема упрощённая, но по ощущениям очень точная:
flowchart TD
A[HTTP request] --> B[Controller]
B --> C["Service @Transactional"]
C --> D[Repository / Hibernate]
D --> C
C --> B
B --> E[JSON serialization / logging]
E --> F[HTTP response]
subgraph "Без OSIV (open-in-view=false)"
C1["Persistence context живёт только внутри @Transactional"]:::good
end
subgraph "С OSIV (open-in-view=true)"
O1["Persistence context живёт до конца web-request"]:::bad
end
classDef good fill:#e6ffed,stroke:#2f855a,color:#22543d;
classDef bad fill:#fff5f5,stroke:#c53030,color:#742a2a;
Что реально меняется для разработчика? Меняется “место, где допустимо думать о данных”. Без OSIV вы вынуждены (в хорошем смысле) спроектировать чтение: либо загрузить нужный граф в сервисе, либо вернуть projection. С OSIV вы можете “надеяться”, что где-то потом всё догрузится само.
И самое неприятное: с OSIV вы часто узнаёте о проблеме не тогда, когда она появилась, а когда она стала очень дорогой. Например, на тестовых данных всё было быстро. В проде данных стало больше, а сериализация стала обходить больше полей, и вы внезапно получили десятки запросов на один ответ. И это может быть “после релиза”, а не “в момент написания кода”.
В нашем курсе и проекте baseline — open-in-view=false не из вредности, а потому что это единственный честный способ держать transaction boundary и fetch design в одном месте: в use case.
5. Слепая вера в JpaRepository
Репозитории Spring Data — отличная штука. Но ровно как швейцарский нож: он удобный, пока вы не пытаетесь им построить дом. JpaRepository даёт вам CRUD и несколько удобных абстракций, но он не даёт главного: осмысленного контракта чтения и записи под конкретный сценарий.
“Слепая вера в репозитории” обычно выглядит так: “У нас же есть findById()/findAll(), значит мы уже сделали data layer”. А потом люди удивляются, почему список заказов стал медленным, почему появился N+1, почему случайный saveAndFlush() изменил момент SQL, почему getReferenceById() внезапно выстрелил в ногу.
Посмотрим на один очень узнаваемый фрагмент. Разработчик меняет имя товара и рефлекторно “подтверждает сохранение” через saveAndFlush():
package com.example.commerce.catalog.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductService {
@Transactional
public void renameProduct(Long id, String newName) {
Product p = productRepository.findById(id).orElseThrow();
// Изменение managed entity будет найдено dirty checking'ом на commit.
p.setName(newName);
// flush "прямо сейчас" почти никогда не нужен для простого rename.
// Он меняет момент отправки SQL и может сделать поведение менее предсказуемым.
productRepository.saveAndFlush(p); // рефлекс "на всякий случай"
}
}
Проблема здесь не в том, что код “не работает”. Он работает. Проблема в том, что вы вводите в use case дополнительную семантику: вы заставляете flush случиться прямо сейчас. А flush — это не “подтверждение”, это изменение момента, когда Hibernate отправит SQL. Иногда это даст лишнюю нагрузку. Иногда приведёт к неожиданным flush-before-query. Иногда усложнит диагностику, потому что SQL уйдёт “раньше, чем ожидается”. И всё это ради привычки “вдруг без save не сохранится”, хотя dirty checking уже делает работу.
Другая частая история — желание “оптимизировать чтение” через getReferenceById(), потому что “он же не делает SELECT”. И получается вот так:
package com.example.commerce.orders.service;
import org.springframework.transaction.annotation.Transactional;
public class OrderReadService {
@Transactional(readOnly = true)
public PurchaseOrder loadOrderRef(Long id) {
// Важно: это не "данные заказа", а proxy на сущность.
// SELECT случится при первом обращении к полям (инициализация прокси).
return orderRepository.getReferenceById(id); // прокси, а не данные
}
}
Это нормально, когда вы правда понимаете, что делаете. Но “слепая” замена findById() на getReferenceById() часто означает: “я не проектирую форму данных, я просто надеюсь, что станет быстрее”. Дальше либо прилетает LazyInitializationException (если OSIV выключен), либо всё “вроде работает” (если OSIV включён), но SQL начинает происходить в неожиданном месте, потому что прокси будет инициализироваться при первом доступе к данным.
И наконец, репозитории не умеют читать мысли вашего use case. Если вы делаете:
package com.example.commerce.catalog.service;
import org.springframework.transaction.annotation.Transactional;
public class CatalogExportService {
@Transactional(readOnly = true)
public List<Product> exportAll() {
// findAll() возвращает entity-граф, а не "готовый формат для экспорта".
// Дальше вне этого метода легко начнётся обход связей и выстрелит N+1.
return productRepository.findAll(); // "а дальше разберёмся"
}
}
…то “дальше” обычно означает, что где-то снаружи начнутся вызовы getDetails(), getAssignments(), getCategory() и прочие обходы графа. И это снова возвращает нас к началу дня: entity leakage + giant graphs.
Репозиторий — это инструмент. Он не отменяет необходимость решить, какие данные нужны, где проходит транзакция, и какой fetch-plan допустим. Если репозиторий используется как “универсальная кнопка”, в какой-то момент он станет универсальной кнопкой “сделать неожиданно дорого”.
6. Комбо-эффект: случайный SQL
Самая опасная часть этих анти-паттернов — что они прекрасно усиливают друг друга. По отдельности они могут выглядеть терпимо. Вместе они превращают систему в набор “скрытых дверей”, за которыми сидит SQL и ждёт, когда вы случайно дёрнете ручку.
Представим вполне реалистичный сценарий (и в Commerce Persistence Lab он легко воспроизводится). Есть тонкий контроллер, который “просто отдаёт заказ”:
package com.example.commerce.orders.web;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
public PurchaseOrder get(@PathVariable Long id) {
// Опасный момент: контроллер возвращает entity наружу.
// Дальше сериализация/логирование могут пройти по графу и внезапно вызвать SQL.
return orderReadService.loadOrder(id);
}
}
Сервис возвращает entity (leakage), OSIV включён (сессия живёт до сериализации), часть связей EAGER (что-то догружается “гарантированно”), остальное LAZY (догружается “когда понадобится”). И дальше, когда JSON сериализатор начинает проходить по объекту, он может пройти по графу примерно так:
graph TD O[PurchaseOrder] --> C[Customer] O --> I[items] I --> P[Product] P --> D[ProductDetails] P --> A[assignments] A --> K[Category]
Если вы в этот момент думаете: “Ну и что, мне же это всё надо”, — это уже важный сигнал. Потому что возможно вам надо это всё в одном конкретном сценарии (карточка заказа для админки). Но точно не “всегда и везде”. А комбо-эффект устроен так, что “всегда и везде” легко появляется незаметно.
Самая подлая деталь: точка, где запросы выстрелят, может быть вообще не “в коде бизнес-логики”. Это может быть логирование:
log.info("Order={} items={}", order.getOrderNumber(), order.getItems().size());
Это может быть toString() (если кто-то его “удобно” написал). Это может быть сериализация ответа. И вот тут OSIV делает “добро”: не даёт упасть. Но вместе с этим он делает “зло”: разрешает этим местам выполнять SQL.
И получается очень характерная картина в логах: use case “вроде прочитал заказ”, а потом начинается фейерверк дополнительных select’ов. И проблема уже не локальная “здесь lazy”, а системная: вы потеряли контроль над тем, где и почему загружаются данные.
Поэтому здесь мало спросить «есть ли у нас lazy-связь». Нормальный audit-вопрос звучит жёстче: в какой точке request lifecycle вообще разрешено выполнять SQL — внутри use case или уже в контроллере, логгере и сериализации. И если запросы начинают происходить в JSON, логах или внешнем коде, почти наверняка у вас уже сработала комбинация OSIV + entity leakage, а EAGER только утяжелил картину.
7. Типичные ошибки при EAGER и OSIV
С этими тремя анти-паттернами есть одна неприятная особенность: их легко оправдать “практичностью”. Поэтому ошибки повторяются даже у опытных людей, просто в более красивой обёртке. Ниже — несколько формулировок, которые полезно узнавать “на слух”, потому что они часто звучат на ревью и в обсуждениях.
Ошибка №1: “Давайте сделаем EAGER, чтобы не было lazy-проблем”.
Такой подход лечит симптом (иногда) и почти всегда создаёт новый: overfetching, N+1 через secondary selects, раздутые join’ы, непредсказуемую цену запросов для списков. Обычно это ещё и закрепляет “универсальный граф”, который потом таскают в экспорт, в списки, в фоновые задачи и удивляются, что всё стало тяжелее.
Ошибка №2: “Включим OSIV — и LazyInitializationException пропадёт”.
Да, пропадёт. Вместе с ним пропадёт и честная граница ответственности. SQL начнёт происходить там, где вы его не проектировали: в контроллере, сериализации, логах, мапперах. На малых данных это выглядит как победа. На реальной нагрузке это обычно превращается в “почему один и тот же endpoint то быстрый, то медленный”.
Ошибка №3: saveAndFlush() — это как Ctrl+S, пусть будет для надёжности.
saveAndFlush() — это не “сохранить файл”, а изменение момента flush, а значит — изменение момента отправки SQL. В managed-flow это почти всегда лишнее, а иногда и вредное: оно делает SQL более ранним и может вызывать каскад неожиданных flush-before-query. Если хочется “надёжности”, её дают тесты и понятная транзакционная граница, а не лишний flush.
Ошибка №4: getReferenceById() быстрее, давайте всегда использовать его.
getReferenceById() не “быстрее”, он “другой”: это прокси вместо данных. Он полезен, когда вы правда хотите ссылку по id (например, чтобы поставить FK, не читая строку). Но как универсальная замена findById() он обычно означает, что вы просто переносите момент SQL куда-то в сторону и усложняете диагностику, особенно если OSIV скрывает последствия.
Ошибка №5: “Репозиторий — это и есть persistence layer”.
Репозиторий — это API. Persistence layer — это решения: что читать (entity или projection), как читать (fetch-plan), где читать (transaction boundary), как писать (dirty checking vs merge vs bulk), и как это проверять (SQL trace, regression tests). Если вместо решений вы используете только универсальные CRUD-методы “в надежде”, вы рано или поздно получите код, который невозможно объяснить на ревью без фразы “ну Hibernate так решил”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ