1. Матрица выбора: смысл
Если честно, самая типичная проблема разработчика в Hibernate-heavy коде не в том, что он «не знает аннотаций». Проблема в том, что он знает слишком много кнопок, а выбирать начинает по привычке: «везде entity», «везде JOIN FETCH», «везде save()», «везде native SQL» — нужное подчеркнуть, лишнее закоммитить и молиться. Матрица выбора — это способ вернуть себе спокойствие: ты не угадываешь, а задаёшь несколько правильных вопросов и получаешь логичный ответ.
Оси аудита уже показывают, где болит. Но finding бесполезен, если дальше лечение выбирается по рефлексу: увидели много SQL — поставили JOIN FETCH, увидели detached данные — сунули merge(), увидели массовое обновление — закрутили save() в цикле. Нужна матрица, которая сначала разводит read и write, а уже потом сужает инструмент.
Представьте, что вы пришли на кухню. Вам нужно нарезать салат. На столе лежат: бензопила, топор, скальпель, ножницы и нормальный кухонный нож. Формально, салат можно сделать почти всем из этого списка. Но есть ощущение, что часть инструментов не предназначена для ежедневной работы. В Hibernate то же самое: StatelessSession или bulk update — это не «плохие» инструменты, они просто не для каждого сценария. И наоборот, entity loading не «зло», он просто дорогой там, где нужен лёгкий read-слепок.
В рамках Commerce Persistence Lab это особенно видно: у нас есть каталог, заказы, склад, аудит. Одни сценарии похожи на «табличку в админке», другие — на «детальную карточку заказа», третьи — на «массовое переоценивание каталога». Ошибка начинается ровно в момент, когда мы берём один стиль и пытаемся им решить всё, как будто Hibernate — швейцарский нож, а не набор разных инструментов.
2. Read vs write: развилка
Самая полезная «первичная развилка» — не JOIN FETCH vs EntityGraph, а гораздо проще: мы сейчас читаем или пишем? Звучит банально, но именно на этом этапе чаще всего появляются дорогие ошибки вида «прочитал сущность для списка и случайно потащил managed-граф», или «хотел массово обновить 50k строк, а сделал цикл по entity».
Ниже — упрощённая схема, которой достаточно, чтобы мозг перестал паниковать и начал думать как инженер, а не как шаман с бубном (в бубен можно, но позже — после работы).
flowchart TD
A["Use case"] --> B{"Read или Write?"}
B -->|Read| C{"Список / Детали / Отчёт?"}
C -->|Список| D["Projection (DTO / interface)"]
C -->|Детали| E["Entity loading + fetch-plan"]
C -->|Отчёт| F["Projection или native SQL"]
B -->|Write| G{"Один агрегат или много строк?"}
G -->|Один агрегат| H["find + mutate (managed)"]
G -->|Много строк| I["bulk update/delete или StatelessSession"]
Обратите внимание на мораль диаграммы: инструменты не «сражаются» друг с другом, они занимают разные клетки. Projection не конкурирует с optimistic locking, потому что это разные сценарии. Bulk update не конкурирует с EntityGraph, потому что один — про массовые записи, а второй — про форму чтения.
Быстрый тест: нужен ли вам managed entity
Есть простой внутренний вопрос, который почти всегда помогает: «Я собираюсь менять состояние объекта в этой операции?». Если ответ «нет, я просто показываю список/отчёт/витрину», то managed entity вам, скорее всего, не нужна. А если managed entity не нужна, то и многие связанные проблемы (dirty checking, случайные UPDATE, графы, lazy-ловушки) вы даже не зовёте на вечеринку.
Если ответ «да, я меняю статус заказа, переименовываю товар, резервирую остаток», то managed entity становится почти неизбежной: она даёт вам нормальную unit of work-модель, версионирование, корректный flush, каскады (если они уместны) и вообще весь ORM-комфорт, ради которого вы это всё затеяли.
В Commerce Persistence Lab это выглядит так: список товаров для админки — обычно read-сценарий, карточка товара — read-сценарий, но детальный, а изменение цены товара или резервирование остатка — write-сценарии, где managed сущность вполне уместна.
То есть матрица нужна не для красивой схемы на стене. Потом эти же развилки всплывают в самом обычном коде: entity там, где нужен list-row DTO; saveAndFlush() там, где хватило бы managed update; bulk там, где продолжают мыслить managed-объектами.
3. Read-сценарии: список, карточка, отчёт
Про чтение в ORM часто думают так: «ну это же просто findAll() и потом .map(...)». И вот тут Hibernate начинает тихо плакать в уголке, потому что «просто» превращается в N+1, в избыточные графы, в случайную инициализацию коллекций и в чтение целого мира ради двух колонок. Поэтому в матрице выбора важно сразу различать форму результата: список, детали или отчёт.
Список — это обычно табличный формат: много строк, но мало колонок и почти никогда не нужен весь граф. Карточка — это обычно «одна сущность + несколько связанных данных», причём связи должны быть выбраны осознанно. Отчёт — это часто агрегаты, GROUP BY, суммы, статистика, где ORM-язык иногда начинает «сопротивляться» и честнее перейти на native SQL или хотя бы на специализированную проекцию.
Список/таблица: projection как базовый кандидат
Для списков (особенно backoffice-таблиц) projection — это почти идеальный default. Она не тащит managed-граф, не создаёт лишний dirty checking, не провоцирует lazy-навигацию случайно и, что важно, делает контракт чтения явным: вот ровно эти поля мы отдаём, и ничего «само» не подтянется, даже если где-то есть @ManyToOne(fetch = EAGER) (который мы, конечно, не любим).
Начнём с простого DTO на record — это очень дружелюбный формат для списка.
import com.example.commerce.catalog.entity.ProductStatus;
public record ProductListRow(
Long id,
String sku,
String name,
ProductStatus status
) {}
Теперь репозиторий, который отдаёт «строки для таблицы», а не сущности:
import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.List;
// Read-репозиторий: здесь мы возвращаем проекцию, а не managed-сущность
public interface ProductQueryRepository extends Repository<Product, Long> {
// JPQL-конструктор: формируем DTO сразу в запросе, чтобы не тащить entity-граф
@Query("""
select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name, p.status)
from Product p
where p.status = :status
""")
List<ProductListRow> findListRows(ProductStatus status);
}
Обратите внимание на маленькую, но важную «архитектурную педантичность»: мы не обязаны мешать write-репозиторий (JpaRepository<Product, Long>) и read-запросы в один интерфейс. В проекте мы и раньше двигались к разделению ...repository и ...query. Матрица выбора отлично живёт в такой структуре: список — это query-слой, а не entity-операция.
Карточка/детали: entity loading + сценарный fetch-plan
Карточка товара или детальные данные заказа — это сценарии, где entity loading уже оправдан. Не потому что «так проще», а потому что вам может понадобиться навигация по связям, логика доменной модели, lazy-поля по запросу, а иногда и дальнейшая мутация в той же транзакции (например, «открыли заказ и сразу сменили статус» — не лучший UX, но как учебный пример годится).
Ключевой момент: когда вы выбираете entity loading для detail-use case, вы автоматически выбираете ещё одну вещь — fetch plan. То есть вы решаете, какие ассоциации должны быть загружены в рамках этого сценария. Не «навсегда в аннотациях», а именно под use case.
Простейший вариант: EntityGraph для детального заказа.
import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.repository.Repository;
import java.util.Optional;
public interface OrderDetailsRepository extends Repository<PurchaseOrder, Long> {
// ВАЖНО: это сценарный fetch-plan — для этого метода гарантируем customer и items
@EntityGraph(attributePaths = {"customer", "items"})
Optional<PurchaseOrder> findDetailedById(Long id);
}
Такой метод не обещает, что «везде у заказа всегда будут items», он обещает конкретное: в этом сценарии мы грузим customer и items. Это как заказать пиццу «с сыром и грибами», а не покупать сразу холодильник пиццы «на всякий случай».
Reporting-like чтение: native SQL как честная граница
Отчётные запросы часто требуют агрегатов и группировок, а иногда и довольно «плоских» результатов. Можно заставить JPQL выражать это, можно построить Criteria, но иногда проще и честнее написать SQL. Это нормально. ORM — инструмент, а не религия.
Для примера: «сколько штук каждого SKU было продано» (условный sales summary). Сделаем интерфейс-проекцию, чтобы не таскать Object[].
public interface SalesSummaryRow {
String getSku();
long getTotalQty();
}
И native query в репозитории:
import com.example.commerce.orders.entity.OrderItem;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.List;
public interface SalesReportRepository extends Repository<OrderItem, Long> {
// Native SQL уместен для агрегатов: плоский результат, GROUP BY, sum(...)
// Возвращаем интерфейс-проекцию, чтобы не работать с Object[]
@Query(value = """
select p.sku as sku, sum(oi.quantity) as totalQty
from order_item oi
join product p on p.id = oi.product_id
group by p.sku
""", nativeQuery = true)
List<SalesSummaryRow> findSalesSummary();
}
Здесь важен не сам SQL, а место в матрице: отчётный read-case — это кандидат на native SQL, потому что он «плоский», агрегатный и не требует managed-сущностей. Это помогает не тащить ORM-механику туда, где она не приносит пользы.
4. Fetch-plan toolbox
Когда мы решили «да, entity нужен», матрица выбора переключается на следующий уровень: как загрузить необходимые связи без N+1 и без оверфетчинга. Тут очень легко свалиться в крайность: либо «всё EAGER», либо «всё JOIN FETCH», либо «я вообще ничего не буду грузить, пусть lazy сам разберётся». Зрелый ответ обычно середина: вы выбираете инструмент под форму сценария.
Есть три типовых инструмента, которые чаще всего покрывают 80% задач: JOIN FETCH, EntityGraph, batch fetching. Мы их уже разбирали раньше, но сейчас нам нужно именно решение-ориентированное понимание: когда какой брать в матрице.
JOIN FETCH: быстро, мощно, но иногда «раздувает» результат
JOIN FETCH — это как пылесос: он правда умеет убрать N+1, но если вы включите его на максимальную мощность и в маленькой комнате, он начнёт засасывать не только пыль, но и ковёр, и кота, и вашу самооценку. Проблема JOIN FETCH не в том, что он плохой, а в том, что он меняет форму результата и может раздувать result set, особенно на коллекциях.
Для детального чтения одного заказа с items JOIN FETCH часто уместен:
import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.Optional;
public interface OrderFetchJoinRepository extends Repository<PurchaseOrder, Long> {
// JOIN FETCH убирает N+1 ценой "раздувания" результата: особенно аккуратно с коллекциями
@Query("""
select po
from PurchaseOrder po
join fetch po.items
where po.id = :id
""")
Optional<PurchaseOrder> findByIdWithItems(Long id);
}
В матрице это выглядит так: detail-read, один root, одна коллекция — JOIN FETCH часто хороший кандидат. А вот list-read + paging + fetch join на коллекциях — это уже красный флажок (но подробные ограничения мы оставим в голове как «проверить», не превращая лекцию в повтор предыдущих дней).
EntityGraph: управляемый fetch-plan без переписывания запросов
EntityGraph удобен, когда вы хотите «тот же запрос», но с разной загрузкой ассоциаций под разные сценарии. Он хорошо ложится в Spring Data, где методы чтения уже описаны, а вы добавляете к ним сценарный fetch-план. И часто это читается проще, чем JPQL с несколькими join fetch.
Пример «детальный заказ: клиент + позиции + продукт каждой позиции» можно выразить графом:
import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.repository.Repository;
import java.util.Optional;
public interface OrderGraphRepository extends Repository<PurchaseOrder, Long> {
@EntityGraph(attributePaths = {"customer", "items", "items.product"})
Optional<PurchaseOrder> findById(Long id);
}
В матрице это хороший кандидат, когда вам нужен entity, но хочется держать запрос «обычным» и управлять загрузкой как настройкой сценария. Это похоже на переключатель фар: машина та же, но режим освещения другой.
Batch fetching: «пусть будет несколько запросов, но умных»
Batch fetching — это компромисс: вы принимаете, что будет несколько SQL-запросов, но хотите, чтобы они были группированными, а не N+1. Это особенно уместно, когда JOIN FETCH сделает один огромный и тяжёлый запрос (например, дубликаты root-строк, ширина результата), а вам достаточно нескольких «secondary selects», но не по одному на каждую строку.
На уровне mapping это может выглядеть так (пример для PurchaseOrder.items):
import org.hibernate.annotations.BatchSize;
import jakarta.persistence.OneToMany;
import jakarta.persistence.FetchType;
import java.util.ArrayList;
import java.util.List;
class PurchaseOrder {
// BatchSize помогает сгруппировать lazy-загрузку коллекций (не N+1, а "пачками")
@BatchSize(size = 20)
// LAZY оставляем, чтобы не "взрывать" граф всегда; дозагружаем осознанно по сценарию
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
}
В матрице это часто выглядит так: «мы читаем список заказов, и иногда нам нужно пройтись по items, но JOIN FETCH слишком дорог». Тогда batch fetching — нормальный, управляемый компромисс: не магия, а осознанная цена.
5. Write: find + mutate
В write-сценариях главная ловушка не в fetch-планах, а в стиле обновления. Самый предсказуемый и понятный паттерн для «изменить одну сущность/агрегат» — это find + mutate внутри транзакции. Он звучит скучно, но именно скучные решения чаще всего оказываются самыми надёжными (в отличие от «красивого универсального merge всего графа сразу»).
Пример из каталога: переименовать товар. Никакого save() «на всякий случай» не нужно, если сущность managed.
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.repository.ProductRepository;
import org.springframework.transaction.annotation.Transactional;
@Transactional // Транзакция нужна, чтобы сущность стала managed и сработал dirty checking
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public void renameProduct(long productId, String newName) {
// Загружаем managed-сущность и меняем состояние — Hibernate сам сделает UPDATE на flush/commit
Product product = productRepository.findById(productId).orElseThrow();
product.setName(newName);
}
}
В матрице это ровно тот случай: write-use case, один агрегат, понятная транзакция, Hibernate делает dirty checking и отправляет UPDATE там, где должен. Вы не делаете «лишних движений» и не создаёте лишних SELECT/flush, которые потом будете диагностировать как будто это баг ORM.
6. Массовые изменения: bulk и синхронизация
Когда меняются многие строки одинаковым правилом, entity-цикл почти всегда проигрывает. Причина простая: entity-цикл платит за materialization объектов, snapshots, dirty checking, возможно каскады — и всё это ради операции, которая по смыслу «одна команда SQL для многих строк». Bulk update/delete — это способ честно сказать: «мне не нужен объектный мир, мне нужно изменить набор строк».
Именно здесь performance-выигрыш сразу тянет за собой вопрос correctness: что теперь правда для уже загруженных managed-объектов?
Пример: массово поменять статус всем товарам каталога (условная административная операция).
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
public interface ProductBulkRepository extends Repository<Product, Long> {
// Bulk идёт напрямую в БД и не синхронизирует уже загруженные managed-сущности
@Modifying
@Query("""
update Product p
set p.status = :status
where p.deleted = false
""")
int changeStatusForCatalog(ProductStatus status);
}
Ключевой смысл для матрицы: bulk — это не «ускоритель вообще всего», это инструмент для сценария «много строк, одно правило». А дальше сразу включается следующий вопрос матрицы: «а что с persistence context?», потому что bulk обходит managed-модель.
Синхронизация после bulk
После bulk-операции важно не продолжать жить как будто ничего не произошло. Bulk меняет БД напрямую, а уже загруженные managed-объекты остаются со старым состоянием. Канонический протокол здесь такой: bulk меняет строки, а не managed-объекты. Поэтому после него либо очищаем контекст и читаем заново, либо держим bulk в отдельном unit of work. clearAutomatically и flushAutomatically бывают удобны как convenience-вариант, но сначала лучше держать в голове именно эту причинно-следственную связку.
И ещё один практический момент: если перед bulk в этой же транзакции у вас уже накопились изменения managed-сущностей, сначала отдельно решите вопрос их flush. Иначе вы смешиваете две модели работы и потом удивляетесь последовательности SQL.
Если хочется показать это явно на сервисе, можно сделать так:
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class CatalogAdminService {
private final ProductBulkRepository bulkRepository;
private final EntityManager entityManager;
public CatalogAdminService(ProductBulkRepository bulkRepository, EntityManager entityManager) {
this.bulkRepository = bulkRepository;
this.entityManager = entityManager;
}
public int hideAllProducts() {
int updated = bulkRepository.changeStatusForCatalog(ProductStatus.HIDDEN);
// После bulk старые managed-объекты больше нельзя считать правдой
entityManager.clear();
return updated;
}
}
Если после этого сценария нужно снова смотреть на те же товары, делаем новое чтение уже после clear(). Не рассчитываем, что старые ссылки magically обновились.
StatelessSession: тяжёлые сценарии
StatelessSession в Hibernate — это как грузовик: он прекрасен, когда нужно перевезти много, но странно ездить на нём в магазин за хлебом. Он отключает привычные вещи (persistence context, first-level cache, автоматический dirty checking) и тем самым становится полезен для специальных high-volume сценариев, где обычная managed-модель слишком дорога.
Например, массовая вставка InventorySnapshot в лабораторных performance-сценариях (идея схематичная, без деталей инфраструктуры):
import jakarta.persistence.EntityManager;
import org.hibernate.Session;
import org.hibernate.StatelessSession;
public class SnapshotImportService {
private final EntityManager entityManager;
public SnapshotImportService(EntityManager entityManager) {
this.entityManager = entityManager;
}
public void insertSnapshot(Object snapshotEntity) {
Session session = entityManager.unwrap(Session.class);
try (StatelessSession ss = session.getSessionFactory().openStatelessSession()) {
ss.insert(snapshotEntity);
}
}
}
В матрице это попадает в клетку «очень большой объём, простой pipeline, не нужна managed-магия». И очень важно помнить вторую половину фразы: не нужна managed-магия. Если она нужна — StatelessSession превращается из грузовика в проблему.
7. Сводная матрица выбора
Хочется, чтобы после лекции у вас осталась не «куча знаний», а маленькая опора для мозга. Ниже — компактная шпаргалка-матрица. Она не заменяет мышление, но помогает не впадать в крайности и быстро сузить выбор до 1–2 кандидатов, которые потом проверяются по SQL, статистике и тестам.
| Use case в Commerce Persistence Lab | Первый кандидат | Почему это обычно правильно | Когда переключаться на другой инструмент |
|---|---|---|---|
| Список товаров (таблица в админке) | Projection (DTO/record) | Узкий результат, нет managed-графа, меньше риска N+1 и accidental updates | Нужны «детали»/связи прямо в списке → EntityGraph/batch fetching; нужен агрегатный отчёт → native SQL |
| Карточка товара (Product + ProductDetails) | Entity + fetch-plan | Это detail-read, где entity-модель удобна, но важно управлять загрузкой | Если данные строго плоские → projection; если отчёт → native SQL |
| Детали заказа (PurchaseOrder + items) | EntityGraph или JOIN FETCH | Нужен граф под один root; можно убрать N+1 управляемо | Для списков с paging лучше не делать fetch join коллекций; иногда лучше batch fetching |
| Изменить один товар/заказ | find + mutate | Предсказуемая unit of work, dirty checking, минимум сюрпризов | Если пришёл detached-граф и хочется «склеить всё сразу» → аккуратно и осознанно, но чаще всё равно лучше find + mutate |
| Массовое изменение каталога (repricing/status) | bulk update/delete | Один SQL на много строк вместо дорогого entity-цикла | Если после bulk нужно продолжать работу с entity в той же транзакции — обязателен план синхронизации (clear/re-read) |
| Большой импорт/очистка исторических данных | StatelessSession / bulk | Минимум overhead, контроль объёма | Если нужна managed-логика, каскады, версия, сложные связи — вернуться к обычной модели + batching |
| Отчёт: продажи/агрегаты | native SQL + projection | Честная форма запроса, прозрачность, не тащим ORM-граф | Если запрос простой и ложится в JPQL — можно остаться в JPQL |
Если хочется «сжать» матрицу в один псевдокод, то он выглядит примерно так (и да, это специально простая формула — вы не обязаны делать из неё философию):
если read:
если список → projection
если детали → entity + fetch-plan
если отчёт → projection или native SQL
если write:
если один агрегат → find + mutate
если много строк → bulk update/delete
если очень большой объём pipeline → StatelessSession (узко)
8. Типичные ошибки при выборе подхода
Ошибки в выборе инструмента обычно выглядят не как «код не компилируется», а как «всё работает, но почему-то больно». Больно в проде, больно на нагрузке, больно на поддержке, а иногда больно данным (это самое неприятное). Поэтому лучше помнить несколько типовых «срывов матрицы» и заранее узнавать их в коде, как знакомых персонажей в сериале: «о, это снова ты».
Ошибка №1: возвращать entity в любом read-сценарии “потому что так привычнее”.
Почти всегда это приводит к тому, что список превращается в мини-граф сущностей, а потом кто-то случайно трогает getCustomer() в стриме, и вы получаете N+1. В матрице списка default — projection, а entity — это осознанное исключение.
Ошибка №2: лечить всё JOIN FETCH, даже когда сценарий — paging-список.
JOIN FETCH отлично помогает на детальном чтении, но на списках с коллекциями может раздувать результат и ломать ожидания. Матрица специально разделяет «detail read» и «list read»: для списка чаще проще и дешевле projection или batch fetching.
Ошибка №3: делать bulk update и продолжать читать/изменять уже загруженные managed-объекты, как будто ничего не случилось.
Bulk — это операция «мимо» persistence context. Поэтому после bulk либо контекст чистится, либо сценарий строится так, чтобы не было иллюзий «я уже читал товар, значит он обновился сам». Матрица не запрещает bulk, она требует дисциплины после выбора bulk.
Ошибка №4: пытаться merge() как универсальный “save всего, что пришло”.
merge() может быть полезен, но как универсальный стиль он почти всегда делает поведение дорогим и непредсказуемым: лишние SELECT, неожиданные side effects на графе, каскады, дубликаты детей. Для «обычного изменения» матрица предпочитает find + mutate, потому что это контролируемый сценарий.
Ошибка №5: использовать native SQL “на всякий случай”, чтобы не думать про ORM.
Native SQL — нормальный инструмент, но он должен появляться там, где он даёт выигрыш в ясности или выразительности (агрегаты, отчёты, сложные join-формы). Если задача — обычный список товаров, native SQL часто только усложнит поддержку. Матрица сначала предлагает projection/JPQL, а native — как честную границу, а не как дефолт.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ