💾 Раздел 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/TТI)
Провайдеры Встроен в 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 считает, что нативный запрос мог изменить любую таблицу в базе данных, и полностью очищает (in群众 / 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» — По умолчанию выключен, требует явной конфигурации регионального провайдера.

Связанные темы