Що таке кеш другого рівня і коли його використовувати
Кеш другого рівня (Second-Level Cache, L2) — це спільний міжпотоковий кеш рівня фабрики сесій (EntityManagerFactory у JPA або SessionFactory у Hibernate). На відміну від транзак...
Кеш другого рівня (Second-Level Cache, L2) — це спільний міжпотоковий кеш рівня фабрики сесій (EntityManagerFactory у JPA або SessionFactory у Hibernate). На відміну від транзакційного кешу L1, кеш L2 зберігає стан сутностей між незалежними сесіями, транзакціями та HTTP-запитами, суттєво знижуючи навантаження на базу даних при операціях частого читання даних, що рідко змінюються.
🟢 Junior Level
Що таке L2 Cache і чим він відрізняється від L1
Кеш першого рівня прив’язаний до одного EntityManager і зникає при закритті сесії. Якщо 1 000 користувачів одночасно запитають одну й ту саму категорію товарів, без кешу другого рівня Hibernate виконає 1 000 однакових запитів SELECT до бази даних.
Кеш L2 вирішує цю проблему: перша сесія завантажує дані з БД і розміщує їх у спільному L2-кеші; наступні 999 сесій читають дані з оперативної пам’яті без жодного звернення до СУБД.
АРХІТЕКТУРА L1 ТА L2 КЕШІВ
[ Session 1 (Thread 1) ] [ Session 2 (Thread 2) ]
┌──────────────────────┐ ┌──────────────────────┐
│ L1 Cache (Map) │ │ L1 Cache (Map) │
└──────────┬───────────┘ └──────────┬───────────┘
│ (Промах L1) │ (Промах L1)
└───────────────┐ ┌──────────────┘
▼ ▼
┌───────────────────────────────────┐
│ L2 Cache (SessionFactory) │
│ (Ehcache, Hazelcast, Infinispan)│
│ User#1 -> ["Іван", "ivan@ya.ru"]│
└─────────────────┬─────────────────┘
│ (Промах L2)
▼
[ База даних (SQL RDBMS) ]
Порівняльна таблиця: L1 vs L2
| Характеристика | Кеш першого рівня (L1) | Кеш другого рівня (L2) |
|---|---|---|
| Область видимості (Scope) | EntityManager / Session (1 потік) |
EntityManagerFactory / SessionFactory (вся JVM / кластер) |
| Статус за замовчуванням | ✅ Увімкнений завжди, невідключаємий | ❌ Вимкнений, вимагає явного підключення провайдера |
| Що зберігається | Живі Java-об’єкти (Managed Entities) | Дегідровані масиви даних (Dehydrated State) |
| Час життя | До закриття сесії або транзакції | Протягом усієї роботи застосунку (або за TTL/TTI) |
| Провайдери | Вбудований у Hibernate Core | Ehcache, Hazelcast, Infinispan, Redis (Redisson) |
Коли L2 Cache необхідний, а коли шкідливий
✅ Ідеально підходить:
- Довідкові дані, що рідко змінюються: країни, валюти, категорії, тарифні плани, системні налаштування.
- Сутності зі співвідношенням «Читання до Запису» не менше 80/20 або 90/10.
- Читання сутностей за первинним ключем (
find(),findById()).
❌ Категорично не підходить:
- Дані, що часто змінюються: фінансові проводки, баланси гаманців, кошики покупців, лічильники лайків.
- Дані, що оновлюються зовнішніми системами в обхід Hibernate (сторонні сервіси, сирі JDBC скрипти, ETL-процеси) — кеш неминуче стане неузгодженим.
- Великі обсяги даних, які рідко запитуються (викличе переповнення пам’яті JVM).
🟡 Middle Level
Що фізично зберігається в L2 Cache: «Дегідрований» стан
У L2 кеші НЕ зберігаються живі Java-об’єкти сутностей. Якби L2 зберігав самі посилання, зміна об’єкта одним потоком моментально призвела б до стану гонитви (Race Condition) та порушення потокобезпеки.
Hibernate поміщає в L2 кеш дегідрований стан (Dehydrated State):
- Плоский масив примітивів, рядків та зовнішніх ключів:
Object[] { "Іван", 30, 100L }. - Номер версії (
@Version). - Коли Сесія 2 робить
find(User.class, 1L), Hibernate витягує масив з L2, створює новий чистий екземплярUserу купі JVM поточної сесії, гідрує (заповнює) його поля і поміщає в L1 кеш Сесії 2.
Стратегії паралельного доступу (CacheConcurrencyStrategy)
При налаштуванні сутності анотацією @org.hibernate.annotations.Cache вибирається стратегія конкурентності:
@Entity
@Table(name = "countries")
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
public class Country {
@Id
private Long id;
private String name;
}
| Стратегія | Опис | Застосування |
|---|---|---|
READ_ONLY |
Дані ніколи не оновлюються. При спробі змінити сутність Hibernate викидає виняток. Найшвидша стратегія без блокувань. | Довідники, валюти, країни, переліки в БД. |
NONSTRICT_READ_WRITE |
Допускає рідкісні зміни. Не використовує блокування. Є ризик короткочасного читання застарілих даних при паралельних транзакціях. | Дані, де допустима eventual consistency і немає критичності до мілісекундних розсинхронізацій. |
READ_WRITE |
Забезпечує сувору ізоляцію Read Committed за допомогою механізму «м’яких блокувань» (Soft Locks). |
Бізнес-сутності з рідкісним записом, де неприпустиме читання «брудних» даних. |
TRANSACTIONAL |
Підтримує повну транзакційну узгодженість (JTA). Вимагає підтримки розподілених транзакцій (XA) провайдером. | Системи корпоративного рівня з розподіленим менеджером транзакцій (JBoss/WildFly). |
Кешування колекцій (Collection Caching)
Кешування батьківської сутності не кешує її дочірні зв’язки автоматично:
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Department {
@Id
private Long id;
// УВАГА: Без окремої анотації @Cache колекція НЕ потрапить у L2!
@OneToMany(mappedBy = "department")
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private List<Employee> employees = new ArrayList<>();
}
Нюанс колекцій: У L2 кеші для колекції зберігається лише список ідентифікаторів: [101, 102, 103]. Щоб колекція завантажилася без SQL, клас Employee також повинен бути позначений @Cacheable, інакше Hibernate виконає N+1 запитів для гідрації кожного співробітника за ID!
🔴 Senior Level
Механіка Soft Locks у стратегії READ_WRITE
Стратегія READ_WRITE реалізує патерн захисту від станів гонитви без накладення важких блокувань у самій БД:
ХРОНОЛОГІЯ SOFT LOCK ПРИ ОНОВЛЕННІ СУТНОСТІ:
Транзакція 1 (UPDATE) L2 Cache Region Транзакція 2 (SELECT)
│ │ │
│── 1. Початок flush() ─────────>│ │
│ Накладає Soft Lock │ Запис блокується │
│ (Entity -> LockEntry) │ (state: LOCKED) │
│ │ │
│── 2. SQL UPDATE в БД │ │
│ │ │
│ │<── 3. Спроба читання ──────────│
│ │ Виявлено Soft Lock! │
│ │ L2 повертає MISS │
│ │──> Читає напряму з БД ─────────│
│ │ (рядок заблоковано в БД │
│ │ або чекає коміту T1) │
│── 4. Коміт транзакції 1 ──────>│ │
│ Зняття Soft Lock │ Новий дегідрований │
│ Запис нового стану ──────────> стан збережено │
- Фаза до коміту (
lock): При зміні сутності Hibernate замінює її значення в L2 кеші на спеціальний маркер —SoftLock. - Паралельне читання: Якщо інша транзакція намагається прочитати сутність з L2 і бачить
SoftLock, вона розуміє, що дані в кеші застаріли, вважає це промахом (Cache Miss) і йде напряму в базу даних. - Фаза після коміту (
afterUpdate): Після успішної фіксації в БДSoftLockвидаляється, і в L2 кеш поміщається актуальний дегідрований стан. - У разі
Rollback:SoftLockпросто інвалідується (видаляється з кешу), змушуючи наступний запит піти в БД.
Інвалідація при Bulk (Масових) операціях
Один із головних архітектурних підводних каменів L2 кешу:
// Масове оновлення через JPQL / HQL:
int updated = entityManager.createQuery("UPDATE User u SET u.active = false WHERE u.lastLogin < :date")
.setParameter("date", threshold)
.executeUpdate();
Що відбувається всередині:
- Запит
executeUpdate()транслюється напряму в один SQLUPDATEу СУБД. - Hibernate не інстанціює сутності і не знає, рядки з якими конкретно первинними ключами були зачеплені в базі даних.
- Щоб запобігти віддачі застарілих даних, Hibernate змушений повністю скинути (евіктити) весь регіональний кеш сутності
User(cache.evictEntityRegion(User.class)). - Наслідок: Будь-які масові операції HQL/JPQL обнуляють hit ratio відповідного регіону L2 кешу.
Взаємодія L2 Cache та Query Cache
Query Cache (кеш запитів) кешує не сутності, а результат запиту (список первинних ключів):
- Ключ:
Хеш(текст SQL/HQL + параметри запиту). - Значення:
List<Long> { 1, 5, 23 }.
Коли виконується кешований запит:
- Hibernate бере список ID з Query Cache:
[1, 5, 23]. - Далі для кожного ID він шукає дегідрований стан у L2 Cache.
- Катастрофа без L2 кешу: Якщо Query Cache увімкнений, а сама сутність у L2 не закешована, Hibernate отримає список ID, а потім виконає N окремих SQL SELECT за первинним ключем (
WHERE id = ?), породжуючи жахливий N+1!
4 Tricky Questions
1. Що фізично зберігається в кеші другого рівня, і чому видача одного й того самого об’єкта з L2 двом паралельним сесіям не призводить до поломки Identity Guarantee (==)?
Відповідь:
- У L2 кеші зберігається не Java-об’єкт, а дегідрований масив примітивів та зовнішніх ключів (
Object[]). - Коли Сесія 1 і Сесія 2 запитують сутність з L2, Hibernate створює два абсолютно різних Java-об’єкти у купі JVM:
User@0x111для Сесії 1 таUser@0x222для Сесії 2. - Всередині кожної сесії гарантія ідентичності дотримується: у L1 Сесії 1 об’єкт завжди
0x111, а в Сесії 2 —0x222. Сесії ізольовані одна від одної і не впливають на чужі транзакції.
2. Чому стратегія CacheConcurrencyStrategy.READ_WRITE не підтримується в несинхронізованих кластерних кешах без явної координації?
Відповідь:
- Стратегія
READ_WRITEспирається на тимчасові «м’які блокування» (Soft Locks), які виставляються в момент фазиflush(). - Якщо кластер складається з кількох незалежних вузлів, для роботи soft locks потрібен розподілений менеджер блокувань (наприклад, у Hazelcast або Infinispan).
- За відсутності надійної кластерної координації вузол A виставить soft lock, а вузол B продовжить читати застарілі дані з локальної репліки, порушуючи ізоляцію
Read Committed.
3. Ми оновили запис у БД напряму через нативний SQL-запит entityManager.createNativeQuery("UPDATE users SET balance = 0 WHERE id = 1").executeUpdate(). Що станеться з L2 кешем?
Відповідь:
- При виконанні нативного SQL Hibernate не може розпарсити довільний діалект та структуру запиту.
- За замовчуванням Hibernate вважає, що нативний запит міг змінити будь-яку таблицю в базі даних, і повністю очищає (evictAll) весь кеш другого рівня для уникнення неузгодженості.
- Щоб захистити L2 кеш від тотальної інвалідації, необхідно явно вказати, яку саме таблицю зачіпає нативний запит, через
addSynchronizedEntityClass(User.class)абоaddSynchronizedQuerySpace("users").
4. Чому при використанні генератора первинних ключів GenerationType.IDENTITY кешування L2 не захищає від звернень до БД при збереженні нових сутностей?
Відповідь:
- Кеш L2 індексує дані виключно за первинним ключем (
EntityKey). - При
GenerationType.IDENTITYпервинний ключ генерується СУБД у момент реальної вставки (AUTO_INCREMENT/SERIAL). - Доти, доки рядок не вставлено в БД фізично, ID об’єкта дорівнює
null. Отже, Hibernate не може заздалегідь помістити об’єкт у L2 кеш і зобов’язаний виконати реальний мережевий SQLINSERT.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь (Elevator Pitch)
«Кеш другого рівня (L2) у Hibernate функціонує на рівні
SessionFactoryі розділяється між усіма сесіями та потоками застосунку. На відміну від L1, він зберігає не живі Java-посилання, а дегідровані масиви даних (Object[]), гідруючи нові копії сутностей для кожного потоку. L2 кеш ідеальний для довідників та read-mostly даних зі співвідношенням читання/запис від 80/20. Основні стратегії конкурентності:READ_ONLY(для незмінних даних) таREAD_WRITE(використовує soft locks для захисту від брудного читання). Критичний нюанс: використання Query Cache без L2 кешування самих сутностей призводить до важкої проблеми N+1».
Ключові метрики для Production-моніторингу
- L2 Cache Hit Ratio (
stats.getSecondLevelCacheHitCount() / (hit + miss)): цільовий показник для довідників > 0.90, для змінюваних сутностей > 0.70. - L2 Put Count vs Hit Count: якщо кількість розміщень (
Put) перевищує кількість влучань (Hit), кеш неефективний і витрачає ресурси даремно. - Cache Miss Count за регіонами: дозволяє виявити сутності з частою інвалідацією.
Червоні прапорці (Чого говорити НЕ МОЖНА)
- ❌ «У кеші L2 зберігаються ті самі посилання на Java-об’єкти, що й у L1» — Груба помилка багатопоточності. Зберігаються дегідровані масиви, для кожної сесії створюється свій екземпляр.
- ❌ «Я кешую сутності замовлень та платежів у L2 зі стратегією READ_ONLY» — Це призведе до відображення застарілих або помилкових даних користувачам.
- ❌ «Увімкнення Query Cache саме по собі кешує сутності» — Query Cache кешує лише списки ID; без L2 кешу він провокує N+1 запитів.
- ❌ «L2 кеш автоматично увімкнений у Spring Boot» — За замовчуванням вимкнений, вимагає явної конфігурації регіонального провайдера.