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-кешування тримати як окреме, обережне рішення.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ