Как настроить кэш второго уровня
Настройка кэша второго уровня (L2 Cache) в Hibernate и JPA включает выбор регионального провайдера кэша (JCache/Ehcache, Hazelcast, Redisson/Redis, Infinispan), включение свойст...
Настройка кэша второго уровня (L2 Cache) в Hibernate и JPA включает выбор регионального провайдера кэша (JCache/Ehcache, Hazelcast, Redisson/Redis, Infinispan), включение свойств в конфигурации Spring Boot / Hibernate, конфигурирование политик хранения (heap, off-heap, TTL) и разметку доменной модели аннотациями @Cacheable и @Cache.
🟢 Junior Level
3 шага для подключения L2 кэша
Для запуска L2 кэша в современном стеке Spring Boot 3+ (Hibernate 6+) требуется выполнить три действия:
Шаг 1: Добавить зависимости (на примере Ehcache 3 через JCache API)
В pom.xml:
<!-- Мост Hibernate к спецификации JCache (JSR-107) -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jcache</artifactId>
</dependency>
<!-- Реализация JCache: Ehcache 3 -->
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<classifier>jakarta</classifier>
</dependency>
Шаг 2: Включить кэш в application.yml
spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
region:
factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
# URI конфигурационного файла провайдера
hibernate.javax.cache.uri: classpath:ehcache.xml
# Режим кэширования: только явно помеченные сущности
jakarta.persistence.sharedCache.mode: ENABLE_SELECTIVE
Шаг 3: Пометить сущность аннотациями
import jakarta.persistence.Cacheable;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Table(name = "currencies")
@Cacheable // Стандарт JPA: сообщает EntityManagerFactory о включении кэша
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY) // Hibernate: задает стратегию
public class Currency {
@Id
private String code;
private String name;
private BigDecimal rate;
}
Режимы кэширования sharedCache.mode
Параметр jakarta.persistence.sharedCache.mode определяет общую политику кэширования приложения:
| Режим | Поведение |
|---|---|
ENABLE_SELECTIVE |
(Рекомендуемый) Кэшируются только сущности с аннотацией @Cacheable (или @Cacheable(true)). |
DISABLE_SELECTIVE |
Кэшируются все сущности подряд, кроме тех, у которых явно указано @Cacheable(false). Опасно для памяти! |
ALL |
Все сущности принудительно кэшируются без исключений. |
NONE |
Кэш второго уровня полностью деактивирован на уровне JPA. |
UNSPECIFIED |
Дефолтное поведение провайдера (в Hibernate эквивалентно отсутствию кэша). |
🟡 Middle Level
Тонкая настройка хранилища (Конфигурация ehcache.xml)
Для предотвращения утечек оперативной памяти каждый регион кэша должен иметь жесткие лимиты по объему и времени жизни (TTL / TTI).
Файл src/main/resources/ehcache.xml:
<config xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
xmlns='http://www.ehcache.org/v3'
xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-107-ext-3.0.xsd">
<!-- Кэш для сущности Currency: неизменяемый справочник -->
<cache alias="com.example.entity.Currency">
<expiry>
<ttl unit="hours">24</ttl> <!-- Время жизни записи: 24 часа -->
</expiry>
<resources>
<heap unit="entries">1000</heap> <!-- До 1000 объектов в On-Heap JVM -->
</resources>
</cache>
<!-- Именной регион для часто читаемых категорий -->
<cache alias="productCategoryRegion">
<expiry>
<tti unit="minutes">30</tti> <!-- Time To Idle: удаляется, если не запрашивался 30 мин -->
</expiry>
<resources>
<heap unit="entries">5000</heap>
<offheap unit="MB">64</offheap> <!-- Хранение вне Heap JVM (снижает нагрузку на GC) -->
</resources>
</cache>
</config>
Привязка кастомного региона в Java-коде:
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "productCategoryRegion")
public class ProductCategory {
@Id
private Long id;
private String title;
}
Настройка кэширования связей (@OneToMany)
Кэширование сущности не кэширует ее дочерние связи. Чтобы закэшировать отношение, аннотация @Cache ставится прямо над коллекцией:
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "authorRegion")
public class Author {
@Id
private Long id;
// Кэш коллекции хранит только массив ID: [101, 102, 103]
@OneToMany(mappedBy = "author")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "authorBooksRegion")
private List<Book> books = new ArrayList<>();
}
Важное правило: Класс Book также обязан быть помечен @Cacheable, иначе при чтении коллекции Hibernate получит ID книг из кэша, но для каждой книги полезет в БД с отдельным SQL SELECT.
Программная инвалидация L2 кэша
Если данные в таблице были обновлены внешней процедурой или триггером, кэш L2 можно сбросить вручную через JPA Cache API:
@Service
@RequiredArgsConstructor
public class CacheManagementService {
private final EntityManagerFactory entityManagerFactory;
public void evictSingleEntity(Long categoryId) {
jakarta.persistence.Cache cache = entityManagerFactory.getCache();
// Удаляет конкретную запись по первичному ключу
cache.evict(ProductCategory.class, categoryId);
}
public void evictAllCategories() {
jakarta.persistence.Cache cache = entityManagerFactory.getCache();
// Очищает весь регион сущности ProductCategory
cache.evict(ProductCategory.class);
}
public void evictEntireL2Cache() {
jakarta.persistence.Cache cache = entityManagerFactory.getCache();
// Очищает абсолютно все регионы L2 кэша
cache.evictAll();
}
}
🔴 Senior Level
Настройка Query Cache (Кэша запросов)
Query Cache позволяет кэшировать результаты JPQL/HQL запросов. По умолчанию он выключен даже при работающем L2 кэше.
spring:
jpa:
properties:
hibernate:
cache:
use_query_cache: true # Включает кэширование запросов
Активация кэширования для конкретного запроса в репозитории:
public interface UserRepository extends JpaRepository<User, Long> {
@QueryHints({
@QueryHint(name = org.hibernate.jpa.HibernateHints.HINT_CACHEABLE, value = "true"),
@QueryHint(name = org.hibernate.jpa.HibernateHints.HINT_CACHE_REGION, value = "activeUsersQueryRegion")
})
@Query("SELECT u FROM User u WHERE u.status = 'ACTIVE'")
List<User> findAllActive();
}
Внутренний механизм инвалидации Query Cache:
Hibernate регистрирует зависимость региона activeUsersQueryRegion от таблицы users. Как только любая транзакция выполняет INSERT, UPDATE или DELETE в таблице users, Hibernate обновляет таймстемп в служебном регионе UpdateTimestampsCache. При следующем вызове findAllActive() Hibernate сравнивает время создания кэша запроса со временем последнего обновления таблицы users. Если таблица менялась — кэш запроса немедленно аннулируется.
Распределенный L2 кэш в микросервисах (Redisson / Redis)
Если приложение запущено в нескольких подах Kubernetes, локальный Ehcache приведет к рассинхронизации данных между инстансами. Решением является единый кэш на базе Redis:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-hibernate-6</artifactId>
<version>3.27.0</version>
</dependency>
spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
region:
factory_class: org.redisson.hibernate.RedissonRegionFactory
redisson:
config: classpath:redisson.yaml
Senior-ловушка сериализации: В отличие от on-heap Ehcache, при использовании Redis дегидрированное состояние сущностей передается по сети в бинарном формате. Все пользовательские составные ключи (@EmbeddedId), энумы и типы значений обязаны реализовывать java.io.Serializable, иначе попытка записи в Redis завершится NotSerializableException.
Мониторинг эффективности (Hibernate Statistics)
@Configuration
public class HibernateMetricsConfig {
@Bean
public HibernatePropertiesCustomizer hibernatePropertiesCustomizer() {
return props -> props.put(org.hibernate.cfg.AvailableSettings.GENERATE_STATISTICS, "true");
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class L2MetricsMonitor {
private final EntityManagerFactory entityManagerFactory;
@Scheduled(fixedDelay = 60000)
public void reportCacheHealth() {
SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
long hits = stats.getSecondLevelCacheHitCount();
long misses = stats.getSecondLevelCacheMissCount();
long puts = stats.getSecondLevelCachePutCount();
double hitRatio = (hits + misses == 0) ? 0.0 : (double) hits / (hits + misses);
log.info("L2 Cache Metrics -> Hits: {}, Misses: {}, Puts: {}, Ratio: {}%",
hits, misses, puts, String.format("%.2f", hitRatio * 100));
// Мониторинг эвиктов по конкретному региону
long regionPuts = stats.getDomainDataRegionStatistics("productCategoryRegion").getPutCount();
long regionHits = stats.getDomainDataRegionStatistics("productCategoryRegion").getHitCount();
}
}
4 Tricky Questions
1. Если задано sharedCache.mode: ENABLE_SELECTIVE, но класс сущности помечен ТОЛЬКО @org.hibernate.annotations.Cache (без стандартной @jakarta.persistence.Cacheable), будет ли сущность кэшироваться?
Ответ:
- В Hibernate 5/6 при режиме
ENABLE_SELECTIVEспецификация JPA требует наличия стандартной аннотации@jakarta.persistence.Cacheable(или ее эквивалента в XMLmapping-file). - Если указана только аннотация Hibernate
@Cache, но отсутствует@Cacheable, JPA-провайдер при инициализации метамодели может проигнорировать сущность и не зарегистрировать ее в фабрике регионов. - Безопасный стандарт: Всегда ставить обе аннотации:
@Cacheable(для JPA-контракта) и@Cache(usage = ...)(для настройки стратегии конкурентности Hibernate).
2. Почему в конфигурации Ehcache размер региона для коллекции @OneToMany нужно рассчитывать совершенно иначе, чем для обычной сущности?
Ответ:
- Регион сущности кэширует дегидрированные строки (по одной записи на каждый
id). - Регион коллекции кэширует связь: ключом выступает
idродителя (например,author_id), а значением — массив идентификаторов дочерних записей:[book_1, book_2, ...]. - Соответственно, количество элементов (
heap entries) в кэше коллекции должно соответствовать количеству уникальных родителей, а не общему числу дочерних строк.
3. Почему хранение L2 кэша в Redis/Redisson может оказаться медленнее обычных SQL-запросов в СУБД?
Ответ:
- Обращение к локальному On-Heap кэшу (Ehcache) занимает единицы наносекунд (прямое чтение из памяти без сериализации).
- Обращение к Redis — это сетевой сокетный round-trip (1–3 мс) + сериализация/десериализация бинарного payload.
- Если СУБД (PostgreSQL/MySQL) уже держит горячие страницы таблицы в своем
shared_buffers/buffer_pool, то выполнение простого индексного запросаSELECT * FROM table WHERE id = ?займет те же 0.5–1 мс. - Вывод: Выносить L2 кэш в Redis имеет смысл только для снятия нагрузки по CPU/соединениям с СУБД, но сетевая задержка кэша будет сопоставима с быстрым чтением из базы.
4. Что произойдет, если время жизни записи в Query Cache установить больше, чем TTL этой же сущности в Entity L2 Cache?
Ответ:
- Query Cache вернет сохраненный список первичных ключей: например,
[1, 2, 3]. - Hibernate пойдет в регион сущностей L2 кэша по этим ID.
- Поскольку TTL сущности истек раньше, записи в кэше сущностей не окажется (Cache Miss).
- Hibernate будет вынужден выполнить единичные SQL-запросы по первичному ключу для каждого протухшего ID (
SELECT * FROM table WHERE id = 1,... id = 2,... id = 3), что вызовет тяжелый эффект N+1 на ровном месте. - Правило: TTL региона Query Cache всегда должен быть меньше либо равен TTL соответствующих Entity-регионов.
🎯 Шпаргалка для интервью
30-секундный ответ (Elevator Pitch)
«Настройка кэша второго уровня в Hibernate состоит из подключения JCache-провайдера (например, Ehcache 3 или Redisson), активации свойств
hibernate.cache.use_second_level_cache=trueи режимаsharedCache.mode=ENABLE_SELECTIVE, а также разметки сущностей аннотациями@Cacheableи@Cache. Вehcache.xmlнастраиваются индивидуальные регионы с ограничениями по heap/off-heap и временем жизни (TTL/TTI). Для кэширования JPQL-запросов включаетсяuse_query_cache, а сами запросы маркируются хинтомHINT_CACHEABLE. Для кластерных инсталляций локальные кэши заменяются на распределенные (Hazelcast/Infinispan/Redis), где критически важна сериализуемость типов».
Ключевые метрики для Production-мониторинга
- Second-Level Cache Hit Ratio: процент успешных попаданий (норма > 85% для справочников).
- Eviction Count: частота принудительного вытеснения по нехватке памяти (сигнал к увеличению heap/off-heap).
- UpdateTimestampsCache Invalidation Rate: частота сброса Query Cache из-за апдейтов таблиц.
Красные флаги (Чего говорить НЕЛЬЗЯ)
- ❌ «Для настройки L2 кэша достаточно просто добавить аннотацию @Cacheable без провайдера» — Без зависимостей
hibernate-jcacheи файла провайдера кэш не запустится. - ❌ «Использую sharedCache.mode: ALL для всех проектов» — Это приведет к кэшированию высоконагруженных таблиц на запись и быстрому падению JVM с OutOfMemoryError.
- ❌ «Не задаю TTL в ehcache.xml, кэш сам очистится при нехватке памяти» — Без лимитов и eviction-политик наступит деградация производительности и OOM.
- ❌ «Query Cache можно настроить без включения кэша сущностей» — Это гарантированно вызовет проблему N+1 запросов.