Hibernate Second-Level Cache with Valkey or Redis

Published on
July 10, 2026

Most Hibernate applications read the same data over and over. Product catalogs, country lists, pricing tiers, permission sets — reference data that barely changes gets fetched from the database on nearly every request. Each of those round trips costs a connection, a query plan, and network latency, and under load they add up to a database that spends most of its time answering questions it has already answered.

Hibernate's second-level cache exists to stop exactly this. But the cache is only as good as the provider behind it, and the default choice most teams reach for — a local, in-process cache — quietly breaks down the moment you run more than one application instance. This guide shows how to back a Hibernate second-level cache with Redis or Valkey using Redisson, with a complete Spring Boot example you can run, an honest look at the four concurrency strategies, and a breakdown of which cache factory to choose.

Quick Start

In a hurry? Here is the whole setup for a Spring Boot 3 / Hibernate 6 application. The rest of the article explains each piece and shows how to scale and tune it.

Add the dependency:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-hibernate-6</artifactId>
    <version>4.6.1</version>
</dependency>

Turn on the second-level cache in application.yml and point it at Redisson:

spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          use_query_cache: true
          region:
            factory_class: org.redisson.hibernate.RedissonRegionFactory
          redisson:
            config: redisson.yaml
            fallback: true

Tell Redisson how to reach Redis or Valkey in redisson.yaml:

singleServerConfig:
  address: "redis://127.0.0.1:6379"

Mark the entities you want cached:

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "products")
public class Product { /* ... */ }

That is a working shared second-level cache. Read on to understand the concurrency strategies, prove the cache is being hit, choose the right region factory, and tune it per region.

First-Level vs. Second-Level Cache

Hibernate caches at two levels, and they do very different jobs.

The first-level cache (L1) is tied to the Hibernate Session. It is mandatory, enabled by default, and cannot be turned off. While a session is open, the first time you load an entity Hibernate keeps it in the session; load the same entity again within that session and it comes back without touching the database. The catch is lifetime and scope: the L1 cache lives and dies with a single session, and no other session can see it. In a typical web request that maps to one session, so the L1 cache rarely helps across requests.

The second-level cache (L2) is tied to the SessionFactory, which lives for the life of the application. It is optional and disabled by default. Once configured, it is shared across every session the factory creates — and, crucially, if you back it with Redis or Valkey, it is shared across every application instance too. That shared scope is what makes the second-level cache worth setting up: an entity loaded by one request, on one server, can be served from cache to a completely different request on a different server.

Why Redis or Valkey for the Second-Level Cache

The textbook second-level cache provider is a local, in-process cache like Ehcache. It works, and for a single-instance application it is perfectly fine. The problem appears the moment you scale out.

A local cache lives inside the JVM, so every application instance keeps its own private copy. Run four instances and you have four separate caches that fill independently, drift out of sync as data changes, and cold-start from scratch every time you add a node or deploy. To make a local cache coherent across instances you have to bolt on a clustering layer (Ehcache, for example, distributes through a separate Terracotta server), which is one more moving part to run and operate.

Redis and Valkey invert that model. The cache lives outside your application as a shared store that every instance reads from and writes to. There is one source of truth, so instances cannot disagree; the cache survives application restarts and redeploys; and a newly added node is warm immediately because it shares the same cache as everyone else. If you are running a clustered or autoscaled service — which is most production Java today — a shared cache is not a nice-to-have, it is the only model that stays consistent.

The Four Cache Concurrency Strategies

When you mark an entity as cacheable you choose a concurrency strategy that tells Hibernate how aggressively it can trust and update the cache. Redisson supports all four. Choosing the right one is mostly a question of how the data changes.

READ_ONLY is for data that never changes once it is in the cache — reference tables, lookup values, historical records. It is the fastest strategy because Hibernate never has to coordinate updates. Attempting to modify a READ_ONLY entity throws an exception, which is a feature: it documents intent and catches mistakes.

NONSTRICT_READ_WRITE suits data that is read far more than it is written and where a brief window of staleness is acceptable. Hibernate evicts the cached entry when a transaction modifies it rather than locking and updating in place, so two transactions can momentarily see different values. It offers eventual consistency, not strong consistency — a good fit for things like product descriptions that are edited occasionally and where a few seconds of staleness costs nothing.

READ_WRITE is the strategy to reach for when you need strong consistency on data that is updated regularly. Hibernate uses soft locks to hold control of an entry until the modifying transaction commits, so readers never see a partially updated value. It costs more coordination than the nonstrict option but keeps the cache correct.

TRANSACTIONAL is for full data integrity inside a JTA/XA environment, where the cache participates in distributed transactions alongside the database. It is the strongest and the most demanding, and it requires a transaction manager. Reach for it only when you genuinely need transactional guarantees across the cache and database together.

A reasonable default for mutable business entities is READ_WRITE; use READ_ONLY for anything immutable, and NONSTRICT_READ_WRITE when you want a little more speed and can tolerate eventual consistency.

Adding Redisson to a Spring Boot Project

Redisson ships a separate Hibernate module for each Hibernate generation, so the first step is matching the dependency to your Hibernate version.

Hibernate versionRedisson artifact
4.xredisson-hibernate-4
5.0.x – 5.1.xredisson-hibernate-5
5.2.xredisson-hibernate-52
5.3.3+ – 5.6.xredisson-hibernate-53
6.0.2+ – 6.xredisson-hibernate-6
7.0.x – 7.1.xredisson-hibernate-7
7.2.x – 7.4.xredisson-hibernate-72

A current Spring Boot 3 application uses Hibernate 6, so the example below uses redisson-hibernate-6. With Maven:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-hibernate-6</artifactId>
    <version>4.6.1</version>
</dependency>

Or with Gradle:

implementation 'org.redisson:redisson-hibernate-6:4.6.1'

Two notes on the coordinates. First, check Maven Central for the latest version — Redisson releases frequently, and you want a recent 4.x build. Second, the open-source modules live under the org.redisson group; Redisson PRO publishes the same modules under pro.redisson, and switching editions is a one-line change to the groupId. More on what PRO adds further down.

Configuring the Second-Level Cache

Configuration has two parts: turn on Hibernate's second-level cache and point it at a Redisson region factory, then give Redisson a config file describing how to connect to Redis or Valkey.

In a Spring Boot application.yml:

spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          use_query_cache: true
          region:
            factory_class: org.redisson.hibernate.RedissonRegionFactory
          redisson:
            config: redisson.yaml
            fallback: true
        generate_statistics: true   # so we can see cache hits in the demo

Then add redisson.yaml to src/main/resources. The simplest possible single-node setup:

singleServerConfig:
  address: "redis://127.0.0.1:6379"

That redis:// address points at Valkey just as happily as Redis. For a cluster you would use clusterServersConfig with several node addresses instead; the Redisson reference guide covers every topology.

The fallback: true line is worth calling out now and we will return to it at the end: it tells Redisson that if the cache is temporarily unreachable, Hibernate should fall back to the database rather than failing the request. It turns a cache outage into a performance dip instead of an outage of your application.

Marking Entities as Cacheable

Enabling the cache does nothing until you tell Hibernate which entities to cache. Annotate the entity with @Cache, pick a concurrency strategy, and give it a region name so you can tune it independently later:

import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import java.math.BigDecimal;

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "products")
public class Product {

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

    private String name;

    private BigDecimal price;

    // constructors, getters, and setters omitted
}

To cache query results as well as entities — so that repeating the same query skips the database entirely — enable the query cache (we already set use_query_cache: true above) and mark the individual query as cacheable. In a Spring Data JPA repository that is a query hint:

import org.springframework.data.jpa.repository.*;
import jakarta.persistence.QueryHint;
import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long> {

    @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
    List<Product> findByName(String name);
}

The query cache stores identifiers, not full entities, and relies on the entity cache to resolve them — which is why you generally enable both together.

Choosing a Region Factory: Open-Source vs. PRO

RedissonRegionFactory in the example is the baseline implementation, and for many applications it is all you need. But Redisson ships a family of region factories, and the more demanding your workload, the more the others earn their place. Here are the ones worth knowing.

Region factoryLocal (near) cacheData partitioningEvictionEdition
RedissonRegionFactoryScriptedOpen-source
RedissonRegionNativeFactoryNative*Open-source
RedissonLocalCachedRegionFactoryScriptedPRO
RedissonRegionV2FactoryAdvancedPRO
RedissonLocalCachedV2RegionFactoryAdvancedPRO
RedissonClusteredRegionFactoryScriptedPRO
RedissonClusteredLocalCachedRegionFactoryScriptedPRO

* Native eviction lets the data store expire entries itself and requires Valkey 9.0+ or Redis 7.4+.

Three capabilities drive that table:

Local (near) cache keeps a copy of hot entries inside the application process, on top of the shared cache in Redis or Valkey. Reads that hit the local copy skip the network entirely — Redisson measures these at up to roughly 5x faster than a standard remote read. Instances stay coordinated through a pub/sub channel that broadcasts invalidations, so a change on one node clears the stale local copy everywhere. If your second-level cache is read-heavy and network round trips are your bottleneck, a local-cached factory is the single biggest win available.

Data partitioning spreads one logical cache region across multiple master nodes in a Redis or Valkey cluster. Instead of a single region being bound to a single node's memory and throughput, partitioning scales the region's capacity, read/write operations, and eviction work across the whole cluster — important once a region grows beyond what one node should hold.

Native eviction hands expiration to the data store itself rather than running a scheduled cleanup task on the Redisson side, which removes overhead on large caches. It needs a recent server (Valkey 9.0+ or Redis 7.4+).

The open-source factories give you a fully functional shared second-level cache. The PRO factories are about scale and latency: near-cache reads, cluster-wide partitioning, and the local-cache/partitioning/native combinations for the heaviest workloads. Switching is a configuration change — point factory_class at a different class (and use the pro.redisson dependency) — so you can start on open source and move up when the workload demands it.

Tuning the Cache per Region

Because each cached entity has a region name, you can tune regions independently — a volatile region can expire quickly while a stable one lives long. Redisson exposes these as hibernate.cache.redisson.<region>.<setting> properties. For the products region from earlier:

spring:
  jpa:
    properties:
      hibernate:
        cache:
          redisson:
            products:
              eviction:
                max_entries: 10000          # cap the region size
              expiration:
                time_to_live: 600000        # 10 minutes, in ms
                max_idle_time: 300000       # evict if untouched for 5 minutes

When you move to a local-cached factory, an additional localcache block controls the in-process layer — its size, eviction policy, and how long local copies live before they are re-validated. The Redisson reference guide lists every available property and its default; treat the snippet above as a starting point and consult the docs for the complete, current set.

Reliability: Surviving a Cache Outage

A shared cache introduces a dependency, and the honest question is what happens when that dependency is briefly unavailable — a failover, a network blip, a rolling Redis upgrade. Without protection, cache calls would fail and take requests down with them.

That is what the fallback setting addresses:

spring:
  jpa:
    properties:
      hibernate:
        cache:
          redisson:
            fallback: true

With fallback enabled, if Redis or Valkey cannot be reached Redisson lets Hibernate go straight to the database instead of throwing. You lose the cache's performance benefit for the duration of the outage, but the application keeps serving correct results. A cache problem degrades performance rather than causing an outage — which is exactly the behavior you want from a cache.

Hibernate Second-Level Cache: The Bottom Line

A Hibernate second-level cache turns repeated reads into memory lookups, and backing it with Redis or Valkey makes that cache shared, consistent, and durable across every instance of your application — something a local in-process cache cannot offer once you scale past a single node. With Redisson the setup is a dependency, a few configuration properties, and a @Cache annotation, and you can confirm it works in a few lines of code.

From there, the path scales with you: start on the open-source region factory, and reach for local caching, data partitioning, and native eviction in Redisson PRO as your read volume and cluster size grow. For the full list of region factories, properties, and supported topologies, see the Cache API implementations section of the Redisson reference guide, and if you want the PRO factories, you can start a free trial.