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:
- What is the second-level cache, and how does it differ from the first-level cache?
- How do you actually use the second-level cache (with a full example)?
- 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:
- It is on by default and cannot be turned off. It is the foundation on which Hibernate guarantees “object consistency within a single transaction.”
- Its scope is tiny. Once the Session ends (the transaction ends), the cache disappears with it. A new Session, a new request — everything resets, and you have to query the database all over again.
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:
- It is off by default. You must bring in a cache provider (EhCache, Caffeine, Infinispan, Hazelcast…) and configure it manually to enable it.
- You have to annotate the entity with
@Cacheableand specify a concurrency strategy, explicitly telling Hibernate “this entity, and only this entity, should be cached.”
The Differences at a Glance
| Aspect | First-Level Cache | Second-Level Cache |
|---|---|---|
| Scope | Session (single transaction) | SessionFactory (whole application) |
| Lifecycle | Gone when the transaction ends | Persists across transactions/requests |
| On by default? | Yes, and cannot be disabled | No, requires extra setup |
| Shared across Sessions? | No | Yes |
| Needs a provider? | No (built in) | Yes (EhCache, Caffeine…) |
| Typical use | Object consistency in one txn | Caching “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:
| Strategy | When to use |
|---|---|
READ_ONLY | Read-only data (e.g. country codes); best performance |
NONSTRICT_READ_WRITE | Occasionally updated, tolerant of brief inconsistency |
READ_WRITE | Needs updates; keeps consistency via a soft lock; most common |
TRANSACTIONAL | Requires 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):
- First time: the console prints
select ... from product where id = ?, and the data is placed into the second-level cache. - Second time: no SQL is printed — the data comes straight from the second-level cache.
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:
- Reference data: countries, currencies, product categories, permission lists — data that barely changes.
- High read frequency, low write frequency: reads vastly outnumber writes, so the hit rate is high and the benefit is real.
- A single application owning the database: only this service writes to the table, so the cache is unlikely to go stale.
Where It Doesn’t Fit
- Frequently updated data: every write has to invalidate the cache, and the maintenance cost may exceed the queries you save — sometimes it’s even slower.
- Multiple services / multiple nodes writing to the same database: another service changes the database directly, and Hibernate’s second-level cache has no idea, so you get stale, dirty data. This is the second-level cache’s most notorious pitfall.
- Clustered deployments (multiple machines): each node has its own local cache, out of sync with the others. You then have to bring in a distributed cache (Infinispan, Hazelcast) to sync them, and the complexity climbs steeply.
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:
-
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.
-
People switched to “application-layer caching,” and prefer explicit control. The more common approach today is Spring’s
@Cacheablecache 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();
}
-
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.
-
“Measure first, then optimize” has caught on. Many performance issues actually come from N+1 queries or missing indexes — solvable with
JOIN FETCHand 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 first-level cache is scoped to a single Session/transaction, on by default, cannot be disabled, and guarantees object consistency within a transaction.
- ✅ The second-level cache is scoped to the whole SessionFactory, shared across transactions and requests, but requires an extra provider and configuration.
- ✅ Four steps to use it: add the dependency → enable the config → annotate with
@Cacheableand pick a concurrency strategy → verify the hit. - ✅ It suits “read-heavy, single-service-owned” reference data; beware of dirty data when you hit frequent updates, multi-service writes, or clustered deployments.
- ✅ The reason it’s rarely mentioned is that microservices and distributed architectures make its premise hard to satisfy, so people turned to the more explicit and controllable Redis + Spring Cache abstraction approach.
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.