Як налаштувати кеш другого рівня
Налаштування кешу другого рівня (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 запитів.