Hibernate умеет хранить загруженные данные в памяти, чтобы не ходить в базу повторно. Это помогает — но только если понимать, какой именно кэш работает, где его границы и когда он может подвести.
Три хранилища на пути одного find(). Кэш первого уровня живёт внутри сессии и умирает вместе с ней; кэш второго уровня лежит в SessionFactory и сессии переживает — поэтому вторая сессия отвечает без запроса. Но наполняется он только тем, что прошло через Hibernate: изменение прямым SQL мимо него оставляет в кэше старое значение.
Кэш первого уровня — тот, что всегда включён
Кэш первого уровня — это сама Session (или EntityManager в терминах JPA). Пока сессия открыта, все загруженные сущности живут в её persistence context. Повторный find() по тому же идентификатору не пойдёт в базу — Hibernate вернёт уже загруженный объект.
EntityManager em = emf.createEntityManager();
Product p1 = em.find(Product.class, 42L); // SELECT ... WHERE id = 42
Product p2 = em.find(Product.class, 42L); // нет запроса — из кэша сессии
System.out.println(p1 == p2); // true: это один и тот же объект
Это не только экономия запросов, но и гарантия консистентности внутри одной сессии: вы всегда работаете с одним экземпляром объекта, а не с независимыми копиями.
Границы кэша первого уровня
Кэш живёт ровно столько, сколько живёт сессия. Как только сессия закрыта — всё, что было в памяти, исчезает. Следующая сессия начинает с чистого листа и пойдёт в базу заново.
// Сессия 1
EntityManager em1 = emf.createEntityManager();
Product p = em1.find(Product.class, 42L); // SELECT
em1.close();
// Сессия 2 — кэш первого уровня уже пустой
EntityManager em2 = emf.createEntityManager();
Product p2 = em2.find(Product.class, 42L); // снова SELECT
Ещё один нюанс: JPQL-запросы, даже если возвращают уже загруженные сущности, не используют кэш первого уровня для фильтрации — они всегда идут в базу. Hibernate потом сверяет результат с тем, что есть в сессии, и подставляет уже существующие объекты вместо новых.
Подробно о том, как устроен persistence context, — в статье /hibernate/persistence-context/.
Кэш второго уровня — между сессиями
Кэш второго уровня работает на уровне SessionFactory / EntityManagerFactory. Он переживает закрытие отдельных сессий и доступен всем из них. Это опциональная возможность — по умолчанию выключена.
Типичные провайдеры: Ehcache и Infinispan. Настройка подключается в persistence.xml или application.yml:
spring:
jpa:
properties:
hibernate.cache.use_second_level_cache: true
hibernate.cache.region.factory_class: org.hibernate.cache.jcache.internal.JCacheRegionFactory
hibernate.javax.cache.provider: org.ehcache.jsr107.EhcacheCachingProvider
Сам по себе этот YAML не заработает: провайдера org.ehcache.jsr107.EhcacheCachingProvider кто-то должен принести. В зависимости добавляют JSR-107 API (javax.cache:cache-api) и сам Ehcache 3 — в Spring Boot 3 обязательно в jakarta-варианте, с классификатором jakarta. Обычный артефакт собран под старый пакет javax, и провайдер с Hibernate 6 просто не поднимется.
Чтобы сущность кэшировалась на втором уровне, её нужно пометить явно:
import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Country {
@Id
private Long id;
private String name;
}
Аннотаций две: @Cacheable из JPA включает кэширование сущности (режим ENABLE_SELECTIVE по умолчанию кэширует только помеченные), @Cache задаёт стратегию доступа и ставится на корневой класс иерархии.
После этого find(Country.class, 1L) из любой сессии сначала проверит кэш второго уровня, и только при промахе пойдёт в базу.
А вот коллекции этой сущности во второй уровень не попадут — вообще. Пометили Country, а навигация country.getCities() всё равно каждый раз идёт в базу: связь надо пометить отдельно, своей @Cache прямо на поле.
@OneToMany(mappedBy = "country")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private List<City> cities;
Это первая причина знакомого «включил кэш второго уровня, а запросов столько же».
Ещё одна тонкость ломает ожидания, привезённые из раздела про первый уровень: во втором лежит не объект, а его разобранное состояние — просто набор значений полей. Попадание в кэш поэтому отдаёт не тот же экземпляр, а новый, собранный из этих значений. Гарантия «одна строка — один объект» действует только внутри сессии.
Стратегии параллельного доступа
Hibernate предлагает несколько стратегий через CacheConcurrencyStrategy:
| Стратегия | Когда использовать |
|---|---|
READ_ONLY | Данные никогда не меняются (справочники, перечисления) |
READ_WRITE | Данные меняются; нужна согласованность при обновлении |
NONSTRICT_READ_WRITE | Редкие обновления; допустима небольшая задержка актуальности |
TRANSACTIONAL | Требуется полная транзакционная изоляция (только JTA) |
Как убедиться, что кэш работает
Включать и выключать кэш, не глядя на результат, — худший вариант из возможных. Hibernate умеет отчитываться сам, надо только попросить:
spring:
jpa:
properties:
hibernate.generate_statistics: true
hibernate.cache.use_second_level_cache: true
После этого в журнале появляется сводка по сессии, а программно доступна статистика: sessionFactory.getStatistics() (в Spring — через entityManagerFactory.unwrap(SessionFactory.class)). Смотреть надо три числа по каждому региону: getSecondLevelCacheHitCount, ...MissCount и ...PutCount.
Читаются они так. Попаданий много, промахов мало — кэш работает. Промахов и записей поровну и много — данные всё время новые: кэш только тратит память, здесь он не нужен. Записей много, попаданий мало — типичная картина вытеснения: регион слишком мал, объекты выбрасываются раньше, чем к ним обратятся второй раз. Отдельно смотрят QueryCacheHitCount — если он нулевой, а кэш запросов включён, значит запросы каждый раз разные или данные меняются чаще, чем читаются.
В проде это выводят в метрики (Micrometer публикует статистику Hibernate при включённом generate_statistics), но generate_statistics сам по себе не бесплатен — на горячем пути его включают на время разбора, а не навсегда.
Запрос во второй уровень не попадает
Главное недопонимание про кэш второго уровня: он кэширует объекты по идентификатору, а не результаты запросов. em.find(Product.class, id) и переход по ленивой связи (тоже поиск по идентификатору) заглядывают в кэш. А select p from Product p where p.category = :c — нет: этот запрос всегда идёт в базу.
Отсюда частое разочарование: «включили кэш, а список товаров всё равно медленный». Список — это запрос, и ускорить его вторым уровнем нельзя. Что можно: включить отдельный кэш запросов (о нём ниже, и у него свои условия), либо кэшировать не сущности, а готовый результат на уровне приложения.
Есть и промежуточный вариант, который часто оказывается лучшим: кэш по естественному ключу. Если сущность ищут не по идентификатору, а по уникальному полю (артикул, код валюты, почта), пометьте это поле @NaturalId — и поиск через session.byNaturalId(...) тоже пойдёт через кэш:
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Currency {
@Id private long id;
@NaturalId private String code; // 'RUB'
}
Hibernate держит отдельный маленький регион «естественный ключ → идентификатор» и дальше берёт объект из обычного кэша сущностей. Для справочников это самый дешёвый способ убрать запросы совсем.
Регион: размер, срок жизни, вытеснение
Провайдер кэша (Ehcache, Caffeine, Infinispan, Hazelcast) хранит объекты в регионах — по одному на сущность или коллекцию, — и у каждого региона есть настройки, без которых кэш ведёт себя непредсказуемо. Три обязательные.
Размер. Сколько объектов (или сколько памяти) регион держит. При переполнении сработает вытеснение: провайдер выбросит те записи, к которым дольше не обращались. Слишком маленький регион означает постоянные записи без попаданий, слишком большой — съеденную память, которая могла бы пойти на работу.
Срок жизни. Через какое время запись считается устаревшей и перечитывается. Это и есть ответ на вопрос «что будет, если данные поменяли мимо Hibernate»: с коротким сроком расхождение живёт минуты, с бесконечным — до перезапуска.
Политика вытеснения. Обычно «давно не использовались», иногда «давно записаны».
Пример для Caffeine в Spring Boot (настройки задаются на провайдере, не в JPA):
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=10m
Практическое правило: у справочников размер «сколько строк в таблице» и большой срок жизни; у сущностей, которые правят, — маленький срок жизни и небольшой размер, иначе кэш превращается в источник устаревших данных.
Сбросить кэш руками
Про сценарий «ночной скрипт поправил строки в базе мимо приложения» честно сказано, что кэш об этом не узнает. Лечение есть — явная инвалидация:
Cache cache = entityManagerFactory.getCache();
cache.evict(Product.class, productId); // одна сущность
cache.evict(Product.class); // весь регион сущности
cache.evictAll(); // всё
Вызвать это можно из служебного эндпоинта администратора (закрытого, разумеется) — тогда после ночной правки кэш сбрасывают одной кнопкой, а не перезапуском сервиса. Важная оговорка про несколько экземпляров: локальный кэш (Caffeine, Ehcache без кластера) живёт в каждом процессе свой, и сброс на одном узле остальных не касается — придётся вызывать на всех или брать распределённый провайдер.
Кэш и транзакции
Последнее, о чём стоит знать до включения: кэш второго уровня не участвует в вашей изоляции.
Стратегия READ_WRITE даёт «прочитанное зафиксировано»: попадание в кэш не покажет незафиксированных чужих изменений, потому что на время изменения запись в кэше помечается как «в работе» и читатели идут в базу. Но повторяемого чтения она не даёт: два одинаковых find в одной транзакции могут вернуть разные данные, если между ними кто-то зафиксировал правку и обновил кэш. Для транзакции, которой нужна согласованность внутри себя, кэш не замена уровню изоляции — а TRANSACTIONAL, единственная стратегия с настоящими транзакционными гарантиями, требует провайдера с поддержкой распределённых транзакций и в обычных приложениях не используется.
Практический вывод: сущности, по которым принимают денежные решения внутри одной транзакции, во второй уровень не кладут — там читают из базы, а при необходимости берут блокировку.
Когда кэш второго уровня помогает
Хорошие кандидаты для кэширования:
- Справочники — страны, валюты, категории товаров — данные меняются раз в месяц, читаются тысячи раз в день.
- Настройки — конфигурационные записи, которые подгружаются при каждом запросе.
- Агрегаты с широким чтением — сущности, к которым много
find()и малоmerge()/remove().
Короткая формула: кэш второго уровня выгоден, когда соотношение чтения к записи высокое и данные не меняются слишком часто.
Когда кэш второго уровня вреден
Именно здесь начинаются неожиданные проблемы.
Устаревшие данные при внешних изменениях
Hibernate инвалидирует кэш только тогда, когда сам выполняет изменение через EntityManager. Если данные в базе поменяло другое приложение, скрипт миграции или прямой SQL — кэш об этом не узнает и продолжит отдавать старые значения.
Оба уровня видно и без базы: карта db играет роль таблицы, level2 переживает сессии, session — это persistence context.
живой пример
import java.util.HashMap;
import java.util.Map;
public class CacheLevels {
static final Map<Long, String> db = new HashMap<>(Map.of(1L, "Норвегия"));
static final Map<Long, String> level2 = new HashMap<>();
static int selects = 0;
static String find(Map<Long, String> session, long id) {
if (session.containsKey(id)) {
return session.get(id) + " — кэш сессии";
}
String value = level2.get(id);
String source = " — кэш второго уровня";
if (value == null) {
selects++;
value = db.get(id);
level2.put(id, value);
source = " — SELECT из базы";
}
session.put(id, value);
return value + source;
}
public static void main(String[] args) {
Map<Long, String> session1 = new HashMap<>();
System.out.println(find(session1, 1L));
System.out.println(find(session1, 1L));
Map<Long, String> session2 = new HashMap<>();
System.out.println(find(session2, 1L));
db.put(1L, "Швеция");
Map<Long, String> session3 = new HashMap<>();
System.out.println(find(session3, 1L));
System.out.println("в базе " + db.get(1L) + ", SELECT-ов всего: " + selects);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первые три строки вывода — тот самый путь: SELECT, кэш сессии, кэш второго уровня. Четвёртая печатает старое название, хотя в базе уже новое.
Распределённые системы и несколько узлов
В кластере у каждого узла своя JVM. Локальный Ehcache на узле А не знает об изменениях, которые прошли через узел Б. Чтобы кэш второго уровня работал корректно в кластере, нужен распределённый провайдер (Infinispan в режиме кластера, Redis через сторонний адаптер). Это отдельная инфраструктура с дополнительной сложностью.
Большие изменяемые графы
Если сущность часто обновляется, каждая запись бьёт по кэшу — но бьёт по-разному, в зависимости от стратегии из таблицы выше. READ_WRITE запись не выбрасывает, а обновляет под мягкой блокировкой: пока транзакция не завершилась, читатели этой строки идут в базу. NONSTRICT_READ_WRITE действует проще — просто выкидывает запись, и следующий читатель наполняет кэш заново. Итог в обоих случаях один: кэш почти всегда промахивается (cache miss), а накладные расходы на сериализацию/десериализацию только увеличивают задержку. В этом случае кэш второго уровня лучше не включать вообще.
Кэш запросов
Список стран нужен на каждой странице, и запрос SELECT c FROM Country c уходит в базу каждый раз, хотя сами страны уже лежат в кэше второго уровня: кэш по идентификатору отвечает только на find(id). Для таких повторяющихся запросов Hibernate умеет кэшировать результаты JPQL/HQL-запросов. Включается отдельно:
spring:
jpa:
properties:
hibernate.cache.use_query_cache: true
И явно помечается на каждом запросе:
List<Country> countries = em.createQuery("select c from Country c order by c.name", Country.class)
.setHint("org.hibernate.cacheable", true)
.getResultList();
Кэш запросов хранит не сами объекты, а список идентификаторов, — но только если запрос выбирал сущности. У скалярного запроса и агрегата (select count(*), select c.name) кэшировать по идентификатору нечего, и там лежат сами значения. Объекты в первом случае берутся из кэша второго уровня (или базы). Поэтому кэш запросов без кэша второго уровня почти бесполезен — он сэкономит один запрос на список, но всё равно пойдёт в базу за каждой сущностью.
Кэш запросов отдаёт только список идентификаторов, а сами объекты приходят из кэша второго уровня, поэтому без него экономится один запрос на список и добавляется по запросу на каждую строку.
Инвалидация кэша запросов
Кэш запросов инвалидируется полностью для таблицы при любом изменении любой сущности из неё. Если таблица часто меняется, кэш запросов к ней не даст ощутимой пользы.
Достаточно одного persist новой страны — и все закэшированные списки по таблице country считаются устаревшими, следующий запрос снова пойдёт в базу.
Когда лучше не включать кэш совсем
Есть ситуации, где кэширование на уровне Hibernate только мешает:
- Высокая частота записи — инвалидации происходят чаще, чем попадания в кэш.
- Несколько приложений с общей базой — Hibernate не знает об изменениях из соседних приложений.
- Требования строгой согласованности — там, где нельзя допустить даже короткого окна устаревших данных.
- Аналитические запросы — сложные агрегаты по большим таблицам лучше оптимизировать индексами и материализованными представлениями на уровне базы, а не прятать за кэшем ORM.
В таких случаях стоит смотреть в сторону кэширования на уровне приложения (Spring Cache + Redis) или оптимизации самих запросов. Про транзакции и блокировки на уровне базы — в статье /postgres/transactional-spring/.
Кэш ORM и прикладной кэш (@Cacheable из Spring) решают разные задачи, и выбирают между ними по тому, что кэшируется. Кэш второго уровня хранит сущности по идентификатору — он прозрачен (код не меняется), инвалидируется сам при записи через Hibernate и не умеет ничего, кроме объектов. Spring Cache хранит результат метода — то есть готовый ответ: посчитанный отчёт, собранный объект передачи данных, ответ чужой службы. Он не знает про базу, инвалидируется руками (@CacheEvict) и требует думать про ключ.
Правило разделения простое. Справочники и объекты, которые читают по идентификатору из разных сценариев, — второй уровень. Результат тяжёлого вычисления или список для экрана — Spring Cache. Данные, которые меняются часто, — ни то ни другое.
Коротко
- Кэш первого уровня — это persistence context, всегда включён, живёт в пределах одной сессии; повторный
find()по тому же ID не идёт в базу. - Кэш второго уровня — опциональный, на уровне
SessionFactory, переживает закрытие сессий; требует на сущности@Cacheableи@Cache. - Хорошие кандидаты для второго уровня — редко меняемые справочники с высоким соотношением чтений к записям.
- Кэш второго уровня не видит изменений из внешних источников (прямой SQL, другие приложения) и сложен в кластере.
- Кэш запросов хранит списки идентификаторов, работает в паре со вторым уровнем и сбрасывается при любом изменении в таблице.
- Если данные меняются часто или несколько приложений разделяют базу — кэш второго уровня лучше не включать.
- Без
hibernate.generate_statisticsи счётчиков попаданий по регионам кэш включать бессмысленно: три числа сразу говорят, работает он, вытесняется или не нужен. - Второй уровень кэширует объекты по идентификатору, а не запросы: список так не ускорить; для поиска по уникальному полю есть
@NaturalId. - У региона обязательны размер, срок жизни и политика вытеснения; после правки данных мимо приложения кэш сбрасывают
getCache().evict(...), и на каждом узле свой. - Кэш не заменяет изоляцию:
READ_WRITEне даёт повторяемого чтения. Сущности по идентификатору — второй уровень, результат вычисления — Spring Cache.
Что почитать дальше
- Persistence context и жизненный цикл сущности - что именно лежит в кэше первого уровня и когда сущность из него выпадает.
- Проблема N+1 запросов - почему второй уровень не убирает лишние запросы по связям.
- Транзакции и блокировки в Hibernate - чем уровень изоляции отличается от согласованности, которую даёт кэш.
- Spring Data JPA: репозитории поверх Hibernate - где включается прикладной кэш и чем он отличается от второго уровня.