1. Два однакові товари в памʼяті
First-level cache у Hibernate потрібен не лише для швидкодії. Його головне завдання — забезпечити обʼєктну ідентичність у межах persistence context. Якщо про це забути, ORM швидко перетворюється на дивний набір збігів. Слово «кеш» у новачка майже автоматично викликає дві думки: «прискорення» і «стало складніше». Але в Hibernate first-level cache зʼявився не як турбокнопка, а як розвʼязання дуже практичної проблеми.
Після лекцій про find() і getReference() виникає цілком прикладне запитання: чи може один і той самий Product#1 у межах одного persistence context перетворитися на кілька конкуруючих копій? Якщо так, далі вже неможливо нормально міркувати ні про звʼязки, ні про зміни.
Уявіть, що в базі є рядок товару product(id=1, sku='SKU-001', name='Keyboard'). Якщо Hibernate в межах однієї операції, тобто одного контексту, створить два різні Java-обʼєкти, які обидва «про один і той самий рядок», ви дуже швидко опинитеся в дивній реальності. Ви зміните імʼя в першого обʼєкта, а далі в коді вам трапиться другий — і він «старий». Або ви порівняєте їх, покладете в колекцію, а потім раптом отримаєте дублікати. Або оновите звʼязок в одного, а другий про це не знатиме.
Саме для того, щоб такого не ставалося, Hibernate дотримується правила: у межах одного persistence context одному рядку БД має відповідати один managed-обʼєкт у памʼяті. На практиці це правило реалізується через first-level cache і патерн identity map.
First-level cache як частина persistence context
Коли говорять «first-level cache», новачки часто уявляють окремий компонент: «увімкнули кеш», «вимкнули кеш», «почистили кеш». Насправді first-level cache — це внутрішній вміст persistence context. Тобто це не додаток до Hibernate, а один із його механізмів — приблизно як печінка: окремо не живе, але сильно впливає на самопочуття.
Якщо сказати зовсім просто, persistence context — це робоча область, де Hibernate тримає керовані сутності й розвʼязує дві ключові задачі. По-перше, він має знати, які обʼєкти зараз managed. По-друге, він має забезпечувати унікальність обʼєкта для кожної сутності за ключем (тип + id). Саме ця карта відповідностей і є first-level cache.
Важливо одразу зафіксувати межу: first-level cache не є кешем «на весь застосунок». У Spring-застосунку з нормальними межами транзакції він зазвичай живе стільки ж, скільки триває транзакція — спеціально спрощуємо, щоб не йти в нетрі. Тому він ідеально підходить для сценарію «я читаю й працюю з даними в межах одного unit of work», але зовсім не зобовʼязаний допомагати вам памʼятати товари між різними HTTP-запитами.
3. Identity map: один рядок ↔ один обʼєкт
Слово «identity» тут не про паспорт і не про кризу самоусвідомлення, хоча в продакшені в сутностей буває і таке. У цьому випадку identity — це ідентичність сутності: конкретний тип і конкретний id. Патерн identity map означає, що в нас є структура даних, яка каже: «якщо ви запитуєте Product#1, ось його єдина managed-версія».
Практично це виглядає так: у Hibernate всередині контексту є щось на кшталт карти, дуже спрощено: ключ ("Product", 1) → значення productObjectInstance. Якщо ви знову просите Product з id=1, Hibernate спершу дивиться в цю карту. Якщо обʼєкт там уже є, вам повертають той самий екземпляр. Якщо його немає, Hibernate йде в БД, читає рядок, створює обʼєкт, кладе його в карту й повертає.
Це правило дає одразу дві переваги. Воно зменшує кількість повторних читань — так, це швидше, — але ще важливіше запобігає ситуації «дві конкуруючі версії однієї сутності в одній операції». Тоді ORM стає не лотереєю, а системою, де можна міркувати так: «у межах цієї транзакції ось цей Product — це ось цей обʼєкт, і він один».
4. Як find() використовує first-level cache
Після лекції про find() легко лишитися з відчуттям: «find() — це завжди SELECT». Це нормальна інтуїція, якщо мислити як SQL-розробник. Але Hibernate мислить як менеджер обʼєктів: «find() — це дай мені managed-представлення сутності з таким id, а вже потім вирішимо, чи треба йти в базу».
Щоб побачити це з інженерного боку, корисно тримати в голові просту послідовність дій. Спершу EntityManager, точніше Hibernate під капотом, перевіряє, чи є сутність з таким ключем у поточному persistence context. Якщо є — повертає її одразу. Якщо ні — виконує SELECT, матеріалізує обʼєкт і реєструє його в контексті.
Нижче — спрощена блок-схема. Вона не показує всіх нюансів Hibernate 7.2, але добре передає головну думку: first-level cache — це «перша зупинка» на маршруті find().
flowchart TD
A["entityManager.find(Product.class, 1L)"] --> B{"Product#1 вже в persistence context?"}
B -- "Так" --> C["Повернути той самий managed-обʼєкт (без нового SELECT)"]
B -- "Ні" --> D["SELECT ... FROM product WHERE id=1"]
D --> E["Створити Java-обʼєкт Product"]
E --> F["Покласти Product#1 у first-level cache (identity map)"]
F --> G["Повернути managed-обʼєкт"]
І тут важливо зрозуміти одну просту річ: вам не потрібно вгадувати поведінку. Можна відкрити SQL trace і побачити, що другий find() не породив нового запиту. Це не «Hibernate вирішив заощадити», а «Hibernate виконує свій базовий обовʼязок».
5. Повторний find() в одній транзакції
Зараз ми зробимо невеликий експеримент на матеріалі нашого навчального проєкту Commerce Persistence Lab. Ідея проста: у межах однієї транзакції двічі запросити той самий Product за одним і тим самим id та перевірити дві речі. Перша — скільки SQL справді пішло. Друга — чи це один і той самий обʼєкт у памʼяті.
Для прикладу візьмемо сервіс, який явно робить два find(). Зверніть увагу: тут важлива одна транзакція, інакше у вас буде два різні persistence context, і ви не побачите identity map у дії.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogIdentityMapDemoService {
private final EntityManager entityManager;
public CatalogIdentityMapDemoService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional // Важливо: один persistence context для всього методу
public void demoRepeatedFind() {
// Перший find(): якщо Product#1 ще не managed, Hibernate виконає SELECT і покладе обʼєкт у L1
var p1 = entityManager.find(Product.class, 1L);
// Другий find() у тому самому контексті: Hibernate поверне той самий екземпляр з identity map
var p2 = entityManager.find(Product.class, 1L);
// Перевіряємо саме посилальну ідентичність: це має бути один і той самий обʼєкт у памʼяті
System.out.println(p1 == p2); // true
}
}
Порівняння p1 == p2 тут навмисно максимально просте. Ми зараз не обговорюємо equals() — це окрема велика історія. Нас цікавить саме посилальна ідентичність: чи є p2 тим самим обʼєктом, що й p1. У нормальній поведінці Hibernate в межах одного контексту — так.
У SQL trace ви, як правило, побачите один SELECT. Другий find() поверне обʼєкт із first-level cache. І це не «оптимізація», а фундамент ORM-моделі: Hibernate не має плодити копії однієї й тієї самої сутності в межах одного unit of work.
Якщо хочеться швидко перевірити, що обʼєкт справді живе в поточному context, достатньо contains().
import jakarta.persistence.EntityManager;
// Якщо обʼєкт managed, значить він перебуває в поточному persistence context (і, відповідно, у L1)
var p = entityManager.find(Product.class, 1L);
System.out.println(entityManager.contains(p)); // true
Цього вже достатньо, щоб не плутати first-level cache з будь-якими обʼєктами, створеними через new.
6. getReference() і find(): одна identity
У попередній лекції ми побачили, що getReference() часто повертає proxy — «посилання-замінник», яке знає id, але не зобовʼязане одразу мати завантажені поля. Тепер додамо важливе уточнення: proxy теж живе всередині identity map. Тобто якщо ви спершу отримали proxy, а потім викликали find(), Hibernate зазвичай не створить другий обʼєкт «на той самий рядок», а намагатиметься використати той самий екземпляр.
Подивімося на короткий приклад. Тут є акуратна пастка: спершу ми створюємо посилання через getReference(), потім порівнюємо, а потім звертаємося до поля, щоб побачити, що читання може статися в момент доступу, а не в момент отримання посилання.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional // В одному persistence context proxy й entity "зводяться" до однієї identity
public void demoReferenceAndFind(EntityManager entityManager) {
// getReference(): зазвичай не робить SELECT, але реєструє proxy як managed-представлення сутності
var ref = entityManager.getReference(Product.class, 1L);
// find(): якщо в контексті вже є managed-репрезентація (включно з proxy), новий обʼєкт не створюється
var ent = entityManager.find(Product.class, 1L);
// У межах одного контексту це зазвичай один і той самий екземпляр (proxy або вже ініціалізована сутність)
System.out.println(ref == ent); // true
// Доступ до поля може ініціювати SELECT: це вже лінива ініціалізація proxy
System.out.println(ref.getName());
}
Тут важливо не заплутатися. getReference() може не робити SELECT, але повернений обʼєкт усе одно стає managed у тому сенсі, що він «у контексті» і відомий Hibernate. Якщо потім ви викликаєте find() для того самого ключа, Hibernate намагається не створювати конкуруючу копію. Він повертає ту саму managed-репрезентацію.
А коли ви викликаєте ref.getName(), proxy розуміє, що йому потрібні дані, і може ініціювати читання. Це вже наслідок proxy-моделі, але identity map тут виконує роль «одного паспорта на одну сутність»: яким би шляхом ви не прийшли — через find чи getReference — у межах контексту ідентичність лишається єдиною.
7. Межі first-level cache: не кеш застосунку і не кеш запитів
Дуже хочеться, особливо після кількох вдалих демо, почати сприймати first-level cache як універсальний прискорювач: «Hibernate ж кешує — отже бази нам майже не потрібно». На жаль, база все ще потрібна, і кава теж. First-level cache розвʼязує цілком конкретну задачу: унікальність managed-обʼєкта за identity в межах контексту. Решта — побічні бонуси або обмеження.
Щоб не переплутати різні рівні «кешування», корисно зафіксувати невелику таблицю. Інші рівні ми згадуємо тут лише як орієнтир, без заглиблення: наша мета сьогодні не продуктивність узагалі, а коректна модель того, як це працює.
| «Кеш» | Де живе | Скільки живе | Що зберігає | Головна мета |
|---|---|---|---|---|
| First-level cache | всередині persistence context (EntityManager / Session) | зазвичай у межах транзакції | managed-обʼєкти за ключем (тип + id) | identity map і консистентність роботи з обʼєктами |
| Second-level cache | на рівні фабрики сесій (грубо: «на застосунок») | між транзакціями | дані сутностей/колекцій (залежно від налаштування) | повторні читання між різними unit of work |
| Query cache | окремий механізм | між транзакціями | результати запитів | прискорення повторюваних однакових запитів |
Ключовий висновок: first-level cache не призначений для «повторного використання даних між запитами користувача». Він також не є «кешем за будь-яких умов запиту». Якщо ви виконуєте JPQL-запит «знайти всі товари зі статусом ACTIVE», Hibernate не зобовʼязаний кешувати сам результат списку на рівні L1. Але якщо внаслідок цього запиту він завантажив Product#1, а потім ви знову попросили find(Product.class, 1), то Product#1 уже буде в контексті — і це знову identity map у дії.
І ще одна межа, яка особливо часто збиває новачків: якщо ви очікуєте, що повторний find() «прочитає свіжі дані з бази», то first-level cache працює якраз навпаки. Він каже: «у нас уже є managed-версія, працюємо з нею». Тому поруч із identity map завжди потрібні й явні операції керування контекстом: інколи обʼєкт треба відʼєднати, інколи контекст — очистити, а інколи дані — перечитати заново.
8. Кейс: Customer і PurchaseOrder
Абстрактні приклади з find() по одному id хороші, але справжня цінність identity map розкривається, коли у вас зʼявляється граф обʼєктів. У нашому проєкті це відбувається одразу: PurchaseOrder посилається на Customer. У реляційному світі це зовнішній ключ purchase_order.customer_id. В обʼєктному — посилання order.getCustomer().
З точки зору Hibernate, «customer замовлення» — це та сама сутність Customer#10, умовно. І в межах одного контексту правило identity map означає: якщо Customer#10 уже managed, то посилання в PurchaseOrder має вказувати на ту саму managed-репрезентацію, а не на «другого такого самого клієнта».
Подивімося на простий сценарій: спершу читаємо клієнта, потім читаємо замовлення, а потім порівнюємо обʼєкт клієнта і те, що лежить у посиланні замовлення. У навчальному проєкті це дуже зручний спосіб відчути, що identity map — це не лише про «менше SQL», а й про «нормальну обʼєктну модель».
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional // Обидва find() виконуються в одному persistence context
public void demoOrderCustomerIdentity(EntityManager entityManager) {
// Спершу завантажуємо Customer і робимо його managed
var customer = entityManager.find(Customer.class, 10L);
// Потім завантажуємо замовлення; посилання order.getCustomer() має вказувати на ту саму identity
var order = entityManager.find(PurchaseOrder.class, 100L);
// Перевіряємо, що Hibernate не створив "другий Customer#10" у межах одного контексту
System.out.println(order.getCustomer() == customer); // зазвичай true
}
Навіть якщо order.getCustomer() спочатку представлений proxy-обʼєктом через lazy-стратегію, Hibernate все одно має тримати єдину identity в межах контексту. Це допомагає уникнути ситуацій, коли у вас «два різні Customer з одним id» в межах однієї операції. Якби такі дублікати зʼявлялися, вам було б неможливо впевнено міркувати про звʼязки та зміни графа: код перетворився б на болото.
І тут можна зробити невелике спостереження за SQL trace: залежно від того, як саме завантажується Customer — одразу чи через proxy, — кількість запитів може відрізнятися, але принцип identity map зберігається. Тобто ви можете експериментувати з порядком операцій і бачити: обʼєкти лишаються єдиними в межах контексту.
9. Типові помилки під час роботи з first-level cache і identity map
Помилки навколо first-level cache зазвичай не виглядають як «застосунок упав». Вони небезпечніші: застосунок працює, але поводиться «дивно», і розробник починає підозрювати змову ORM. Насправді це майже завжди конфлікт між очікуваннями — «кожен find знову читає базу» — і реальною моделлю Hibernate — «у межах контексту сутність єдина і вже відома». Давайте спокійно розберемо найчастіші граблі.
Помилка №1: сприймати first-level cache як глобальний кеш застосунку.
Дуже типовий сценарій мислення: «раз find() вдруге не робить SELECT, значить Hibernate все запамʼятав, і в наступному запиті користувача теж не буде SELECT». Але наступний запит майже завжди означає новий persistence context, а отже, і новий first-level cache. У підсумку людина дивується: «чому знову читає базу?» Тому що L1 — це памʼять конкретної операції, а не памʼять застосунку.
Помилка №2: очікувати, що повторний find() оновить дані “як у SQL”.
Якщо ви прочитали Product#1, а потім десь в іншій транзакції або в іншій сесії його оновили, повторний find() у тому самому контексті не зобовʼязаний «підхопити зміни». Він поверне той самий managed-обʼєкт. Це логічно для unit of work, але несподівано для мислення «я ж перечитав». Для таких ситуацій існують явні операції синхронізації, наприклад refresh, і очищення контексту, наприклад clear; без них повторний find() не зобовʼязаний поводитися як «сходи в базу ще раз».
Помилка №3: намагатися “побачити identity map” без єдиного контексту.
Якщо ви робите два читання в різних транзакціях або в різних методах, де фактично різні EntityManager/Session, ви отримаєте два обʼєкти, і це буде нормально. Новачки іноді перевіряють p1 == p2 у двох різних місцях і засмучуються, що бачать false. Identity map працює лише в межах одного persistence context, тому і для демонстрацій, і для реальних unit of work важлива правильна межа операції.
Помилка №4: плутати “кешування сутності” і “кешування запиту”.
First-level cache не означає, що «будь-який запит тепер безкоштовний». Він гарантує унікальність обʼєкта за id. Якщо ви робите запити, які повертають багато різних сутностей, Hibernate все одно ходитиме в базу. Ба більше, якщо запит повертає тисячу рядків, у контексті зʼявиться тисяча managed-обʼєктів — і це вже питання дисципліни роботи з контекстом. Ми поки що не йдемо в цей бік, але важливо не будувати очікування в дусі «Hibernate все закешував».
Помилка №5: робити висновки про поведінку ORM “за відчуттями”, ігноруючи SQL trace.
Найпрактичніша помилка: «мені здається, Hibernate сходив у базу» або «мені здається, він не сходив». Не потрібно гадати. У нашому курсі SQL trace увімкнено саме для того, щоб ви могли відкрити лог і побачити: чи був SELECT, чи був він один і де саме він стався. First-level cache — не магія, а механізм, який чудово спостерігається через логування.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ