1. Введение
Если подойти к теме «оптимизации чтения» как к выбору вкуса мороженого, легко попасть в ловушку: «мне нравится кэш, значит кэшируем». Но в Hibernate эти три режима отвечают на три разные вопроса, и сравнивать их нужно именно так — через вопрос, который вы решаете. Иначе вы получите очень характерный результат: сложность вырастет, а ускорение не появится (зато появится новая легенда: «Hibernate медленный, потому что…»).
Никакой новой магии здесь не появляется: те же уже знакомые механизмы просто живут на разных уровнях — внутри текущего persistence context, внутри текущей read-транзакции и между транзакциями.
Давайте сформулируем эти три вопроса максимально бытовым языком:
1) Нужно ли мне в рамках текущего unit of work менять загруженные объекты и сохранять изменения? Если да — вам нужен обычный managed-flow. Это не про скорость, это про корректность и «право на запись».
2) Я точно не буду менять данные, но мне всё равно удобнее читать через entity (а не через projection). Можно ли сделать чтение дешевле? Вот здесь появляется read-only. Он не делает запрос «без SQL», он делает сущности менее дорогими внутри текущей сессии.
3) Один и тот же запрос выполняется снова и снова в разных транзакциях (например, на каждый HTTP-запрос), и результат достаточно стабилен. Можно ли переиспользовать результат между транзакциями? Это поле query cache (и/или second-level cache), то есть оптимизация уровня «между запросами приложения».
Чтобы закрепить различие, удобно держать в голове маленькую табличку:
| Подход | На какой вопрос отвечает | Граница жизни эффекта | Главная цена/риск |
|---|---|---|---|
| Managed entity | «Можно ли менять и сохранять?» | текущая транзакция / текущий persistence context | snapshots + dirty checking + риск accidental updates |
| Read-only entity | «Можно ли читать дешевле внутри транзакции?» | текущая транзакция / текущая сессия | нельзя ожидать сохранения изменений, легко запутаться в “изменилось в памяти” |
| Cacheable query | «Можно ли переиспользовать результат между транзакциями?» | между транзакциями (при наличии L2/query cache) | invalidation, свежесть, и часто зависимость от второго уровня |
Эта таблица важна тем, что она убирает главный методический обман: read-only и query cache — не «более быстрые managed сущности», а ответы на другие вопросы.
2. Сценарий чтения из Commerce Lab
Чтобы сравнение было честным, нам нужен один и тот же сценарий чтения. Возьмём максимально жизненную штуку из Commerce Persistence Lab: список активных товаров для бэкофиса. Он показывается часто, параметры обычно повторяются, и в большинстве случаев это чтение без записи.
Это специально пограничный пример. Для shared/query cache чище начинать со Category и других read-mostly справочников, где данные меняются редко. Но на одном и том же списке Product проще честно сравнить managed, read-only и cacheable подходы без скачков между use case’ами.
Для такого списка удобно сразу держать рядом простую read-модель. Projection здесь не декоративна: она показывает, что для списков entity-семантика нужна далеко не всегда, а значит разговор про read-only и кэш имеет смысл только после выбора read-model.
package com.example.commerce.catalog.dto;
import java.math.BigDecimal;
/**
* Read-model для списка товаров: это DTO, не entity.
* Он не managed, не участвует в dirty checking и не имеет жизненного цикла сущности.
*/
public record ProductListRow(
Long id,
String sku,
String name,
BigDecimal amount
) {}
Здесь ProductListRow — просто «строчка таблицы». Она не managed, не участвует в dirty checking и не имеет жизненного цикла сущности. И это важная часть сравнения: read-only и query cache не должны заставлять вас забывать про вопрос «а entity вообще нужна?».
3. Вариант A: managed как baseline
Начать сравнение всегда полезно с baseline — не потому что он «лучший», а потому что он самый понятный и самый распространённый. Managed-загрузка — это нормальная работа Hibernate: вы получили сущности, Hibernate создал snapshots, будет делать dirty checking, а если вы что-то поменяете — при flush/commit улетит UPDATE. Это не ошибка, это контракт. Ошибка начинается там, где мы используем этот контракт в read-only сценарии и удивляемся цене.
Ниже — простой baseline-метод, который возвращает список Product как managed entity. Он выглядит невинно, как кот, который ничего не ронял… пока вы не отвернулись.
import jakarta.persistence.EntityManager;
import java.util.List;
public class ProductCatalogQueries {
public List<Product> loadManaged(EntityManager em) {
// Baseline: возвращаем managed-сущности, которые попадут в persistence context
// и будут участвовать в dirty checking на flush/commit.
return em.createQuery("""
select p from Product p
where p.deleted = false
order by p.name
""", Product.class)
.getResultList();
}
}
Что важно в этом варианте:
Сущности будут managed. Это значит, что Hibernate будет считать их потенциально изменяемыми. В тяжёлых списках это даёт цену по памяти (snapshots) и по CPU (dirty checking на flush). И да, flush может случиться не только в конце метода, а по разным причинам (мы это уже проходили раньше в курсе).
Теперь — главный «сюрприз», из-за которого managed-загрузка в read-only сценариях иногда опасна. Если где-то в коде случайно (или «на минуточку») поменять сущность, вы получите запись в БД. Причём очень часто без save() — потому что dirty checking всё сделает сам.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void readListAndAccidentallyMutate(EntityManager em) {
// Сущность managed: изменение поля будет замечено на flush/commit через dirty checking.
Product p = em.find(Product.class, 1L);
// «Случайно» меняем состояние — этого достаточно, чтобы на commit улетел UPDATE.
p.setName(p.getName() + " "); // accidental update bait
// commit -> UPDATE product SET name = ...
}
И вот здесь появляется типичный производственный сюжет: «мы просто читали список, почему у нас внезапно UPDATE?». Ответ неприятно прост: потому что managed-entity — это write-capable объект, и Hibernate делает ровно то, что обещал.
Заметьте, что first-level cache в этом сценарии тоже работает, но он решает другую задачу. Если вы дважды внутри одной транзакции сделаете em.find(Product.class, 1L), вы получите тот же Java-объект. Но если вы дважды выполните JPQL-запрос списка, SQL вполне может уйти в БД дважды, даже если Hibernate «склеит» результат в те же managed instances. Это место, где многие ждут «магии», а получают нормальную инженерную реальность.
4. Вариант B: read-only загрузка
Теперь меняем не форму запроса, а отношение Hibernate к уже загруженным entity. Read-only здесь нужен не для уменьшения числа SELECT, а для снижения цены того же самого чтения через entity внутри текущей транзакции: Hibernate не обязан держать эти объекты как кандидатов на будущий UPDATE.
import jakarta.persistence.EntityManager;
import java.util.List;
public class ProductCatalogQueries {
public List<Product> loadReadOnly(EntityManager em) {
return em.createQuery("""
select p from Product p
where p.deleted = false
order by p.name
""", Product.class)
// Просим Hibernate считать загруженные сущности read-only в рамках этого запроса.
.setHint("org.hibernate.readOnly", true)
.getResultList();
}
}
На этом read-case эффект локальный и очень конкретный: SQL на сам список останется тем же, но исчезает лишняя «готовность к записи». Это удобно, когда entity-семантика всё ещё нужна, а запись — точно нет.
Если кто-то всё же дёрнет сеттер, поле в Java-объекте изменится, но read-only нельзя воспринимать как «ускоренный managed с правом потом сохранить». Для такого сценария вы либо остаетесь в managed-flow, либо вообще выходите в projection/DTO.
5. Вариант C: cacheable query
Если read-only — это оптимизация внутри одного unit of work, то query cache живёт уже между транзакциями. Здесь вопрос не в dirty checking, а в том, повторяется ли один и тот же read-case между разными вызовами сервиса. Поэтому cacheable-вариант ниже сознательно сделан через DTO: так проще увидеть reuse готового read-result между транзакциями, не завязывая демонстрацию на догрузку managed-сущностей.
И ещё один важный фильтр: список Product здесь остаётся учебным пограничным примером ради непрерывности сравнения. Самым чистым кандидатом на shared/query cache обычно будет Category и другой read-mostly справочник, где повторы частые, а инвалидаций мало.
import jakarta.persistence.EntityManager;
import java.util.List;
public class ProductCatalogQueries {
public List<ProductListRow> loadCacheableRows(EntityManager em) {
return em.createQuery("""
select new com.example.commerce.catalog.dto.ProductListRow(
p.id, p.sku, p.name, p.price.amount
)
from Product p
where p.deleted = false
order by p.name
""", ProductListRow.class)
// Разрешаем Hibernate кэшировать результат запроса, если query cache вообще включён.
.setHint("org.hibernate.cacheable", true)
// Явно задаём регион, чтобы это был отдельный cacheable read-case.
.setHint("org.hibernate.cacheRegion", "catalog.products.list")
.getResultList();
}
}
Сам .setHint("org.hibernate.cacheable", true) не включает механизм целиком. Query cache должен быть глобально включён, под ним нужен рабочий shared-cache provider с region factory, а для entity-query практический выигрыш обычно ещё и зависит от осмысленного L2. Ниже — только reminder-уровень для самих свойств, не полный setup:
spring:
jpa:
properties:
# Включает сам механизм query cache.
hibernate.cache.use_query_cache: true
# Для entity-query обычно нужен и рабочий L2; одних этих свойств мало без provider/region factory.
hibernate.cache.use_second_level_cache: true
То есть cacheable query отвечает на вопрос «можно ли переиспользовать уже посчитанный read-result между транзакциями?», а не на вопрос «можно ли теперь не думать о SQL, invalidation и свежести данных».
6. Сравнение в SQL и statistics
Сравнение в Hibernate почти всегда упирается в наблюдаемость. Если вы сравниваете «по ощущениям», победит тот вариант, который вы сделали последним (потому что он “свежий в голове”). Нам нужно сравнение, которое можно подтвердить: SQL-логом и/или Hibernate statistics (если они включены профилем stats).
Удобная методика для сравнения выглядит так: выполнить один и тот же use case дважды, но в разных транзакциях, потому что именно на этом месте проявляется разница между «внутритранзакционными» и «межтранзакционными» механизмами. Для этого в лабораторном проекте удобно использовать TransactionTemplate, чтобы явно создать два unit of work.
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionTemplate;
@Component
public class CatalogReadComparisonRunner {
private final TransactionTemplate tx;
private final CatalogReadFacade facade;
public CatalogReadComparisonRunner(TransactionTemplate tx, CatalogReadFacade facade) {
this.tx = tx;
this.facade = facade;
}
public void runTwice() {
// Две разные транзакции: здесь и проявляется разница между read-only и query cache.
tx.execute(s -> { facade.loadCatalogManaged(); return null; });
tx.execute(s -> { facade.loadCatalogManaged(); return null; });
}
}
Теперь — какие ожидания разумны.
В managed варианте вы почти наверняка увидите, что оба раза SQL-запрос выполняется. Это нормально: first-level cache не кэширует результат JPQL-запроса как «список строк», он обеспечивает identity map для сущностей. А ещё вы знаете, что если кто-то внутри этого чтения тронет entity — можно получить UPDATE на commit.
В read-only варианте вы тоже увидите SQL-запрос оба раза, потому что read-only не про «не выполнять SQL», а про «не делать сущности дорогими для отслеживания изменений». Зато у вас исчезает часть “write overhead”, и сильно уменьшается риск accidental update (но появляется дисциплина: не рассчитывайте на запись).
В cacheable query варианте (при включённом механизме) второй прогон может не выполнить SQL на сам запрос, потому что результат будет взят из query cache. И вот здесь как раз важно смотреть не только на «есть ли SQL», но и на то, что именно возвращается: DTO/projection или entity. Если DTO — шанс увидеть “0 SQL” во второй транзакции выше. Если entity — всё упирается в наличие второго уровня кэша сущностей.
Чтобы дать себе быстрый индикатор, можно вывести пару статистических счётчиков. В реальном проекте у вас, вероятно, уже есть lab-support утилита, но даже голый вывод из Statistics полезен.
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
public class StatsView {
public void print(SessionFactory sf) {
// Важно: статистика должна быть включена настройками, иначе числа будут нулевыми/бесполезными.
Statistics st = sf.getStatistics();
// Сколько раз реально исполнялись запросы (в терминах Hibernate statistics).
System.out.println("Query executions = " + st.getQueryExecutionCount()); // например: 2
// Сколько сущностей загрузили (для DTO/projection часто будет 0).
System.out.println("Entity load count = " + st.getEntityLoadCount()); // например: 0 для DTO
}
}
Да, это грубые показатели, но они отлично дисциплинируют мышление: вы перестаёте спорить словами и начинаете спорить числами. Hibernate обычно выигрывает именно тогда, когда с ним разговаривают цифрами, а не эмоциями.
Для закрепления удобно свести сравнение в таблицу «что именно мы экономим»:
| Вариант | SQL на запрос | Snapshots / dirty checking | Межтранзакционный reuse |
|---|---|---|---|
| Managed entity | обычно да, каждый раз | да | нет |
| Read-only entity | обычно да, каждый раз | меньше / может быть выключено для этих сущностей | нет |
| Cacheable query (DTO) | 1-й раз да, 2-й раз может не быть | не относится (DTO) | да |
Мини-решатель выбора подхода
Хочется закончить лекцию не лозунгом «делайте read-only и кэш», а маленьким решателем, который можно применить в code review. Он очень простой: сначала вы выбираете форму чтения, потом — режим работы сессии, потом — кэширование. Если перепутать порядок, вы начнёте лечить симптомы, а не причины.
Ниже — схема, которая обычно спасает от типичного “cache-first мышления”:
flowchart TD
A["Нужна запись в рамках use case?"] -->|Да| M["Managed entity (обычный режим)"]
A -->|Нет| B["Нужна entity-семантика (lazy, навигация, доменные методы)?"]
B -->|Да| R["Read-only entity (hint или session default)"]
B -->|Нет| P["Projection/DTO как read-model"]
P --> C["Запрос повторяется часто с теми же параметрами?"]
C -->|Да| Q["Cacheable query + region (при включённом query cache)"]
C -->|Нет| N["Обычный запрос без кэша"]
Здесь важно, что query cache стоит после решения «entity или projection». Это не случайность, а дисциплина: если вы в списке грузите entity «просто потому что так проще», а потом пытаетесь закэшировать, вы часто кэшируете не то, что нужно, и платите за invalidation там, где проще было бы просто читать меньше колонок.
А read-only стоит именно там, где entity всё-таки нужна, но запись не нужна. Это тот случай, когда Hibernate можно “попросить не напрягаться” — и он, как приличный ORM, действительно не будет.
7. Типичные ошибки при работе с кэшем
Ошибка №1: включить cacheable query как лечение плохого SQL или N+1.
Это очень частый сюжет: запрос делает 50 SQL-операций из-за fetching-ошибки, а разработчик вместо исправления fetching пытается всё закэшировать. В результате становится сложнее, а иногда даже хуже: вы кэшируете неоптимальный запрос, платите за invalidation, и всё равно страдаете при промахах кэша. Правильная последовательность обратная: сначала нормальная форма чтения, потом локальные оптимизации, потом кэширование.
Ошибка №2: использовать read-only режим и потом удивляться, что данные не сохранились.
Read-only — это не «ускоренный managed», а режим, в котором Hibernate не обязан сохранять изменения. Если вы в середине метода решили “чуть-чуть поправить имя” и ожидаете UPDATE, вы сами нарушили контракт. В таких случаях обычно помогает разделение: один метод читает (read-only), другой меняет (managed).
Ошибка №3: думать, что @Transactional(readOnly = true) — это железобетонный запрет на запись.
В Spring это в первую очередь hint. Он может повлиять на flush mode и поведение провайдера, но это не «охранник с дубинкой», который выбьет из рук любой UPDATE. За реальный запрет отвечает архитектура (разделение use cases) и дисциплина кода, а не одна аннотация.
Ошибка №4: ожидать, что query cache даст эффект без повторяемости параметров.
Query cache кэширует результат запроса с конкретными параметрами. Если у вас каждый запрос отличается (разные фильтры, динамические order by, разные страницы), кэш будет почти всегда промахиваться, а вы будете платить за его поддержку. Это не баг, это неправильный кандидат.
Ошибка №5: кэшировать entity query и забыть, что без second-level cache сущностей вы всё равно можете увидеть SQL.
Это самая коварная ловушка: «я включил query cache, почему всё равно есть запросы?». Потому что query cache часто хранит список идентификаторов, а дальше Hibernate должен получить состояние сущностей. Если сущности не кэшируются вторым уровнем, он пойдёт в БД. Поэтому для демонстраций и для многих read-case’ов честнее кэшировать DTO/projection, а entity-кэширование держать как отдельное, осторожное решение.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ