Skip to content

Spring Boot JPA (Part 4) | Second Level Caching | L2 Caching

The Personal Notebook vs Department Archive Analogy

Imagine an investigator working at a research firm. While working on a specific investigation today, the investigator jots phone numbers and names onto a yellow notepad on their personal desk. As long as they are working on that specific case, looking up a number takes zero effort: they look down at their notepad. But when the investigator goes home at five o'clock, the yellow notepad is shredded. Tomorrow morning, a different investigator sitting at that desk has to look up those same numbers from scratch. That personal notepad is the First Level Cache (L1): it is private to a single transaction session and perishes when the session ends.

To prevent duplicated effort, the research firm maintains a centralized, climate controlled department library archive in the hallway. When an investigator verifies an official company phone number, they file a typed card into the archive drawer. Tomorrow, any investigator from any department who needs that company number checks the hallway archive first. They do not query the external government registry or make phone calls. That shared archive is the Second Level Cache (L2): it lives outside individual sessions, shared across all threads and transactions in the entire application process.

This lecture covers First Level versus Second Level caching, cache concurrency strategies, configuring L2 cache providers (Ehcache, Hazelcast, Redis), the Query Cache, and cache eviction patterns.


First Level (L1) vs Second Level (L2) Cache

Understanding the architectural boundaries between L1 and L2 cache is fundamental to database performance tuning:

[ Application Process (JVM) ]
  |
  +--- Transaction 1 (Thread A) ----> [ L1 Cache: Session 1 ] ---+
  |                                                              |
  +--- Transaction 2 (Thread B) ----> [ L1 Cache: Session 2 ] ---+--> [ L2 Cache: SessionFactory ] ---> [ Database ]
  |                                                              |       (Shared across all)
  +--- Transaction 3 (Thread C) ----> [ L1 Cache: Session 3 ] ---+
DimensionFirst Level Cache (L1)Second Level Cache (L2)
ScopeSession / Transaction Level (EntityManager)Process Level (EntityManagerFactory / SessionFactory)
SharingPrivate to a single thread / database transactionShared across all concurrent threads and transactions in the JVM
LifecycleCreated on transaction start, destroyed on transaction closeRetained across the entire lifetime of the application
Default StateAlways enabled by default in Hibernate; cannot be turned offDisabled by default; must be explicitly configured
Storage LocationHeap memory within SessionImpl instanceIn memory (Ehcache, Caffeine) or distributed (Redis, Hazelcast)
EvictionentityManager.clear(), entityManager.detach(entity)Evicted programmatically or via TTL / LRU eviction algorithms

How Second Level Caching Operates

When an entity is configured for second level caching, lookups follow a multi tiered progression:

  1. Application calls userRepository.findById(42L).
  2. Hibernate checks the active L1 Cache (current session).
    • If present -> returns entity immediately with zero SQL.
  3. If absent in L1, Hibernate checks the L2 Cache.
    • If present in L2 (Cache Hit) -> Hibernate deserializes the cached entity state, hydrates a managed entity inside the current L1 session, and returns it. Zero database queries execute!
  4. If absent in L2 (Cache Miss) -> Hibernate executes the SQL SELECT against the database, stores the result in both L1 and L2 cache, and returns the entity.

Configuring Second Level Cache in Spring Boot

To enable L2 caching with Ehcache in Spring Boot:

1. Add Dependencies in pom.xml

xml
<!-- Hibernate JCache integration -->
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
</dependency>

<!-- Ehcache 3 Implementation -->
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <classifier>jakarta</classifier>
</dependency>

2. Configure application.properties

properties
# Enable second level cache
spring.jpa.properties.hibernate.cache.use_second_level_cache=true

# Specify JCache region factory
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory

# Path to Ehcache XML configuration file
spring.jpa.properties.javax.persistence.sharedCache.mode=ENABLE_SELECTIVE
spring.jpa.properties.hibernate.javax.cache.uri=classpath:ehcache.xml

# Enable statistics for debugging and metrics monitoring
spring.jpa.properties.hibernate.generate_statistics=true

Cache Concurrency Strategies

When multiple threads read and write to the same cached entity simultaneously, Hibernate requires a concurrency strategy to guarantee data consistency.

You declare the strategy using @org.hibernate.annotations.Cache:

java
package com.example.orderservice.entity;

import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

@Entity
@Table(name = "products")
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class ProductEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private double price;

    // Getters and setters
}

The Four Concurrency Strategies:

  1. READ_ONLY:

    • Best for static reference data that never changes (countries, currencies, postal codes).
    • Maximum performance: zero locking overhead.
    • If your application attempts to update an entity marked READ_ONLY, Hibernate throws an exception.
  2. NONSTRICT_READ_WRITE:

    • Best for data that is updated rarely, where brief inconsistencies between the cache and database are acceptable.
    • Does not lock the cache during updates; simply invalidates cache entries after transaction commit.
  3. READ_WRITE:

    • The standard choice for read heavy data that is periodically updated.
    • Uses soft locks: when a transaction updates an entity, it locks the cache entry until commit. If other transactions attempt to read during this window, they fall back to querying the database directly, preventing dirty reads.
  4. TRANSACTIONAL:

    • Provides full JTA (Java Transaction API) distributed transaction isolation.
    • Requires a specialized distributed cache provider (such as Infinispan).

The Query Cache vs Entity Cache

A common misconception is that enabling L2 cache automatically caches query results like findByCategory("electronics").

It does not. By default, L2 cache caches entities only by primary key (findById).

If you execute SELECT p FROM ProductEntity p WHERE p.category = 'books', Hibernate does not check the L2 entity cache for the list of IDs. To cache search results, you must enable the Query Cache:

properties
spring.jpa.properties.hibernate.cache.use_query_cache=true

In your repository:

java
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<ProductEntity> findByCategory(String category);

How the Query Cache Works:

  1. The Query Cache stores only query parameters and a list of matching primary key IDs (e.g. [101, 102, 103]).
  2. When the query executes again, Hibernate retrieves the IDs from the Query Cache, and then looks up each individual entity from the L2 entity cache by ID.

Interview Questions & Pitfalls

Q1: What is the primary difference between Hibernate First Level (L1) and Second Level (L2) cache?

The L1 cache is scoped to an individual EntityManager or Session and is tied to a single database transaction; it is always enabled and private to that thread. The L2 cache is scoped to the EntityManagerFactory and is shared across all sessions, transactions, and threads in the application process; it is disabled by default and requires an external cache provider like Ehcache or Redis.

Q2: Which cache concurrency strategy should be used for reference data that never changes?

Use CacheConcurrencyStrategy.READ_ONLY. It provides the highest performance because Hibernate does not need to maintain lock states or synchronize concurrent modifications. Any attempt to modify a READ_ONLY cached entity will throw an exception.

Q3: Why does enabling second level cache not automatically cache queries like findAll() or findByCategory()?

The standard L2 entity cache stores entities indexed strictly by primary key ID. It has no index on arbitrary query predicates. To cache queries, you must explicitly enable the Hibernate Query Cache (hibernate.cache.use_query_cache=true) and annotate the query with @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true")).

Q4: What is the danger of updating database records directly using raw SQL or external stored procedures when L2 cache is active?

If a process updates database records directly bypassing Hibernate, Hibernate's L2 cache has no awareness of the mutation. The L2 cache will continue serving stale, outdated data to the application until entries expire based on TTL.

Q5: How do you programmatically evict all entities from the second level cache?

You access the Cache interface from the EntityManagerFactory: entityManagerFactory.getCache().evictAll() (evicts all cached entities across all regions) or entityManagerFactory.getCache().evict(ProductEntity.class, productId) (evicts a specific entity instance).