CodeGym /Courses /Hibernate deep-dive /Second-level cache

Second-level cache

Hibernate deep-dive
Level 25, Lesson 2
Available

1. L1 limitations and the role of the L2 cache

If you honestly disabled OSIV (spring.jpa.open-in-view=false) and keep transactions at the service layer, then every new business call most often creates a new persistence context. That is good for architecture and predictability, but there is a side effect: even if you read the same Category five times in a row "in different requests," Hibernate will start from scratch every time. This is exactly the moment when a developer gets the temptation to say: "Can we somehow avoid going to the database again and again?" Second-level cache (L2 cache) is precisely an attempt to give Hibernate some "memory" between different sessions, while doing it in a careful and controlled way.

Imagine that in Commerce Persistence Lab you have a category directory (Category) and it changes once a week, but it is read hundreds of times a day. Within a single transaction, the first-level cache helps only if you repeatedly access the same entity by id. But if you have two different service calls (two different transactions), the first-level cache is no longer involved, because it lives exactly as long as the EntityManager/Session lives.

Second-level cache solves exactly this task: it lives "above" sessions, at the SessionFactory level (roughly, the application level), and can serve entity data across different unit of works. That does not mean "Hibernate will stop going to the database." It means that in some read scenarios it can answer without SQL if the data is already in L2 and is considered current according to the cache rules.

2. Where L2 cache lives

With second-level cache, the problem is most often not the API, but the mental picture. If the mental model is wrong, caching turns either into a "magic acceleration button" or into a source of inexplicable bugs ("why did I update the row, but the application reads the old one?"). We need a calm, engineering-oriented view: L2 cache is a layer between Hibernate and the database that can participate in reading and sometimes writing, but it does not replace transactions, the persistence context, or normal SQL.

Let's draw a simplified chain. It is not meant to cover 100% of the details, but it captures the main idea well:

flowchart TD
    A["Service method (@Transactional)"] --> B["EntityManager / Session"]
    B --> C{"L1 cache (persistence context) has a managed instance?"}
    C -->|Yes| D["Return the same Java object (without SQL)"]
    C -->|No| E{"L2 cache (shared) has entity state?"}
    E -->|Yes| F["Build a managed entity from cached data"]
    E -->|No| G["SQL to DB SELECT ..."]
    G --> H["Store in L1 (and possibly in L2)"]
    F --> H
    H --> D

The key point here is: even if data came from L2, Hibernate still creates a managed instance and puts it into L1 (persistence context). So L2 is not "return the same object that was created yesterday." It is more like "use the stored state so SQL does not need to run, but still work within the current session."

This difference matters, because first-level cache is responsible for the identity map and the lifecycle of managed objects inside a transaction, while second-level cache is responsible for reusing data across transactions. These are different tasks, and if you mix them up, it is roughly like trying to store milk inside a computer case: technically possible, but the smell would be engineeringly disapproved.

3. What L2 cache caches

To prevent L2 cache from turning into a fantasy in the style of "turn it on and everything will become fast," you need to clearly understand its boundaries. In its basic and most common form, second-level cache works as an entity cache by primary key: Hibernate caches entity state so that when this entity is loaded again by id in another session, SQL does not have to run.

Here it is useful to compare first-level and second-level cache in a table. It is short, but it brings things down to earth:

Property First-level cache (L1) Second-level cache (L2)
Where it lives inside Session / EntityManager at the SessionFactory level (roughly "for the whole application")
Lifetime boundary one unit of work (usually one transaction) many unit of works (many transactions)
Main goal identity map + basis for dirty checking reusing data across sessions
What it caches managed objects in memory (and their snapshots) entity state (data), not the Java object itself
"Does it work by itself?" yes, always no, this is a deliberate decision and requires infrastructure

Now about what L2 does not have to do. It does not promise to "cache any JPQL query." If you run a "list of products by status" query, SQL may still execute, because this is not a "load by id," but a full query. Without special mechanisms, Hibernate should not guess that "the result of this query can be taken from cache." It can only use L2 as a data source for entities loaded by key.

That is another reason why in the course we talked so much about read models and projections. Very many real "heavy" reads are not find-by-id, but lists, tables, reports, where + order by + pagination. L2 cache in its pure form helps far from every read scenario.

However, it can be useful for reference data, rarely changing entities, and repeated findById reads. A typical candidate in our domain is Category if you read it by id often and change it rarely. A more controversial candidate is Product, because a product may change more often (price, status, soft delete), and the cost of caching may end up higher than the benefit, especially if you have a lot of invalidation.

4. Freshness and invalidation in L2

When a developer first hears "cache between transactions," they are usually happy. When they first run into stale data, they start to get sad. And this is where an honest conversation matters: any shared cache (and L2 is exactly a shared cache) pays for speed with freshness questions, invalidation, and operational complexity. That is why in engineering practice second-level cache is not "default ON," but a tool that is enabled for a specific load profile and specific data freshness requirements.

Hibernate knows how to invalidate (clear) cache entries when it itself executes an UPDATE/DELETE for the corresponding entity. That looks logical: if Hibernate updated Category, it can evict or update its entry in L2. But there is a nuance that Hibernate documentation constantly emphasizes (and that really shows up in production): if the data changes not through Hibernate (for example, another service, another language, a manual SQL statement in an admin tool, "hot" migrations), the cache does not learn about it automatically.

And this is where the "fun quest" begins with the question: "So how much can I actually cache this data?" If you have one service and one database, and all writes go through the same Hibernate, the situation is simpler. If you have multiple applications writing to one schema, L2 cache becomes more dangerous: it may return slightly stale state, and that is no longer a performance optimization, but a bug generator.

In our training project, we deliberately do not build a distributed landscape, but even in a monolith there are typical causes of staleness. For example, you did a bulk update (yesterday's topic) or you performed a direct SQL update in the lab. A bulk update can bypass managed entities and create a mismatch not only in the first-level cache (stale persistence context), but also in "shared" layers. Therefore the general discipline sounds like this: the more you move away from an entity-oriented model (bulk, native SQL, external writes), the more carefully you need to treat any form of caching.

And that is exactly why in the course we keep repeating the mantra: first fix fetching, query shape, and SQL, and only then consider caching as an amplifier. Cache is not a substitute for correct read design; it is a mechanism for reusing an already well-designed read.

5. CacheMode in Hibernate Session

Now for the practical part, which is useful to know even if L2 cache is not yet enabled in your project. Hibernate gives you the ability to control how the current Session relates to the cache. This is done through CacheMode. And this is a very important idea: caching in Hibernate is not "turn it on once and forget it." For a specific use case, you can say: "read from cache if possible," or "ignore cache," or "reload from the database and update the cache."

The nicest part: you can show this with small and understandable code snippets without setting up huge infrastructure. In Commerce Persistence Lab we often work through EntityManager, but the Hibernate-specific API is available via unwrap(Session.class).

Below is a minimal example where we enable "GET" mode. It means: you may read from cache if something is there. At the same time, the mode itself does not guarantee a cache hit: if there is no cache or it is empty, Hibernate will still go to the database.

import jakarta.persistence.EntityManager;
import org.hibernate.CacheMode;
import org.hibernate.Session;

// Get the Hibernate Session from the JPA EntityManager to control caching at the Hibernate level
Session session = entityManager.unwrap(Session.class);

// Allow reading from L2 (if there is data there), but do not force Hibernate to use "cache only"
session.setCacheMode(CacheMode.GET);

// On a cache hit, SQL may not be needed, but a managed instance will still be created in the current session (L1)
Product product = session.find(Product.class, 1L);

A useful thought: CacheMode.GET is a good "default mental mode" for reads where you do not mind caching, but also do not want to manage it aggressively. It does not make cache mandatory; it makes it allowed.

Now an example of the opposite mode: IGNORE. This is useful when you are doing a diagnostic scenario and want to predictably see SQL, or when you suspect the cache may be stale and you consciously want to bypass it.

import jakarta.persistence.EntityManager;
import org.hibernate.CacheMode;
import org.hibernate.Session;

// Hibernate Session is needed to set CacheMode for a specific unit of work
Session session = entityManager.unwrap(Session.class);

// Completely ignore the L2 cache: Hibernate will work as if the shared cache does not exist
session.setCacheMode(CacheMode.IGNORE);

// Useful for diagnostics: reading is "more honest" and more predictable (most likely SQL will be executed)
PurchaseOrder order = session.find(PurchaseOrder.class, 10L);

And finally, a mode that is often perceived as "anti-magic": REFRESH. The idea is simple: "I do not want to read from cache, I want to read from the database and update the cache with fresh data." This may be needed in admin scenarios, after suspicious updates, or in tests/labs when you want to explicitly synchronize cached state with the real database.

import jakarta.persistence.EntityManager;
import org.hibernate.CacheMode;
import org.hibernate.Session;

// Control cache behavior only within the current Session/transaction
Session session = entityManager.unwrap(Session.class);

// Force reading from the DB and updating the cache state (if L2 is enabled and configured)
session.setCacheMode(CacheMode.REFRESH);

// Useful when guaranteed freshness is needed: read from the DB and update cached state
Product product = session.find(Product.class, 1L);

To keep all this from turning into a mess, it is handy to keep a small table nearby, not as something to memorize, but as something to have at hand:

CacheMode How to think about it in plain English When it fits
GET "Allow cache usage" normal read use cases where a cache hit is possible and not dangerous
IGNORE "Do not touch the cache at all" diagnostics, suspicion of stale data, desire for predictable SQL
REFRESH "Read from DB, update the cache" scenarios where freshness is required, admin operations, controlled sync

Important clarification: even when data comes from L2, it still becomes managed inside the current session and participates in its rules. So CacheMode does not cancel L1; it affects the relationship with L2.

6. Cache candidates: @Cacheable and region

The question that appears immediately is: "Okay, so how does Hibernate understand which entities can be kept in the second level?" The answer is through a combination of two decisions. The first decision is that you mark the entity as a cacheable candidate. The second is that you (or your team) choose which concurrency strategy and which region it is cached in. It is important that without a provider (an external cache implementation) this story will not work for real, but as "reading code" and "understanding intent" this is something you need to know already.

To begin with, the simplest JPA-level option: @Cacheable. It says: "this entity can generally be considered for shared cache." By default, many projects follow a "selective caching" approach so they do not accidentally cache everything.

Here is a tiny fragment of what this may look like on the Category entity in our project:

import jakarta.persistence.Cacheable;
import jakarta.persistence.Entity;

@Entity
@Cacheable // JPA signal: the entity can be considered a candidate for the second-level cache (L2)
public class Category {
    // ...
}

Now the Hibernate-specific level: the @Cache annotation, where you usually specify the strategy and region. Strategy is about how the cache behaves during concurrent changes. Region is simply the name of the "box" where entries of this type are stored. Region matters so the cache stays manageable: you can say "clear the category cache" without touching everything else.

Example (short and illustrative):

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

@Entity
@Cacheable // Allow shared cache at the JPA level
@Cache(
    usage = CacheConcurrencyStrategy.READ_WRITE, // How the cache synchronizes during concurrent changes
    region = "catalog.category" // Explicit region name so it can be managed/cleared selectively later
)
public class Category {
    // ...
}

Why READ_WRITE? In real life, this decision depends on how mutable the entity is and how important consistency is to you. For reference data, READ_ONLY or similar approaches may be a good fit (if the data is truly immutable), but we are not turning this lecture into a course on provider strategies. Our goal is to see that caching is not "flip a flag," but an explicit expression of intent in code: "this data type can be reused across transactions."

And one more important nuance that beginners often confuse. If you marked an entity cacheable, that does not mean that any query to this table will stop going to the database. The cache most often helps specifically when loading by id. Therefore, if your scenario is a product list through a projection or a complex JPQL query, L2 cache may barely participate at all. And that is normal: cache does not have to save every scenario; it helps where the conditions of use match.

7. Cache eviction: evict and discipline

A cache without the ability to evict is not a cache, but a trap. Even if you designed everything perfectly, you still periodically need to be able to say: "evict this entry, let Hibernate reread it from the database." In JPA, this is provided by the Cache API at the EntityManagerFactory level. Hibernate also has its own ways, but the JPA option is clear enough and useful as a baseline.

In a Spring Boot application, it is convenient to keep EntityManagerFactory (or SessionFactory) and perform targeted eviction operations. For example, we can add a small utility component in com.example.commerce.common.jpa (or nearby, wherever similar infrastructural pieces live) and be able to evict a specific Product by id.

Mini example:

import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceUnit;

@PersistenceUnit
private EntityManagerFactory entityManagerFactory; // Inject the factory to get access to the JPA Cache (L2)

public void evictProduct(long productId) {
    // Evict exactly one entry: on the next read, Hibernate/cache provider will have to reread from the DB
    entityManagerFactory.getCache().evict(Product.class, productId);
}

What does this give you in practice? In a training lab, it is a very useful tool for "clearing the magic and seeing reality." In a real application, it is the ability to force cache eviction after administrative operations, migrations, or suspicious bulk updates. At the same time, you should not turn evict() into a daily "band-aid for everything": if you are constantly clearing the cache, then the cache is either not needed, or you are caching the wrong thing, or your data freshness scenarios are described incorrectly.

Another option is a full cache clear. This is truly an emergency button that you want to use rarely, because it can nullify the benefit of caching and temporarily increase load on the database (after clearing, everything will start warming up again).

import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceUnit;

@PersistenceUnit
private EntityManagerFactory entityManagerFactory; // Access to the shared cache at the application level

public void evictAll() {
    // Emergency clear: evict the entire L2 cache (then there will be "warm-up" and more SQL for a while)
    entityManagerFactory.getCache().evictAll();
}

In short: evict is not a "hack," it is an element of cache operational discipline. Any shared cache lives in the real world, where there are manual fixes, unusual scenarios, and a need to quickly restore consistency without restarting the whole application.

8. Typical mistakes when working with second-level cache

Mistake #1: confusing second-level cache with first-level cache and expecting it to preserve object identity.
A very common illusion sounds like this: "If the object was in the cache yesterday, then I will get the same Java instance today." No, that is not how it works. L2 stores data (state), not your object reference. In every new session, Hibernate will still create a managed instance; it may just fill it from cache instead of SQL. If you build logic on == across different transactions, you are building it on sand.

Mistake #2: enabling L2 cache as a "cure for a slow query."
If your product list is slow because of N+1, a wide SELECT, or a heavy JOIN, enabling the second level usually does not make that query "healthy." Often it will keep going to the database just as before. Cache is an amplifier for repeated reads, not a replacement for normal query design. First we fix fetching and the SQL profile, and only then do we consider a shared cache.

Mistake #3: caching frequently changing entities and wondering about endless invalidation.
If an entity is constantly updated, the cache will constantly be cleared or rebuilt. In the end you pay with complexity and get almost no benefit. A typical symptom: you proudly enabled cache for Product, and then you have bulk repricing, frequent status changes, soft delete, auditing — and most of the time the cache is either empty or constantly invalidated. That is not "Hibernate is bad," that is the wrong candidate.

Mistake #4: forgetting about external data changes and getting stale state.
If other applications, manual SQL scripts, or the "wrong" mechanism write to the database, the cache may return old values because it is not telepathic. Hibernate invalidates the cache when it changes data itself, but it cannot know about changes "from the outside" without separate infrastructure. So before caching, you need to answer honestly: "Are we okay with this consistency model?".

Mistake #5: using CacheMode as a magic switch without understanding the scenario.
CacheMode.IGNORE is useful for diagnostics, but if you set it globally "just in case," you are effectively disabling the benefit of L2. CacheMode.REFRESH is useful for forcing freshness, but if you start using it without a reason, you will constantly hit the database while also updating the cache, turning the cache into extra work. CacheMode should be an answer to a specific use case question, not to a developer's anxiety.

1
Task
Hibernate deep-dive, level 25, lesson 2
Locked
Category as a candidate for shared cache and targeted eviction
Category as a candidate for shared cache and targeted eviction
1
Task
Hibernate deep-dive, level 25, lesson 2
Locked
CacheMode selection for reading Product
CacheMode selection for reading Product
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION