Skip to content
Go back

What Is Hibernate's Second-Level Cache?

Published:  at  08:00 AM

Introduction

When building with Spring Boot + JPA/Hibernate, we often query the same batch of “barely-ever-changing” data over and over — country lists, product categories, system settings. A natural thought arises: could we cache them instead of hitting the database every single time?

The answer is: yes, we can. Hibernate actually ships with two layers of caching: the first-level cache and the second-level cache. The first-level cache is on by default and we rarely even notice it exists; the second-level cache, on the other hand, requires extra setup — and is discussed relatively little these days. This article will help you understand:

  1. What is the second-level cache, and how does it differ from the first-level cache?
  2. How do you actually use the second-level cache (with a full example)?
  3. When should you use it, when should you not, and why do we rarely hear about it now?

The Second-Level Cache

What Is the Second-Level Cache? How Does It Differ from the First-Level Cache?

To understand the second-level cache, we first have to talk about the first-level cache.

First-Level Cache

The first-level cache is scoped to the Hibernate Session (which, in JPA, is the EntityManager’s Persistence Context). Its lifecycle usually matches that of “a single transaction.”

Its behavior: within the same Session, querying the same entity by the same id will not hit the database again after the first time — it returns the cached instance from within the Session directly.

@Transactional
public void demo() {
    // First query: fires a SELECT and hits the database
    User u1 = entityManager.find(User.class, 1L);

    // Second query: same Session, same id — no SQL is fired, returns from cache
    User u2 = entityManager.find(User.class, 1L);

    System.out.println(u1 == u2); // true — they are the exact same instance
}

The first-level cache has two key characteristics:

Aside: How Do You Bypass the First-Level Cache and Force a Re-Query?

Sometimes we need to “skip the cache and hit the database fresh” (for example, when we suspect the data has been modified by someone else). Here are four common approaches:

Approach 1: entityManager.refresh(entity) — re-fires a SELECT for a single entity and overwrites it with the latest values from the database.

User user = entityManager.find(User.class, 1L);
// Someone else may have modified the data
entityManager.refresh(user); // re-fires SELECT, overwriting the current state

Approach 2: entityManager.clear() — clears the entire Persistence Context, so any subsequent query re-hits the database.

User u1 = entityManager.find(User.class, 1L);
entityManager.clear(); // clear the whole Persistence Context
User u2 = entityManager.find(User.class, 1L); // re-fires SELECT

Approach 3: entityManager.detach(entity) — removes only a single entity from the Persistence Context, more precise in scope than clear().

User u1 = entityManager.find(User.class, 1L);
entityManager.detach(u1); // removes just this one
User u2 = entityManager.find(User.class, 1L); // re-fires SELECT

Approach 4: Open a new Session — the first-level cache lives and dies with the Session. Switching to a new Session (a new transaction) naturally gives you a brand-new empty cache, and everything is queried from the database afresh.

Second-Level Cache

The second-level cache is scoped to the SessionFactory (shared across the entire application). Its lifecycle spans multiple Sessions, multiple transactions, and even multiple requests.

In other words, once user A’s request has read a User into the second-level cache, user B’s request (a different Session) querying the same record can hit the cache directly without touching the database. This is exactly what the first-level cache cannot do.

But it isn’t free:

The Differences at a Glance

AspectFirst-Level CacheSecond-Level Cache
ScopeSession (single transaction)SessionFactory (whole application)
LifecycleGone when the transaction endsPersists across transactions/requests
On by default?Yes, and cannot be disabledNo, requires extra setup
Shared across Sessions?NoYes
Needs a provider?No (built in)Yes (EhCache, Caffeine…)
Typical useObject consistency in one txnCaching “read-heavy” shared data

In one sentence: the first-level cache is about “don’t query twice within the same transaction,” while the second-level cache is about “don’t query twice across different transactions.”

How to Use the Second-Level Cache (With an Example)

Below is a full walkthrough using EhCache (integrated via the JCache standard). We’ll assume the project already has Spring Boot + JPA.

Step 1: Add the Dependencies

Hibernate officially recommends integrating a cache provider through the JCache (JSR-107) standard, which makes it easier to swap providers later. Using Maven as an example:

<!-- Hibernate's JCache bridge -->
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
</dependency>

<!-- The actual cache provider: EhCache 3 -->
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <classifier>jakarta</classifier>
</dependency>

Step 2: Turn On the Second-Level Cache Configuration

Enable it in application.properties:

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

# Use JCache as the region factory
spring.jpa.properties.hibernate.cache.region.factory_class=jcache

# (Optional) Enable the query cache, which caches the id list of query results
spring.jpa.properties.hibernate.cache.use_query_cache=true

# (Recommended) Statistics for observing cache-hit behavior
spring.jpa.properties.hibernate.generate_statistics=true

Step 3: Annotate the Entity with @Cacheable

Only annotated entities get placed into the second-level cache. The key point here is the concurrency strategy on @Cache:

import jakarta.persistence.Cacheable;
import jakarta.persistence.Entity;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

@Entity
@Cacheable // Standard JPA annotation marking this entity as cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Hibernate specifies the concurrency strategy
public class Product {

    @Id
    private Long id;

    private String name;

    private BigDecimal price;

    // getters / setters ...
}

Choosing the concurrency strategy is crucial. There are four common ones:

StrategyWhen to use
READ_ONLYRead-only data (e.g. country codes); best performance
NONSTRICT_READ_WRITEOccasionally updated, tolerant of brief inconsistency
READ_WRITENeeds updates; keeps consistency via a soft lock; most common
TRANSACTIONALRequires JTA transaction management; for full transactional isolation

Step 4: Verify the Cache Hit

Write some code that queries across Sessions and watch the SQL log:

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @Transactional(readOnly = true)
    public Product getProduct(Long id) {
        return productRepository.findById(id).orElseThrow();
    }
}

Call getProduct(1L) from two different requests (two different transactions):

If you enabled generate_statistics, you can also confirm the cache is truly working by observing SecondLevelCacheHitCount (the hit count) via the SessionFactory’s Statistics.

A small note: the second-level cache stores the entity’s “disassembled state” (an id mapped to each field’s value), not the whole Java object. On every hit, Hibernate reassembles it into a new entity instance, so what you get across Sessions is not the same object — a difference from the first-level cache.

When Should You Use It? When Should You Not? Why Do We Rarely Hear About It?

Where It Fits

The sweet spot for the second-level cache is “read-heavy, write-light, and safe to share” data:

Where It Doesn’t Fit

Why Do We Rarely Hear About It Now?

It’s not that it’s useless — it’s that the era and the architecture have changed, and its role has been taken over by other solutions:

  1. Microservices + distributed architecture became mainstream. The second-level cache assumes “I own the database and control every write,” but in the microservices world data is often shared across services or changed via events. One instance won’t automatically evict the Hibernate cache on other instances, and the consistency problems make people hesitate.

  2. People switched to “application-layer caching,” and prefer explicit control. The more common approach today is Spring’s @Cacheable cache abstraction paired with Redis. It’s decoupled from the ORM, shareable across services, and lets you own the invalidation strategy (TTL, active evict). It’s also clearer in intent — you know exactly “which piece of logic’s result got cached,” instead of it being buried deep in the ORM. Yes, it means depending on an external service, but this has become the mainstream approach.

// The more common approach today: Spring Cache abstraction + Redis — explicit and controllable
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
    return productRepository.findById(id).orElseThrow();
}
  1. Implicit caching is hard to debug. The second-level cache is hidden inside Hibernate, so when dirty data or performance issues show up, it’s often hard to tell whether the cache is the culprit. An explicit cache layer, by contrast, is more transparent and easier to operate.

  2. “Measure first, then optimize” has caught on. Many performance issues actually come from N+1 queries or missing indexes — solvable with JOIN FETCH and adding indexes, without reaching for a heavy mechanism like the second-level cache.

Wrap-Up

In this article we walked through Hibernate’s second-level cache from the ground up:

The essence of the second-level cache is this: it’s a cache that “assumes every change to your data goes through Hibernate.” Once that assumption holds, it can save you a huge number of queries with almost zero intrusion; once it doesn’t hold, the risk of dirty data it brings will outweigh its benefits. Understand that premise, and you’ll understand both why it was once popular and why it slowly faded from the mainstream.

Of course, under a distributed architecture, not all data belongs in Redis either. A caching tool is only a means; what really needs designing is the data consistency model, the cache invalidation strategy, the data’s lifecycle, and the update flow — not simply replacing Hibernate’s second-level cache with Redis across the board just because the system happens to use microservices.

Whether the second-level cache is a good fit ultimately depends on how much data consistency your business demands. If your system can tolerate briefly stale data and has a proper invalidation strategy (e.g. TTL or event-based notification), then the benefits of the second-level cache may outweigh its risks. Conversely, if every read must return the freshest data, you should evaluate it carefully, or even avoid it.


Suggest Changes
Share this post on:

Previous Post
The Buffer Pool and MySQL's Query Cache
Next Post
Getting Started with React (1): Setup, JSX, and Your First Component