Hibernate умеет хранить загруженные данные в памяти, чтобы не ходить в базу повторно. Это помогает — но только если понимать, какой именно кэш работает, где его границы и когда он может подвести.
Кэш первого уровня — тот, что всегда включён
Кэш первого уровня — это сама 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
javax.cache.provider: org.ehcache.jsr107.EhcacheCachingProvider
Чтобы сущность кэшировалась на втором уровне, её нужно пометить явно:
import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Country {
@Id
private Long id;
private String name;
}
После этого find(Country.class, 1L) из любой сессии сначала проверит кэш второго уровня, и только при промахе пойдёт в базу.
Стратегии параллельного доступа
Hibernate предлагает несколько стратегий через CacheConcurrencyStrategy:
| Стратегия | Когда использовать |
|---|---|
READ_ONLY | Данные никогда не меняются (справочники, перечисления) |
READ_WRITE | Данные меняются; нужна согласованность при обновлении |
NONSTRICT_READ_WRITE | Редкие обновления; допустима небольшая задержка актуальности |
TRANSACTIONAL | Требуется полная транзакционная изоляция (только JTA) |
Когда кэш второго уровня помогает
Хорошие кандидаты для кэширования:
- Справочники — страны, валюты, категории товаров — данные меняются раз в месяц, читаются тысячи раз в день.
- Настройки — конфигурационные записи, которые подгружаются при каждом запросе.
- Агрегаты с широким чтением — сущности, к которым много
find()и малоmerge()/remove().
Короткая формула: кэш второго уровня выгоден, когда соотношение чтения к записи высокое и данные не меняются слишком часто.
Когда кэш второго уровня вреден
Именно здесь начинаются неожиданные проблемы.
Устаревшие данные при внешних изменениях
Hibernate инвалидирует кэш только тогда, когда сам выполняет изменение через EntityManager. Если данные в базе поменяло другое приложение, скрипт миграции или прямой SQL — кэш об этом не узнает и продолжит отдавать старые значения.
// Другой процесс выполнил: UPDATE product SET price = 999 WHERE id = 5
// Наш код получит старую цену из кэша второго уровня
Product p = em.find(Product.class, 5L); // цена из кэша, не из базы!
Распределённые системы и несколько узлов
В кластере у каждого узла своя JVM. Локальный Ehcache на узле А не знает об изменениях, которые прошли через узел Б. Чтобы кэш второго уровня работал корректно в кластере, нужен распределённый провайдер (Infinispan в режиме кластера, Redis через сторонний адаптер). Это отдельная инфраструктура с дополнительной сложностью.
Большие изменяемые графы
Если сущность часто обновляется, то при каждом merge() или remove() Hibernate вытесняет её из кэша. В итоге кэш почти всегда промахивается (cache miss), а накладные расходы на сериализацию/десериализацию только увеличивают задержку. В этом случае кэш второго уровня лучше не включать вообще.
Кэш запросов
Помимо кэширования по идентификатору, Hibernate умеет кэшировать результаты JPQL/HQL-запросов. Включается отдельно:
spring:
jpa:
properties:
hibernate.cache.use_query_cache: true
И явно помечается на каждом запросе:
List<Country> countries = em.createQuery("FROM Country ORDER BY name", Country.class)
.setHint("org.hibernate.cacheable", true)
.getResultList();
Кэш запросов хранит не сами объекты, а список идентификаторов. Объекты затем берутся из кэша второго уровня (или базы). Поэтому кэш запросов без кэша второго уровня почти бесполезен — он сэкономит один запрос на список, но всё равно пойдёт в базу за каждой сущностью.
Инвалидация кэша запросов
Кэш запросов инвалидируется полностью для таблицы при любом изменении любой сущности из неё. Если таблица часто меняется, кэш запросов к ней не даст ощутимой пользы.
// Кто-то сохранил новую страну
em.persist(new Country("Новая Зеландия", "NZ"));
em.flush();
// Весь кэш запросов к таблице Country сброшен
// Следующий запрос снова пойдёт в базу
Когда лучше не включать кэш совсем
Есть ситуации, где кэширование на уровне Hibernate только мешает:
- Высокая частота записи — инвалидации происходят чаще, чем попадания в кэш.
- Несколько приложений с общей базой — Hibernate не знает об изменениях из соседних приложений.
- Требования строгой согласованности — там, где нельзя допустить даже короткого окна устаревших данных.
- Аналитические запросы — сложные агрегаты по большим таблицам лучше оптимизировать индексами и материализованными представлениями на уровне базы, а не прятать за кэшем ORM.
В таких случаях стоит смотреть в сторону кэширования на уровне приложения (Spring Cache + Redis) или оптимизации самих запросов. Про транзакции и блокировки на уровне базы — в статье /postgres/transactional-spring/.
Коротко
- Кэш первого уровня — это persistence context, всегда включён, живёт в пределах одной сессии; повторный
find()по тому же ID не идёт в базу. - Кэш второго уровня — опциональный, на уровне
SessionFactory, переживает закрытие сессий; требует явной аннотации@Cacheна сущности. - Хорошие кандидаты для второго уровня — редко меняемые справочники с высоким соотношением чтений к записям.
- Кэш второго уровня не видит изменений из внешних источников (прямой SQL, другие приложения) и сложен в кластере.
- Кэш запросов хранит списки идентификаторов, работает в паре со вторым уровнем и сбрасывается при любом изменении в таблице.
- Если данные меняются часто или несколько приложений разделяют базу — кэш второго уровня лучше не включать.
Что почитать дальше
- Persistence context и жизненный цикл сущности
- Проблема N+1 запросов
- Транзакции и блокировки в Hibernate
- Spring Data JPA: репозитории поверх Hibernate