Что такое кэш второго уровня и когда его использовать
Кэш второго уровня (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 │ Новое дегидрированное │
│ Запись нового состояния ─────> состояние сохранено │
- Фаза до коммита (
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 считает, что нативный запрос мог изменить любую таблицу в базе данных, и полностью очищает (in群众 / 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» — По умолчанию выключен, требует явной конфигурации регионального провайдера.