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

Managed, read-only і query cache

Hibernate deep-dive
Рівень 25 , Лекція 4
Відкрита

1. Вступ

Якщо підійти до теми «оптимізації читання» як до вибору смаку морозива, легко потрапити в пастку: «мені подобається кеш, отже кешуємо». Але в Hibernate ці три режими відповідають на три різні запитання, і порівнювати їх потрібно саме так — через запитання, яке ви розвʼязуєте. Інакше ви отримаєте дуже характерний результат: складність зросте, а прискорення не зʼявиться (зате зʼявиться нова легенда: «Hibernate повільний, тому що…»).

Жодної нової магії тут не зʼявляється: ті самі вже знайомі механізми просто працюють на різних рівнях — всередині поточного persistence context, усередині поточної транзакції читання та між транзакціями.

Давайте сформулюємо ці три запитання максимально побутовою мовою:

1) Чи потрібно мені в межах поточного unit of work змінювати завантажені обʼєкти та зберігати зміни? Якщо так — вам потрібен звичайний managed-flow. Це не про швидкість, це про коректність і «право на запис».

2) Я точно не буду змінювати дані, але мені все одно зручніше читати через entity, а не через projection. Чи можна зробити читання дешевшим? Ось тут і зʼявляється read-only. Він не робить запит «без SQL», а робить сутності менш дорогими всередині поточної сесії.

3) Один і той самий запит виконується знову й знову в різних транзакціях (наприклад, на кожен HTTP-запит), і результат достатньо стабільний. Чи можна повторно використовувати результат між транзакціями? Це вже поле query cache (і/або second-level cache), тобто оптимізація рівня «між запитами застосунку».

Щоб закріпити різницю, зручно тримати в голові маленьку таблицю:

Підхід На яке запитання відповідає Межа дії ефекту Головна ціна або ризик
Managed entity «Чи можна змінювати та зберігати?» поточна транзакція / поточний persistence context знімки стану + dirty checking + ризик випадкових оновлень
Read-only entity «Чи можна читати дешевше всередині транзакції?» поточна транзакція / поточна сесія не можна очікувати збереження змін, легко заплутатися в «змінилося в памʼяті»
Cacheable query «Чи можна повторно використовувати результат між транзакціями?» між транзакціями (за наявності L2/query cache) інвалідація, актуальність і часто залежність від другого рівня

Ця таблиця важлива ще й тим, що прибирає головну методичну пастку: read-only і query cache — не «швидші managed-сутності», а відповіді на інші запитання.

2. Сценарій читання з Commerce Lab

Щоб порівняння було чесним, нам потрібен один і той самий сценарій читання. Візьмемо максимально життєвий приклад із Commerce Persistence Lab: список активних товарів для бекофісу. Він показується часто, параметри зазвичай повторюються, і в більшості випадків це читання без запису.

Це спеціально прикордонний приклад. Для shared/query cache чистіше починати з Category та інших read-mostly довідників, де дані змінюються рідко. Але на одному й тому самому списку Product простіше чесно порівняти managed, read-only і cacheable-підходи без стрибків між сценаріями.

Для такого списку зручно одразу тримати поруч просту 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. Це не помилка, це контракт. Помилка починається там, де ми використовуємо цей контракт у сценарії лише для читання та дивуємося ціні.

Нижче — простий baseline-метод, який повертає список Product як managed entity. Він виглядає невинно, як кіт, який нічого не скинув… поки ви не відвернулися.

import jakarta.persistence.EntityManager;
import java.util.List;

public class ProductCatalogQueries {

    public List<Product> loadManaged(EntityManager em) {
        // Базовий варіант: повертаємо 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() + " "); // пастка для випадкового оновлення

    // commit -> UPDATE product SET name = ...
}

І ось тут зʼявляється типова виробнича історія: «ми просто читали список, чому в нас раптом UPDATE?». Відповідь неприємно проста: тому що managed-entity — це обʼєкт, придатний до запису, і Hibernate робить рівно те, що обіцяв.

Зверніть увагу: кеш першого рівня в цьому сценарії теж працює, але він розвʼязує інше завдання. Якщо ви двічі в межах однієї транзакції викличете 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-семантика все ще потрібна, а запис — точно ні.

Якщо хтось усе ж смикне setter, поле в 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: так простіше побачити повторне використання готового 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-запитів практичний виграш зазвичай ще й залежить від осмисленого кешу другого рівня (L2). Нижче — лише нагадування щодо самих властивостей, не повне налаштування:

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, інвалідацію і актуальність даних?».

6. Порівняння в SQL і statistics

Порівняння в Hibernate майже завжди впирається в спостережуваність. Якщо ви порівнюєте «за відчуттями», переможе той варіант, який ви зробили останнім (бо він «свіжий у голові»). Нам потрібне порівняння, яке можна підтвердити: SQL-логом та/або Hibernate statistics (якщо вони увімкнені профілем stats).

Зручна методика для порівняння виглядає так: виконати один і той самий випадок використання двічі, але в різних транзакціях, тому що саме на цьому місці проявляється різниця між внутрішньотранзакційними та міжтранзакційними механізмами. Для цього в лабораторному проєкті зручно використовувати 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-запит виконується. Це нормально: кеш першого рівня не кешує результат JPQL-запиту як «список рядків», він забезпечує identity map для сутностей. А ще ви знаєте, що якщо хтось усередині цього читання торкнеться entity — на commit може бути виконано UPDATE.

У read-only варіанті ви теж побачите SQL-запит обидва рази, тому що read-only не про «не виконувати SQL», а про «не робити сутності дорогими для відстеження змін». Зате у вас зникає частина write overhead, і сильно зменшується ризик випадкового оновлення (але натомість потрібна дисципліна: не розраховуйте на запис).

У 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("Виконання запитів = " + st.getQueryExecutionCount()); // наприклад: 2

        // Скільки сутностей завантажили (для DTO/projection часто буде 0).
        System.out.println("Кількість завантажених сутностей = " + st.getEntityLoadCount());   // наприклад: 0 для DTO
    }
}

Так, це грубі показники, але вони чудово дисциплінують мислення: ви перестаєте сперечатися словами та починаєте сперечатися цифрами. Hibernate зазвичай виграє саме тоді, коли з ним говорять цифрами, а не емоціями.

Для закріплення зручно звести порівняння в таблицю «що саме ми економимо»:

Варіант SQL на запит Snapshots / dirty checking Повторне використання між транзакціями
Managed entity зазвичай так, щоразу так ні
Read-only entity зазвичай так, щоразу менше / може бути вимкнено для цих сутностей ні
Cacheable query (DTO) 1-й раз так, 2-й раз може не бути не стосується (DTO) так

Міні-розвʼязувач вибору підходу

Хочеться завершити лекцію не гаслом «робіть read-only і кеш», а маленьким розвʼязувачем, який можна застосувати під час code review. Він дуже простий: спочатку ви обираєте форму читання, потім — режим роботи сесії, потім — кешування. Якщо переплутати порядок, ви почнете лікувати симптоми, а не причини.

Нижче — схема, яка зазвичай рятує від типового cache-first мислення:

flowchart TD
    A["Потрібен запис у межах випадку використання?"] -->|Так| M["Managed entity (звичайний режим)"]
    A -->|Ні| B["Потрібна entity-семантика (lazy, навігація, доменні методи)?"]
    B -->|Так| R["Read-only entity (hint або налаштування сесії за замовчуванням)"]
    B -->|Ні| P["Projection/DTO як read-model"]
    P --> C["Запит часто повторюється з тими самими параметрами?"]
    C -->|Так| Q["Cacheable query + region (за увімкненого query cache)"]
    C -->|Ні| N["Звичайний запит без кешу"]

Тут важливо, що query cache стоїть після рішення «entity чи projection». Це не випадковість, а дисципліна: якщо ви в списку завантажуєте entity просто тому, що так простіше, а потім намагаєтеся закешувати результат, ви часто кешуєте не те, що потрібно, і платите за інвалідацію там, де було б простіше просто читати менше колонок.

А read-only стоїть саме там, де entity все ж потрібна, але запис не потрібен. Це той випадок, коли Hibernate можна «попросити не напружуватися» — і він, як пристойний ORM, справді не буде.

7. Типові помилки під час роботи з кешем

Помилка №1: увімкнути cacheable query як лікування поганого SQL або N+1.
Це дуже частий сюжет: запит робить 50 SQL-операцій через помилку fetching, а розробник замість виправлення fetching намагається все закешувати. У результаті стає складніше, а іноді й гірше: ви кешуєте неоптимальний запит, платите за інвалідацію і все одно страждаєте під час промахів кешу. Правильна послідовність зворотна: спочатку нормальна форма читання, потім локальні оптимізації, потім кешування.

Помилка №2: використовувати read-only режим і потім дивуватися, що дані не збереглися.
Read-only — це не «прискорений managed», а режим, у якому Hibernate не зобовʼязаний зберігати зміни. Якщо ви посеред методу вирішили «трішки підправити імʼя» і очікуєте UPDATE, ви самі порушили контракт. У таких випадках зазвичай допомагає розділення: один метод читає (read-only), інший змінює (managed).

Помилка №3: думати, що @Transactional(readOnly = true) — це залізобетонна заборона на запис.
У Spring це насамперед підказка. Вона може впливати на flush mode і поведінку провайдера, але це не «охоронець із кийком», який вибʼє з рук будь-який UPDATE. За реальну заборону відповідає архітектура (розділення use cases) і дисципліна коду, а не одна анотація.

Помилка №4: очікувати, що query cache дасть ефект без повторюваності параметрів.
Query cache кешує результат запиту з конкретними параметрами. Якщо у вас кожен запит відрізняється (різні фільтри, динамічний order by, різні сторінки), кеш майже завжди промахуватиметься, а ви платитимете за його підтримку. Це не баг, це неправильний кандидат.

Помилка №5: кешувати entity-запит і забути, що без second-level cache сутностей ви все одно можете побачити SQL.
Це найпідступніша пастка: «я увімкнув query cache, чому все одно є запити?». Тому що query cache часто зберігає список ідентифікаторів, а далі Hibernate має отримати стан сутностей. Якщо сутності не кешуються другим рівнем, він піде в БД. Тому для демонстрацій і для багатьох сценаріїв читання чесніше кешувати DTO/projection, а entity-кешування тримати як окреме, обережне рішення.

1
Опитування
Кеш Hibernate, рівень 25, лекція 4
Недоступний
Кеш Hibernate
Перший і другий кеш
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ