💾 Розділ 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 запитів.

Пов’язані теми