💾 Розділ 16 · Питання #10

Що таке кеш другого рівня і коли його використовувати

Кеш другого рівня (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             │ Новий дегідрований             │
        │   Запис нового стану ──────────> стан збережено                 │
  1. Фаза до коміту (lock): При зміні сутності Hibernate замінює її значення в L2 кеші на спеціальний маркер — SoftLock.
  2. Паралельне читання: Якщо інша транзакція намагається прочитати сутність з L2 і бачить SoftLock, вона розуміє, що дані в кеші застаріли, вважає це промахом (Cache Miss) і йде напряму в базу даних.
  3. Фаза після коміту (afterUpdate): Після успішної фіксації в БД SoftLock видаляється, і в L2 кеш поміщається актуальний дегідрований стан.
  4. У разі 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();

Що відбувається всередині:

  1. Запит executeUpdate() транслюється напряму в один SQL UPDATE у СУБД.
  2. Hibernate не інстанціює сутності і не знає, рядки з якими конкретно первинними ключами були зачеплені в базі даних.
  3. Щоб запобігти віддачі застарілих даних, Hibernate змушений повністю скинути (евіктити) весь регіональний кеш сутності User (cache.evictEntityRegion(User.class)).
  4. Наслідок: Будь-які масові операції HQL/JPQL обнуляють hit ratio відповідного регіону L2 кешу.

Взаємодія L2 Cache та Query Cache

Query Cache (кеш запитів) кешує не сутності, а результат запиту (список первинних ключів):

  • Ключ: Хеш(текст SQL/HQL + параметри запиту).
  • Значення: List<Long> { 1, 5, 23 }.

Коли виконується кешований запит:

  1. Hibernate бере список ID з Query Cache: [1, 5, 23].
  2. Далі для кожного ID він шукає дегідрований стан у L2 Cache.
  3. Катастрофа без L2 кешу: Якщо Query Cache увімкнений, а сама сутність у L2 не закешована, Hibernate отримає список ID, а потім виконає N окремих SQL SELECT за первинним ключем (WHERE id = ?), породжуючи жахливий N+1!

4 Tricky Questions

1. Що фізично зберігається в кеші другого рівня, і чому видача одного й того самого об’єкта з L2 двом паралельним сесіям не призводить до поломки Identity Guarantee (==)?

Відповідь:

  1. У L2 кеші зберігається не Java-об’єкт, а дегідрований масив примітивів та зовнішніх ключів (Object[]).
  2. Коли Сесія 1 і Сесія 2 запитують сутність з L2, Hibernate створює два абсолютно різних Java-об’єкти у купі JVM: User@0x111 для Сесії 1 та User@0x222 для Сесії 2.
  3. Всередині кожної сесії гарантія ідентичності дотримується: у L1 Сесії 1 об’єкт завжди 0x111, а в Сесії 2 — 0x222. Сесії ізольовані одна від одної і не впливають на чужі транзакції.

2. Чому стратегія CacheConcurrencyStrategy.READ_WRITE не підтримується в несинхронізованих кластерних кешах без явної координації?

Відповідь:

  1. Стратегія READ_WRITE спирається на тимчасові «м’які блокування» (Soft Locks), які виставляються в момент фази flush().
  2. Якщо кластер складається з кількох незалежних вузлів, для роботи soft locks потрібен розподілений менеджер блокувань (наприклад, у Hazelcast або Infinispan).
  3. За відсутності надійної кластерної координації вузол A виставить soft lock, а вузол B продовжить читати застарілі дані з локальної репліки, порушуючи ізоляцію Read Committed.

3. Ми оновили запис у БД напряму через нативний SQL-запит entityManager.createNativeQuery("UPDATE users SET balance = 0 WHERE id = 1").executeUpdate(). Що станеться з L2 кешем?

Відповідь:

  1. При виконанні нативного SQL Hibernate не може розпарсити довільний діалект та структуру запиту.
  2. За замовчуванням Hibernate вважає, що нативний запит міг змінити будь-яку таблицю в базі даних, і повністю очищає (evictAll) весь кеш другого рівня для уникнення неузгодженості.
  3. Щоб захистити L2 кеш від тотальної інвалідації, необхідно явно вказати, яку саме таблицю зачіпає нативний запит, через addSynchronizedEntityClass(User.class) або addSynchronizedQuerySpace("users").

4. Чому при використанні генератора первинних ключів GenerationType.IDENTITY кешування L2 не захищає від звернень до БД при збереженні нових сутностей?

Відповідь:

  1. Кеш L2 індексує дані виключно за первинним ключем (EntityKey).
  2. При GenerationType.IDENTITY первинний ключ генерується СУБД у момент реальної вставки (AUTO_INCREMENT / SERIAL).
  3. Доти, доки рядок не вставлено в БД фізично, ID об’єкта дорівнює null. Отже, Hibernate не може заздалегідь помістити об’єкт у L2 кеш і зобов’язаний виконати реальний мережевий SQL INSERT.

🎯 Шпаргалка для інтерв’ю

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» — За замовчуванням вимкнений, вимагає явної конфігурації регіонального провайдера.

Пов’язані теми