💾 Section 16 · Question #11

How to Configure the Second-Level Cache

Configuring the Second-Level Cache (L2 Cache) in Hibernate and JPA requires selecting a regional cache provider (JCache/Ehcache 3, Hazelcast, Redisson/Redis, Infinispan), enabli...

Configuring the Second-Level Cache (L2 Cache) in Hibernate and JPA requires selecting a regional cache provider (JCache/Ehcache 3, Hazelcast, Redisson/Redis, Infinispan), enabling caching properties in Spring Boot / Hibernate, defining storage policies (heap, off-heap, TTL/TTI), and decorating the domain model with @Cacheable and @Cache annotations.


🟢 Junior Level

3 Steps to Enable the Second-Level Cache

In a modern Spring Boot 3+ (Hibernate 6+) application, enabling the L2 cache involves three fundamental steps:

Step 1: Add Dependencies (Ehcache 3 via JCache API)

In pom.xml:

<!-- Hibernate bridge to the JCache (JSR-107) specification -->
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
</dependency>

<!-- JCache reference implementation: Ehcache 3 -->
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <classifier>jakarta</classifier>
</dependency>

Step 2: Configure Properties in application.yml

spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          region:
            factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
      # Path to the provider-specific configuration XML
      hibernate.javax.cache.uri: classpath:ehcache.xml
      # Caching policy: Only cache explicitly marked entities
      jakarta.persistence.sharedCache.mode: ENABLE_SELECTIVE

Step 3: Annotate Domain Entities

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 // Standard JPA: Informs EntityManagerFactory to register entity for caching
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY) // Hibernate: Specifies concurrency strategy
public class Currency {
    @Id
    private String code;
    private String name;
    private BigDecimal exchangeRate;
}

Shared Cache Modes (sharedCache.mode)

The jakarta.persistence.sharedCache.mode property defines the global caching policy across the persistence unit:

Mode Behavior & Description
ENABLE_SELECTIVE (Recommended) Caches only entities marked with @Cacheable (or @Cacheable(true)).
DISABLE_SELECTIVE Caches all entities across the application except those explicitly marked @Cacheable(false). Dangerous for memory!
ALL Blindly caches 100% of all entities in the domain model.
NONE Completely disables the Second-Level Cache at the JPA layer.
UNSPECIFIED Uses provider-specific defaults (equivalent to disabled in Hibernate).

🟡 Middle Level

Fine-Tuning Storage Policies (ehcache.xml)

To prevent JVM heap exhaustion, every cache region must define strict memory bounds and expiration policies (TTL/TTI).

File: 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">

    <!-- Default cache for Currency: static lookup table -->
    <cache alias="com.example.entity.Currency">
        <expiry>
            <ttl unit="hours">24</ttl> <!-- Time-To-Live: 24 hours -->
        </expiry>
        <resources>
            <heap unit="entries">1000</heap> <!-- Max 1,000 entities in JVM Heap -->
        </resources>
    </cache>

    <!-- Named custom region for frequently read product categories -->
    <cache alias="productCategoryRegion">
        <expiry>
            <tti unit="minutes">30</tti> <!-- Time-To-Idle: evicted if unread for 30 minutes -->
        </expiry>
        <resources>
            <heap unit="entries">5000</heap>
            <offheap unit="MB">64</offheap> <!-- Stored outside JVM Garbage Collector bounds -->
        </resources>
    </cache>
</config>

Binding the custom region in Java:

@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "productCategoryRegion")
public class ProductCategory {
    @Id
    private Long id;
    private String title;
}

Configuring Association Caching (@OneToMany)

Caching a parent entity does not cache its child associations. To cache a relational collection, place the @Cache annotation directly on the collection field:

@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "authorRegion")
public class Author {
    @Id
    private Long id;

    // Collection cache stores only an array of child IDs: [101, 102, 103]
    @OneToMany(mappedBy = "author")
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "authorBooksRegion")
    private List<Book> books = new ArrayList<>();
}

Crucial Rule: The child class Book must also be annotated with @Cacheable. If Book is not cached, Hibernate retrieves the book IDs from authorBooksRegion and executes N individual SQL queries to load each Book row from the database!

Programmatic Cache Invalidation

When tables are modified by external batch scripts or database triggers, the L2 cache can be evicted manually via JPA’s Cache API:

@Service
@RequiredArgsConstructor
public class CacheManagementService {

    private final EntityManagerFactory entityManagerFactory;

    public void evictSingleEntity(Long categoryId) {
        jakarta.persistence.Cache cache = entityManagerFactory.getCache();
        // Evicts a single record by primary key
        cache.evict(ProductCategory.class, categoryId);
    }

    public void evictEntireCategoryRegion() {
        jakarta.persistence.Cache cache = entityManagerFactory.getCache();
        // Evicts all records in the ProductCategory region
        cache.evict(ProductCategory.class);
    }

    public void purgeEntireL2Cache() {
        jakarta.persistence.Cache cache = entityManagerFactory.getCache();
        // Nuclear option: Purges ALL regions across the entire application
        cache.evictAll();
    }
}

🔴 Senior Level

Configuring the Query Cache

The Query Cache stores the results of JPQL / Criteria queries. By default, it is disabled even when the L2 cache is active.

spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_query_cache: true # Enables query results caching

Activating caching on a repository query:

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();
}

Internal Invalidation Mechanics of the Query Cache:

Hibernate tracks table dependencies for activeUsersQueryRegion. Whenever any transaction executes an INSERT, UPDATE, or DELETE against the users table, Hibernate updates a timestamp in an internal cache region called UpdateTimestampsCache. On subsequent executions of findAllActive(), Hibernate compares the query cache creation timestamp against the last update timestamp of the users table. If the table was modified, the query cache entry is immediately discarded.

Distributed L2 Cache in Microservices (Redisson / Redis)

In a multi-pod Kubernetes environment, local in-memory caches like Ehcache lead to data inconsistency across instances. A shared Redis cluster provides a unified L2 cache:

<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

The Senior Serialization Trap: Unlike local on-heap caches, Redis transmits dehydrated entity state over TCP sockets in binary format. All composite primary keys (@EmbeddedId), embeddable objects, and custom enum types must implement java.io.Serializable. If an un-serializable type is encountered, Redisson throws a runtime NotSerializableException.

Monitoring Cache Performance (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));
        
        // Detailed metrics for a specific domain region
        long regionPuts = stats.getDomainDataRegionStatistics("productCategoryRegion").getPutCount();
        long regionHits = stats.getDomainDataRegionStatistics("productCategoryRegion").getHitCount();
    }
}

4 Tricky Questions

1. If sharedCache.mode: ENABLE_SELECTIVE is configured, but an entity is annotated ONLY with @org.hibernate.annotations.Cache (without standard @jakarta.persistence.Cacheable), will Hibernate cache it?

Answer:

  1. Under ENABLE_SELECTIVE, the JPA specification explicitly mandates the presence of the standard @jakarta.persistence.Cacheable annotation (or its XML equivalent).
  2. If only Hibernate’s proprietary @Cache annotation is present without @Cacheable, the JPA metamodel initializer may skip registering the entity in the region factory.
  3. Production Best Practice: Always apply both annotations: @Cacheable (to fulfill the JPA specification contract) and @Cache(usage = ...) (to configure Hibernate’s concurrency strategy and region name).

2. Why must the size limit for a @OneToMany collection region in Ehcache be sized differently from an entity region?

Answer:

  1. An entity cache region stores dehydrated rows indexed by individual entity primary keys (id).
  2. A collection cache region stores the relationship: the cache key is the parent entity’s ID (e.g., author_id), and the value is a list of child IDs: [book_1, book_2, ...].
  3. Consequently, the capacity (heap unit="entries") of a collection region must match the number of cached parent entities, not the total number of child records.

3. Why can an L2 cache backed by Redis / Redisson perform slower than direct relational database queries?

Answer:

  1. Local in-memory caches (such as on-heap Ehcache) execute reads in nanoseconds via direct memory pointers without serialization.
  2. Redis requires network socket round-trips (1–3 ms) plus binary serialization/deserialization of payloads.
  3. If the database engine (PostgreSQL or MySQL) already has the table’s index pages cached in shared_buffers or buffer_pool, an indexed primary key lookup (SELECT * FROM table WHERE id = ?) takes only 0.5–1 ms.
  4. Conclusion: Remote Redis L2 caching is designed to offload CPU and connection limits from the database cluster, not to decrease single-query latency compared to indexed DB reads.

4. What happens if the TTL of a Query Cache region is configured to be longer than the TTL of the underlying Entity Cache region?

Answer:

  1. The Query Cache returns its stored list of entity primary keys: e.g., [1, 2, 3].
  2. Hibernate looks up each primary key in the entity L2 cache region.
  3. Because the entity cache TTL expired earlier, none of the entities exist in the entity cache (Cache Miss).
  4. Hibernate is forced to execute individual SQL queries for each expired primary key (SELECT * FROM table WHERE id = 1, ... id = 2, ... id = 3), triggering an N+1 query disaster!
  5. Rule: The TTL of any Query Cache region must always be less than or equal to the TTL of the associated entity regions.

🎯 Interview Cheat Sheet

30-Second Elevator Pitch

“Configuring the Second-Level Cache in Hibernate entails adding a JCache provider (such as Ehcache 3 or Redisson), setting hibernate.cache.use_second_level_cache=true with sharedCache.mode=ENABLE_SELECTIVE, and decorating target entities with @Cacheable and @Cache(usage = ...). Memory limits and expiration policies (TTL/TTI) are configured per region in ehcache.xml. To cache JPQL queries, enable use_query_cache and annotate repository methods with HINT_CACHEABLE. In distributed microservice deployments, use clustered providers like Hazelcast or Redisson, ensuring all composite keys and value types implement java.io.Serializable.”

Key Production Metrics & Health Checks

  • Second-Level Cache Hit Ratio: Should exceed 85–90% for lookup and reference tables.
  • Eviction Count: Frequent memory evictions signal that heap/off-heap limits in ehcache.xml are undersized.
  • UpdateTimestampsCache Invalidation Frequency: High invalidation rates indicate that tables tied to Query Cache regions are receiving frequent write operations, nullifying query caching benefits.

Red Flags to Avoid

  • ❌ “Adding @Cacheable to an entity enables L2 caching without declaring a cache provider dependency.” — Hibernate requires hibernate-jcache and a concrete provider implementation like Ehcache.
  • ❌ “Using sharedCache.mode: ALL is recommended for enterprise systems.” — This blindly caches write-heavy entities and causes rapid memory exhaustion (OutOfMemoryError).
  • ❌ “You do not need to configure TTL in ehcache.xml because the JVM garbage collector manages cache memory.” — Caches retain strong references; without TTL/TTI policies, memory leaks are inevitable.
  • ❌ “The Query Cache can be safely enabled without enabling L2 caching on the returned entities.” — This produces an N+1 query storm on every query cache hit.