1. Обмеження L1 і роль кешу L2
Якщо ви чесно вимкнули OSIV (spring.jpa.open-in-view=false) і тримаєте транзакції на рівні сервісу, то кожен новий бізнес-виклик найчастіше створює новий persistence context. Це добре для архітектури та передбачуваності, але має побічний ефект: навіть якщо ви пʼять разів поспіль читаєте ту саму Category «в різних запитах», Hibernate щоразу починатиме з чистого аркуша. Саме в цей момент у розробника зʼявляється спокуса сказати: «А можна якось зробити так, щоб не ходити до бази знову і знову?». Second-level cache (L2 cache) — це якраз спроба дати Hibernate «памʼять» між різними сесіями, але зробити це акуратно й керовано.
Уявіть, що в Commerce Persistence Lab у вас є довідник категорій (Category) і він змінюється раз на тиждень, а читається сотні разів на день. У межах однієї транзакції first-level cache допоможе лише тоді, коли ви повторно звертаєтеся до тієї самої сутності за id. Але якщо у вас два різні виклики сервісу (дві різні транзакції) — first-level cache вже не бере участі, бо він живе рівно стільки, скільки живе EntityManager/Session.
Second-level cache розвʼязує саме це завдання: він живе над сесіями, на рівні SessionFactory (грубо кажучи, на рівні застосунку), і може віддавати дані для entity між різними unit of work. Це не означає, що Hibernate перестане ходити до бази. Це означає, що в деяких сценаріях читання він зможе відповісти без SQL, якщо дані вже є в L2 і вони вважаються актуальними за правилами кешу.
2. Де живе кеш L2
Із second-level cache найчастіше проблема не в API, а в картинці в голові. Якщо mental model неправильна, кеш перетворюється або на «магічну кнопку прискорення», або на джерело незрозумілих багів («чому я оновив рядок, а застосунок читає старе?»). Нам потрібен спокійний, інженерний погляд: кеш L2 — це шар між Hibernate і базою даних, який може брати участь у читанні й іноді в записі, але не скасовує ані транзакції, ані persistence context, ані нормальний SQL.
Давайте намалюємо спрощений ланцюжок. Він не претендує на 100% усіх деталей, але добре фіксує головне:
flowchart TD
A["Метод сервісу (@Transactional)"] --> B["EntityManager / Session"]
B --> C{"У кеші L1 (persistence context) є керований екземпляр?"}
C -->|Так| D["Повернути той самий Java-обʼєкт (без SQL)"]
C -->|Ні| E{"У спільному кеші L2 є стан entity?"}
E -->|Так| F["Зібрати керовану entity з даних кешу"]
E -->|Ні| G["SQL у БД: SELECT ..."]
G --> H["Зберегти в L1 (і, можливо, в L2)"]
F --> H
H --> D
Ключовий момент тут: навіть якщо дані прийшли з L2, Hibernate все одно створює керований екземпляр і кладе його в L1 (persistence context). Тобто L2 — це не «повертаємо той самий обʼєкт, який був створений учора». Це радше «використовуємо збережений стан, щоб не виконувати SQL, але все одно працюємо в межах поточної сесії».
Ця різниця важлива, бо first-level cache відповідає за identity map і життєвий цикл керованих обʼєктів у межах транзакції, а second-level cache — за повторне використання даних між транзакціями. Це різні завдання, і якщо ви їх змішаєте, вийде приблизно як намагатися зберігати молоко в системному блоці компʼютера: технічно можливо, але запах буде інженерно несхвальний.
3. Що кешує кеш L2
Щоб кеш L2 не перетворився на фантазію в стилі «увімкнемо — і все стане швидко», потрібно чітко розуміти його межі. У базовій і найпоширенішій формі second-level cache працює як кеш сутностей за первинним ключем: Hibernate кешує стан entity, щоб під час повторного завантаження цієї entity за id в іншій сесії можна було не виконувати SQL.
Тут корисно порівняти first-level і second-level cache у вигляді таблиці. Вона коротка, але дуже приземляє:
| Властивість | First-level cache (L1) | Second-level cache (L2) |
|---|---|---|
| Де живе | всередині Session / EntityManager | на рівні SessionFactory (умовно «на весь застосунок») |
| Межа життя | один unit of work (зазвичай одна транзакція) | багато unit of work (багато транзакцій) |
| Головна мета | identity map + основа для dirty checking | повторне використання даних між сесіями |
| Що кешує | керовані обʼєкти в памʼяті (і їхні знімки стану) | стан entity (дані), а не Java-обʼєкт як такий |
| «Працює саме?» | так, завжди | ні, це усвідомлене рішення, і воно потребує інфраструктури |
Тепер про те, що L2 не зобовʼязаний робити. Він не обіцяє «закешувати будь-який JPQL-запит». Якщо ви робите запит «список товарів за статусом», SQL усе одно може виконуватися, бо це не «завантаження за id», а повноцінний запит. Без спеціальних механізмів Hibernate не має вгадувати, що «результат цього запиту можна взяти з кешу». Він може лише використовувати L2 як джерело даних для сутностей, які завантажуються за ключем.
Це ще одна причина, чому в курсі ми так багато говорили про модель читання й проекції. Дуже багато реальних «важких» читань — це не find-по-id, а списки, таблиці, звіти, where + order by + pagination. Кеш L2 у чистому вигляді допомагає далеко не в кожному сценарії читання.
Зате він може бути корисним для довідників, сутностей, що рідко змінюються, і повторюваних findById-читань. Типовий кандидат у нашому домені — Category (якщо ви читаєте її за id часто і змінюєте рідко). Більш спірний кандидат — Product, бо товар може змінюватися частіше (ціна, статус, soft delete), і ціна кешування може виявитися вищою за користь, особливо якщо у вас багато скидань кешу.
4. Свіжість і інвалідація L2
Коли розробник уперше чує «кеш між транзакціями», він зазвичай радіє. Коли вперше ловить застарілі дані — починає сумувати. І тут важлива чесна розмова: будь-який спільний кеш (а L2 — саме спільний кеш) платить за швидкість питаннями свіжості, інвалідації та експлуатаційної складності. Тому в інженерній практиці second-level cache — це не «увімкнено за замовчуванням», а інструмент, який вмикають під конкретний профіль навантаження і конкретні вимоги до актуальності даних.
Hibernate вміє інвалідовувати (скидати) записи кешу, коли він сам виконує UPDATE/DELETE для відповідної сутності. Це виглядає логічно: якщо Hibernate оновив Category, він може викинути або оновити її запис у L2. Але є нюанс, який документація Hibernate постійно підкреслює (і який реально спливає в продакшені): якщо дані змінюються не через Hibernate (наприклад, інший сервіс, інша мова, ручний SQL в адмінці, гарячі міграції), то кеш про це автоматично не дізнається.
І тут починається непросте запитання: «А наскільки мені взагалі можна кешувати ці дані?». Якщо у вас один сервіс і одна БД, і всі записи змінюються через той самий Hibernate, ситуація простіша. Якщо у вас кілька застосунків, які пишуть в одну схему, L2 cache стає небезпечнішим: він може віддавати трохи застарілий стан, і це вже не оптимізація продуктивності, а джерело багів.
У нашому навчальному проєкті ми спеціально не будуємо розподілену систему, але навіть у моноліті є типові причини застарівання. Наприклад, ви робили bulk update (вчорашня тема) або прямий SQL-апдейт у лабораторії. Bulk update може обходити керовані сутності та створювати розсинхрон не лише в first-level cache (stale persistence context), а й у «спільних» шарах. Тому загальна дисципліна звучить так: чим більше ви виходите за межі entity-oriented моделі (bulk, native SQL, зовнішні записи), тим обережніше потрібно ставитися до будь-якої форми кешування.
І саме тому в курсі ми постійно повторюємо просте правило: спочатку виправляйте fetching, самі запити й SQL, а вже потім розглядайте кеш як підсилювач. Кеш — це не замінник коректного design для читання, а механізм повторного використання вже добре спроєктованого читання.
5. CacheMode у Hibernate Session
Тепер — практична частина, яку корисно знати навіть тоді, коли кеш L2 у проєкті ще не ввімкнено. Hibernate дає змогу керувати тим, як поточна Session ставиться до кешу. Це робиться через CacheMode. І це дуже важлива ідея: кешування в Hibernate — це не «один раз увімкнули й забули». Ви можете для конкретного сценарію використання сказати: «читай із кешу, якщо вийде», або «ігноруй кеш», або «перечитай із бази й онови кеш».
Найприємніше: це можна показати маленькими й зрозумілими шматками коду, не налаштовуючи величезну інфраструктуру. У Commerce Persistence Lab ми часто працюємо через EntityManager, але Hibernate-специфічний API доступний через unwrap(Session.class).
Нижче — мінімальний приклад, де ми вмикаємо режим «GET». Він означає: можна читати з кешу, якщо там щось є. При цьому сам режим не гарантує успішного потрапляння в кеш: якщо кешу немає або він порожній, Hibernate все одно піде до БД.
import jakarta.persistence.EntityManager;
import org.hibernate.CacheMode;
import org.hibernate.Session;
// Дістаємо Hibernate Session із JPA EntityManager, щоб керувати кешем на рівні Hibernate
Session session = entityManager.unwrap(Session.class);
// Дозволяємо читання з L2 (якщо там є дані), але не змушуємо Hibernate "лише з кешу"
session.setCacheMode(CacheMode.GET);
// Під час успішного потрапляння в кеш SQL може не знадобитися, але керований екземпляр усе одно буде створений у поточній сесії (L1)
Product product = session.find(Product.class, 1L);
Корисна думка: CacheMode.GET — це хороший «типовий ментальний режим» для читань, де ви не проти кешу, але й не хочете керувати ним агресивно. Він не робить кеш обовʼязковим, він робить його дозволеним.
Тепер приклад протилежного режиму: IGNORE. Це корисно, коли ви робите діагностичний сценарій і хочете передбачувано побачити SQL, або коли підозрюєте, що кеш може віддавати застарілий стан, і свідомо хочете обійти його.
import jakarta.persistence.EntityManager;
import org.hibernate.CacheMode;
import org.hibernate.Session;
// Потрібна Hibernate Session, щоб задати CacheMode для конкретного unit of work
Session session = entityManager.unwrap(Session.class);
// Повністю ігноруємо L2 кеш: Hibernate працюватиме так, ніби спільного кешу не існує
session.setCacheMode(CacheMode.IGNORE);
// Зручно для діагностики: читання стає чеснішим і передбачуванішим (найімовірніше буде SQL)
PurchaseOrder order = session.find(PurchaseOrder.class, 10L);
І насамкінець режим, який часто сприймають як «анти-магію»: REFRESH. Ідея проста: «я не хочу читати з кешу, я хочу прочитати з бази й оновити кеш свіжими даними». Це може бути потрібно в адмінських сценаріях, після підозрілих оновлень або в тестах і лабораторіях, коли ви хочете явно синхронізувати кешований стан із реальною БД.
import jakarta.persistence.EntityManager;
import org.hibernate.CacheMode;
import org.hibernate.Session;
// Керуємо поведінкою кешу лише в межах поточної Session/транзакції
Session session = entityManager.unwrap(Session.class);
// Примушуємо читання з БД і оновлення кешованого стану (якщо L2 увімкнено й налаштовано)
session.setCacheMode(CacheMode.REFRESH);
// Корисно, коли потрібна гарантована свіжість: читаємо з БД і оновлюємо кешований стан
Product product = session.find(Product.class, 1L);
Щоб це все не перетворилося на кашу, зручно тримати маленьку таблицю — не як «зазубрити», а як «мати під рукою»:
| CacheMode | Як думати по-людськи | Коли доречно |
|---|---|---|
| GET | «Дозволяю використовувати кеш» | звичайні сценарії читання, де успішне потрапляння в кеш можливе і не небезпечне |
| IGNORE | «Кеш взагалі не чіпаємо» | діагностика, підозра на застарілі дані, бажання передбачуваного SQL |
| REFRESH | «Читаємо з БД, кеш оновлюємо» | сценарії, де потрібна свіжість, адміноперації, керована синхронізація |
Важливе уточнення: навіть коли дані прийшли з L2, вони все одно стають керованими в межах поточної сесії та беруть участь у її правилах. Тобто CacheMode не скасовує L1, він впливає на взаємодію з L2.
6. Кандидати на кеш: @Cacheable і region
Питання, яке виникає одразу: «Гаразд, а як Hibernate розуміє, які сутності можна тримати в другому рівні?». Відповідь — через комбінацію двох рішень. Перше рішення — ви позначаєте сутність як кандидата на кешування. Друге — ви (або ваша команда) обираєте, якою стратегією concurrency і в якому region це кешується. Важливо, що без провайдера (зовнішньої реалізації кешу) ця історія не запрацює по-справжньому, але як «читання коду» і «розуміння наміру» це потрібно знати вже зараз.
Для початку — найпростіший JPA-рівень: @Cacheable. Він каже: «цю сутність узагалі можна розглядати для спільного кешу». За замовчуванням багато проєктів дотримуються підходу «кешування вибіркове», щоб випадково не закешувати все підряд.
Ось мініфрагмент, як це може виглядати на сутності Category у нашому проєкті:
import jakarta.persistence.Cacheable;
import jakarta.persistence.Entity;
@Entity
@Cacheable // JPA-сигнал: сутність можна розглядати кандидатом на другий рівень кешу (L2)
public class Category {
// ...
}
Тепер Hibernate-специфічний рівень: анотація @Cache, де зазвичай вказують стратегію і region. Стратегія — це про те, як кеш поводиться під час конкурентних змін. Region — це просто імʼя «коробки», куди складаються записи цього типу. Region важливий, щоб кеш був керованим: ви могли сказати «очистити кеш категорій», не чіпаючи все інше.
Приклад (короткий і показовий):
import jakarta.persistence.Cacheable;
import jakarta.persistence.Entity;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Cacheable // Дозволяємо спільний кеш на рівні JPA
@Cache(
usage = CacheConcurrencyStrategy.READ_WRITE, // Як кеш синхронізується під час конкурентних змін
region = "catalog.category" // Явне імʼя region, щоб потім можна було керувати ним і чистити вибірково
)
public class Category {
// ...
}
Чому READ_WRITE? У реальному житті це рішення залежить від того, наскільки сутність змінювана і наскільки вам важлива узгодженість. Для довідників часто підходить READ_ONLY або подібні підходи (якщо дані справді незмінні), але ми зараз не робимо з лекції курс зі стратегій провайдерів. Наша мета — побачити, що кешування — це не «увімкнув прапорець», а явне вираження наміру в коді: «ось цей тип даних можна повторно використовувати між транзакціями».
І ще один важливий нюанс, який новачки часто плутають. Якщо ви позначили entity як cacheable, це не означає, що будь-який запит до цієї таблиці перестане ходити до БД. Кеш найчастіше допомагає саме під час завантаження за id. Тому, якщо ваш сценарій — це список товарів через проекцію або складний JPQL, L2 cache може взагалі майже не брати участі. І це нормально: кеш не зобовʼязаний рятувати кожен сценарій, він допомагає там, де збіглися умови використання.
7. Скидання кешу: evict і дисципліна
Кеш без можливості скидання — це не кеш, а пастка. Навіть якщо ви все спроєктували ідеально, вам усе одно періодично потрібно вміти сказати: «викидаємо запис, хай Hibernate перечитає з бази». У JPA для цього є Cache API на рівні EntityManagerFactory. Hibernate також має свої способи, але JPA-варіант достатньо зрозумілий і корисний як базовий орієнтир.
У Spring Boot застосунку зручно тримати EntityManagerFactory (або SessionFactory) і виконувати точкові операції вилучення з кешу. Наприклад, ми можемо додати невеликий службовий компонент у com.example.commerce.common.jpa (або поруч, де у вас живуть подібні інфраструктурні штуки) і вміти скидати конкретний Product за id.
Мініприклад:
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceUnit;
@PersistenceUnit
private EntityManagerFactory entityManagerFactory; // Інжектуємо фабрику, щоб отримати доступ до JPA Cache (L2)
public void evictProduct(long productId) {
// Скидаємо рівно один запис: під час наступного читання Hibernate/провайдер кешу будуть змушені перечитати з БД
entityManagerFactory.getCache().evict(Product.class, productId);
}
Що це дає на практиці? У навчальній лабораторії — дуже корисний інструмент, щоб «скинути магію й подивитися реальність». У реальному застосунку — можливість примусово скинути кеш після адміністративних операцій, міграцій або підозрілих масових оновлень. Важливо при цьому не перетворити evict() на щоденний «пластир від усього»: якщо ви постійно очищаєте кеш, отже кеш або не потрібен, або ви кешуєте не те, або у вас некоректно описані сценарії актуальності даних.
Ще один варіант — повне очищення кешу. Це справді аварійна кнопка, яку хочеться використовувати рідко, бо вона може обнулити користь кешу і тимчасово збільшити навантаження на БД (після очищення все почне прогріватися заново).
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceUnit;
@PersistenceUnit
private EntityManagerFactory entityManagerFactory; // Доступ до shared cache на рівні застосунку
public void evictAll() {
// Аварійне очищення: скидаємо весь L2 кеш (далі буде "прогрів" і більше SQL протягом певного часу)
entityManagerFactory.getCache().evictAll();
}
Якщо коротко: evict — це не «костиль», а елемент дисципліни експлуатації кешу. Будь-який спільний кеш живе в реальному світі, де бувають ручні правки, незвичайні сценарії та потреба швидко відновити консистентність без перезапуску всього застосунку.
8. Типові помилки під час роботи з second-level cache
Помилка №1: плутати second-level cache із first-level cache і чекати, що він збереже identity обʼєкта.
Дуже часта ілюзія звучить так: «Якщо обʼєкт був у кеші вчора, отже сьогодні я отримаю той самий Java-екземпляр». Ні, так не працює. L2 зберігає дані (стан), а не ваше посилання на обʼєкт. У кожній новій сесії Hibernate все одно створить керований екземпляр, просто може наповнити його не з SQL, а з кешу. Якщо ви будуєте логіку на == між різними транзакціями — ви будуєте її на піску.
Помилка №2: вмикати L2 cache як «ліки від повільного запиту».
Якщо у вас гальмує список товарів через N+1, широкий SELECT або важкий JOIN, увімкнення другого рівня зазвичай не робить цей запит «здоровим». Часто він як ходив до БД, так і ходитиме. Кеш — підсилювач повторних читань, а не заміна нормальному проєктуванню запитів. Спочатку вирівнюємо fetching і SQL-профіль, і лише потім розглядаємо спільний кеш.
Помилка №3: кешувати сутності, що часто змінюються, і дивуватися нескінченній інвалідації.
Якщо сутність постійно оновлюється, кеш постійно скидатиметься або перебудовуватиметься. У підсумку ви платите складністю, а виграшу майже немає. Типовий симптом: ви гордо ввімкнули кеш на Product, а потім маєте масове переоцінювання, часті зміни статусу, soft delete, аудит — і кеш більшу частину часу або порожній, або постійно інвалідовується. Це не «Hibernate поганий», це неправильний кандидат.
Помилка №4: забути про зовнішні зміни даних і отримати застарілий стан.
Якщо в базу пишуть інші застосунки, ручні SQL-скрипти або «не той» механізм, кеш може віддавати старі значення, бо він не телепат. Hibernate інвалідовує кеш, коли сам змінює дані, але він не може знати про зміни «ззовні» без окремої інфраструктури. Тому перед кешуванням потрібно чесно відповісти: «Нас влаштовує така модель консистентності?».
Помилка №5: використовувати CacheMode як магічний перемикач, не розуміючи сценарію.
CacheMode.IGNORE корисний для діагностики, але якщо ви поставите його глобально «про всяк випадок», ви фактично вимкнете користь L2. CacheMode.REFRESH корисний для примусової свіжості, але якщо ви почнете застосовувати його без причини, ви постійно навантажуватимете базу й одночасно оновлюватимете кеш, перетворюючи кеш на зайву роботу. CacheMode має бути відповіддю на конкретний сценарій використання, а не на тривожність розробника.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ