💾 Раздел 16 · Вопрос #11

Как настроить кэш второго уровня

Настройка кэша второго уровня (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), будет ли сущность кэшироваться?

Ответ:

  1. В Hibernate 5/6 при режиме ENABLE_SELECTIVE спецификация JPA требует наличия стандартной аннотации @jakarta.persistence.Cacheable (или ее эквивалента в XML mapping-file).
  2. Если указана только аннотация Hibernate @Cache, но отсутствует @Cacheable, JPA-провайдер при инициализации метамодели может проигнорировать сущность и не зарегистрировать ее в фабрике регионов.
  3. Безопасный стандарт: Всегда ставить обе аннотации: @Cacheable (для JPA-контракта) и @Cache(usage = ...) (для настройки стратегии конкурентности Hibernate).

2. Почему в конфигурации Ehcache размер региона для коллекции @OneToMany нужно рассчитывать совершенно иначе, чем для обычной сущности?

Ответ:

  1. Регион сущности кэширует дегидрированные строки (по одной записи на каждый id).
  2. Регион коллекции кэширует связь: ключом выступает id родителя (например, author_id), а значением — массив идентификаторов дочерних записей: [book_1, book_2, ...].
  3. Соответственно, количество элементов (heap entries) в кэше коллекции должно соответствовать количеству уникальных родителей, а не общему числу дочерних строк.

3. Почему хранение L2 кэша в Redis/Redisson может оказаться медленнее обычных SQL-запросов в СУБД?

Ответ:

  1. Обращение к локальному On-Heap кэшу (Ehcache) занимает единицы наносекунд (прямое чтение из памяти без сериализации).
  2. Обращение к Redis — это сетевой сокетный round-trip (1–3 мс) + сериализация/десериализация бинарного payload.
  3. Если СУБД (PostgreSQL/MySQL) уже держит горячие страницы таблицы в своем shared_buffers / buffer_pool, то выполнение простого индексного запроса SELECT * FROM table WHERE id = ? займет те же 0.5–1 мс.
  4. Вывод: Выносить L2 кэш в Redis имеет смысл только для снятия нагрузки по CPU/соединениям с СУБД, но сетевая задержка кэша будет сопоставима с быстрым чтением из базы.

4. Что произойдет, если время жизни записи в Query Cache установить больше, чем TTL этой же сущности в Entity L2 Cache?

Ответ:

  1. Query Cache вернет сохраненный список первичных ключей: например, [1, 2, 3].
  2. Hibernate пойдет в регион сущностей L2 кэша по этим ID.
  3. Поскольку TTL сущности истек раньше, записи в кэше сущностей не окажется (Cache Miss).
  4. Hibernate будет вынужден выполнить единичные SQL-запросы по первичному ключу для каждого протухшего ID (SELECT * FROM table WHERE id = 1, ... id = 2, ... id = 3), что вызовет тяжелый эффект N+1 на ровном месте.
  5. Правило: 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 запросов.

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