JavaRush /Курсы /Hibernate deep-dive /Managed, read-only и query cache

Managed, read-only и query cache

Hibernate deep-dive
25 уровень , 4 лекция
Открыта

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-кэширование держать как отдельное, осторожное решение.

1
Задача
Hibernate deep-dive, 25 уровень, 4 лекция
Недоступна
Managed против read-only в двух rollback-транзакциях
Managed против read-only в двух rollback-транзакциях
1
Задача
Hibernate deep-dive, 25 уровень, 4 лекция
Недоступна
Три read-стратегии на одном списке Category
Три read-стратегии на одном списке Category
1
Опрос
Кэш Hibernate, 25 уровень, 4 лекция
Недоступен
Кэш Hibernate
Первый и второй кэш
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ