Alireza Tanoomandian
2025-09-05Table of Contents:
If you’ve ever built a Java app that talks to a database, you probably know the pain of writing endless SQL queries and mapping rows to Java objects. That’s where Hibernate comes in. It’s an ORM (Object-Relational Mapping) framework that takes care of all that boilerplate for you. Instead of juggling SQL and Java code, you work with plain Java objects, and Hibernate handles the persistence layer.
Why developers love Hibernate:
At its core, Hibernate saves you from writing repetitive data access code and keeps your app clean and maintainable.
Now, let’s talk Spring Boot. If Hibernate saves you from database headaches, Spring Boot saves you from the headache of configuring and wiring up your application. It’s basically the “batteries included” version of the Spring framework.
With Spring Boot, you don’t have to spend hours setting up XML files or configuring servers. You just add the right starter dependency, and you’re good to go. Spring Boot is the reason many developers can spin up a production-ready Java app in minutes instead of days.
Put Hibernate and Spring Boot together, and you’ve got a powerful combo: Hibernate takes care of your database layer, while Spring Boot gives you the scaffolding to build and run your app smoothly. And with Hibernate’s caching features, you can squeeze out even more performance — which is exactly what we’re going to explore in this post.
Hibernate provides different cache layers to avoid hitting the database more than necessary. The first-level cache is always on and lives within a single Session. A Session in Hibernate represents a unit of work with the database. It is created from a SessionFactory and usually tied to the lifecycle of a single request in a web application. Any entity you load during that session is kept in memory, so repeated fetches of the same object won’t trigger new queries. This is why calling session.get(User.class, 1) twice within the same session only hits the database once. The cache is automatically cleared when the session ends, which happens either when you explicitly close it by calling session.close() or when the framework (for example, Spring) manages the session lifecycle and disposes of it after the request completes. Once the session is closed, all cached entities are gone.
The second-level cache, on the other hand, lives at the SessionFactory level and can be shared across multiple sessions. It is not enabled by default and requires configuration with a provider such as Ehcache, Caffeine, or Redis. This cache is especially useful in read-heavy applications because it allows different sessions to reuse the same data instead of constantly querying the database.
On top of that, Hibernate also offers a query cache, which stores the results of queries rather than the entities themselves. It relies on the second-level cache to actually serve the entities, so it is more of an optimization for frequently executed queries like “top ten products.” Finally, there is an optional natural ID cache that speeds up lookups for entities identified by business keys such as email or username.
In short, the first-level cache works within a single session and is always available, the second-level cache works across sessions and needs explicit setup, while query and natural ID caches are optional layers for special use cases.
In the following, we try to cover the second-level cache and query cache.
At first glance, you might think: “Spring Boot already has a caching abstraction (@EnableCaching, @Cacheable, etc.), so why not just use that?” And that’s a fair question. The difference really comes down to what is being cached and how eviction is handled.
Spring Boot Starter Cache is a general-purpose caching abstraction. It’s designed to let you cache arbitrary method results anywhere in your application. You decide what goes into the cache and when, usually through annotations like @Cacheable, @CacheEvict, and @CachePut. This is great for business logic — for example, caching the result of a slow API call, or storing some computed value for a few minutes. But it doesn’t know anything about Hibernate sessions, persistence contexts, or database entity state. You have to manage consistency yourself.
Hibernate’s second-level cache, on the other hand, is deeply integrated into the ORM layer. It automatically kicks in when you load or update entities, and it ensures that cache entries stay consistent with the database. When you fetch an entity for the first time, Hibernate puts it into the second-level cache. When you update or delete that entity, Hibernate automatically evicts or refreshes the cache entry so you never serve stale data. The eviction process here is tightly coupled to Hibernate’s transaction and flush cycle, meaning you don’t have to sprinkle manual @CacheEvict annotations in your code — Hibernate handles it for you.
Another key difference is scope. With Spring Boot’s caching, you’re caching method results — it’s an application-level concern. With Hibernate second-level cache, you’re caching entities and query results — it’s a database-level concern. This makes Hibernate’s cache a better fit when your primary goal is reducing database load, especially in read-heavy systems where the same entities are fetched repeatedly by different users or requests.
In short:
By focusing on the second-level cache for database-related caching, you get the best of both worlds: automatic entity-level caching with proper eviction and a big drop in database round-trips. You can still use Spring Boot’s caching abstraction in other parts of your app, but for persistence, Hibernate’s built-in cache is the more reliable and specialized tool.
The second-level cache in Hibernate sits above individual sessions and is tied to the SessionFactory. While the first-level cache is cleared as soon as a session closes, the second-level cache can keep data around and make it available to all sessions that are opened from the same factory. This is why it plays such a huge role in performance tuning — it cuts down the number of times Hibernate has to hit the database for commonly accessed entities.
When you load an entity for the first time, Hibernate fetches it from the database and then stores a copy of it in the second-level cache. This process is often called a put because the entity is being placed into the cache. If another session later requests the same entity by its identifier, Hibernate checks the cache first. If the data is present and still valid, it is returned directly from memory without a database round trip.
The cache, of course, is not meant to be permanent storage. Hibernate makes sure that cached entities stay consistent with the database. When an entity is updated or deleted, the second-level cache entry is either updated or removed entirely. This process is called eviction. Depending on the cache provider and the caching strategy you configure (read-only, read-write, nonstrict-read-write, or transactional), the eviction behavior can vary. For example, a read-only cache never allows updates, while a read-write cache will carefully synchronize changes to prevent stale data.
The query cache adds another layer on top of this. Instead of storing entities, it stores the IDs returned by a particular query. For example, if you run select p from Product p where p.category = 'Books', Hibernate can cache the list of matching product IDs. When you run the same query again, Hibernate pulls the IDs from the query cache and then loads the actual entities from the second-level cache. If any of those entities are missing or have been evicted, Hibernate fetches them from the database and repopulates the cache. This two-step approach (query cache + second-level cache) ensures that results are both fast and consistent.
Eviction is just as important for queries as it is for entities. When an entity changes in a way that could affect a cached query, Hibernate invalidates the query cache region so that subsequent queries won’t return stale results. Most cache providers also support time-based eviction (e.g., entries expire after a certain TTL) or size-based eviction (e.g., least recently used entries get cleared when the cache is full).
To sum it up, the second-level cache works by storing entities across sessions, putting them into cache when first loaded, and evicting or refreshing them when updates happen. The query cache complements it by caching lists of entity IDs for repeated queries, relying on the second-level cache to serve the actual objects. Together, they allow Hibernate to serve more requests from memory, cut down database load, and significantly improve performance in read-heavy applications.
After getting familiar with the concept and how it works, let's dive into code. We begin by creating a Spring Boot project and try to connect to a MySQL server with Hibernate. Then, enable the Hibernate second-level cache and check the process.
If you want to skip the coding process or prefer to see the final result, here is the code.
First, have to create a Spring Boot project. I prefer a Gradle-Kotlin setup for the project and continue with it, but you can create the project with your desired setup from Spring Start Page. Here is the final starter code.
For the purpose of this blog post, we need to install these packages and also add lombok to decrease the boilerplate code:
implementation("org.springframework.boot:spring-boot-starter-actuator:3.5.5")
implementation("org.springframework.boot:spring-boot-starter-data-jpa:3.5.5")
implementation("org.springframework.boot:spring-boot-starter-jdbc:3.5.5")
implementation("org.springframework.boot:spring-boot-starter-web:3.5.5")
compileOnly("org.projectlombok:lombok:1.18.38")
runtimeOnly("com.mysql:mysql-connector-j:8.4.0")
annotationProcessor("org.projectlombok:lombok:1.18.38")
testImplementation("org.springframework.boot:spring-boot-starter-test:3.5.5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.13.0")
For the database connection, let's create a Docker Compose file with the name compose.yaml as follows:
services:
mysql:
image: mysql:8.0
container_name: mysql_container
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: hibernate_cache_db
MYSQL_USER: user
MYSQL_PASSWORD: password
ports:
- "3306:3306"
volumes:
- hibernate_cache_mysql_data:/var/lib/mysql
volumes:
hibernate_cache_mysql_data:
And run it with docker compose up -d.
After that, add the following properties to the src/main/resources/application.properties to connect the Spring Boot App to the MySQL database:
spring.application.name=hibernate-cache
server.port=8080
spring.datasource.url=jdbc:mysql://localhost:3306/hibernate_cache_db
spring.datasource.username=user
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# not for production use. in production user none or validate
spring.jpa.hibernate.ddl-auto=update
# for check whether request hits the database or not
spring.jpa.show-sql=true
Finally, you should be able to run the project through your IDE or by running the following:
./gradlew bootRun
note entityConsider that we want to store a table of Notes which includes title, content, and archived as a flag to whether the note is archived or not. Then, add a simple CRUD (Create, Read, Update, Delete) operation with a REST API to interact with it. Here is the final code.
So, first create an entity class for Note in src/main/java/me/artm2000/hibernatecache/database/entity. You should create the packages database.entity if you follow along. Finally, here is the Note.java entity class:
// src/main/java/me/artm2000/hibernatecache/database/entity/Note.java
package me.artm2000.hibernatecache.database.entity;
import jakarta.persistence.*;
import lombok.Data;
@Data
@Entity
@Table(name = "notes")
public class Note {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@Column(nullable = false)
private Boolean archived = false;
}
Next, we're going to create the JpaRepository for the Note entity in src/main/java/me/artm2000/hibernatecache/database/repository with the name NoteRepository.java. Here is the repository code:
// src/main/java/me/artm2000/hibernatecache/database/repository/NoteRepository.java
package me.artm2000.hibernatecache.database.repository;
import me.artm2000.hibernatecache.database.entity.Note;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface NoteRepository extends JpaRepository<Note, Long> {
Optional<Note> findByTitle(String title);
List<Note> findAllByArchived(Boolean archived);
}
After creating the entity and the repository, we're going to create the NoteService interface and NoteServiceImpl as its implementation as follows:
// src/main/java/me/artm2000/hibernatecache/service/NoteService.java
package me.artm2000.hibernatecache.service;
import me.artm2000.hibernatecache.database.entity.Note;
import java.util.List;
public interface NoteService {
Note createNote(Note note);
Note getNoteById(Long id);
Note getNoteByTitle(String title);
List<Note> getAllNonArchivedNotes();
List<Note> getAllNotes();
void updateNoteById(Long id, Note note);
void archiveNoteById(Long id);
void deleteNoteById(Long id);
}
And the implementation:
// src/main/java/me/artm2000/hibernatecache/service/impl/NoteServiceImpl.java
package me.artm2000.hibernatecache.service.impl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.artm2000.hibernatecache.database.entity.Note;
import me.artm2000.hibernatecache.database.repository.NoteRepository;
import me.artm2000.hibernatecache.service.NoteService;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class NoteServiceImpl implements NoteService {
private final NoteRepository noteRepository;
@Override
public Note createNote(Note note) {
return noteRepository.save(note);
}
@Override
public Note getNoteById(Long id) {
return noteRepository.findById(id).orElse(null);
}
@Override
public Note getNoteByTitle(String title) {
return noteRepository.findByTitle(title).orElse(null);
}
@Override
public List<Note> getAllNonArchivedNotes() {
return noteRepository.findAllByArchived(false);
}
@Override
public List<Note> getAllNotes() {
return noteRepository.findAll();
}
@Override
public void updateNoteById(Long id, Note note) {
Optional<Note> existingNote = noteRepository.findById(id);
if (existingNote.isPresent()) {
Note currentNote = existingNote.get();
currentNote.setTitle(note.getTitle());
currentNote.setContent(note.getContent());
currentNote.setArchived(note.getArchived());
noteRepository.save(currentNote);
}
}
@Override
public void archiveNoteById(Long id) {
Optional<Note> existingNote = noteRepository.findById(id);
if (existingNote.isPresent()) {
Note currentNote = existingNote.get();
currentNote.setArchived(true);
noteRepository.save(currentNote);
}
}
@Override
public void deleteNoteById(Long id) {
noteRepository.deleteById(id);
}
}
Now, we can add the controller:
// src/main/java/me/artm2000/hibernatecache/controller/NoteController.java
package me.artm2000.hibernatecache.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.artm2000.hibernatecache.database.entity.Note;
import me.artm2000.hibernatecache.service.NoteService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequiredArgsConstructor
public class NoteController {
private final NoteService noteService;
// create notes
@PostMapping("/v1/notes")
public Note createNote(@RequestBody Note note) {
log.info("Creating note: {}", note);
return noteService.createNote(note);
}
// get one note by id
@GetMapping("/v1/notes/{id}")
public Note getNoteById(@PathVariable Long id) {
log.info("Getting note by id: {}", id);
return noteService.getNoteById(id);
}
// get one note by title
@GetMapping("/v1/notes/search")
public Note getNoteByTitle(@RequestParam String title) {
log.info("Getting note by title: {}", title);
return noteService.getNoteByTitle(title);
}
// get all non-archived notes
@GetMapping("/v1/notes")
public List<Note> getAllNonArchivedNotes() {
log.info("Getting all non-archived notes");
return noteService.getAllNonArchivedNotes();
}
// get all notes
@GetMapping("/v1/notes/all")
public List<Note> getAllNotes() {
log.info("Getting all notes");
return noteService.getAllNotes();
}
// update note by id
@PutMapping("/v1/notes/{id}")
public ResponseEntity<Void> updateNoteById(@PathVariable Long id, @RequestBody Note note) {
log.info("Updating note by id: {}", id);
noteService.updateNoteById(id, note);
return ResponseEntity.noContent().build();
}
// archive note by id
@PatchMapping("/v1/notes/{id}/archive")
public ResponseEntity<Void> archiveNoteById(@PathVariable Long id) {
log.info("Archiving note by id: {}", id);
noteService.archiveNoteById(id);
return ResponseEntity.noContent().build();
}
// delete note by id
@DeleteMapping("/v1/notes/{id}")
public ResponseEntity<Void> deleteNoteById(@PathVariable Long id) {
log.info("Deleting note by id: {}", id);
noteService.deleteNoteById(id);
return ResponseEntity.noContent().build();
}
}
Within the current state, you should be able to send requests and operate with the APIs. As we set spring.jpa.show-sql=true, you should see logs like Hibernate: select n1_0.id,n1_0.archived,n1_0.content,n1_0.title from notes n1_0 where .... for every API call that needs to read something from the database, and some others for other database operation hits.
The Hibernate second-level cache is only the API layer — by itself, it doesn’t actually store anything. To make it work, you need to plug in a cache provider. Hibernate supports several popular options such as Ehcache, Infinispan, Caffeine, and Redis.
If you’re running a single-instance application, an in-memory cache like Ehcache or Caffeine is often enough. But in modern deployments, where applications usually run on multiple instances behind a load balancer, you need a distributed cache. Otherwise, each instance has its own isolated cache, and you lose the benefit of shared memory across nodes. That’s where Redis shines.
Redis is an open-source, in-memory data store that is blazing fast and battle-tested in production. It can act as a centralized cache for all your app instances, ensuring that no matter which instance serves a request, the cache is consistent. With libraries like Redisson, you can integrate Redis with Hibernate’s second-level cache without too much hassle.
The easy-to-use way of connecting Redis as a Hibernate 2nd-level cache provider is to use the redisson-hibernate package, which has support for Hibernate CacheFactory by default. You have to install the following dependency to bring this package to the project:
implementation("org.redisson:redisson-hibernate-6:3.50.0")
It's important that the redisson-hibernate-x version follows the current Hibernate package version. So, if you're reading this blog post and you're using a different Hibernate version, you have to install the proper version with your Hibernate package version.
To enable Hibernate second-level cache, you have to set these properties too:
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.use_query_cache=true
And to use Redisson region factory, you have to set this, too:
spring.jpa.properties.hibernate.cache.region.factory_class=org.redisson.hibernate.RedissonRegionFactory
For a more performance boost, you can also tell Hibernate to try to minimize the cache put operation with the following:
spring.jpa.properties.hibernate.cache.use_minimal_puts=true
Here are the properties to enable default properties in one place:
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.use_query_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.redisson.hibernate.RedissonRegionFactory
spring.jpa.properties.hibernate.cache.use_minimal_puts=true
To enable cache on an entity, you have to add the following annotation to the entity:
// src/main/java/me/artm2000/hibernatecache/database/entity/Note.java
package me.artm2000.hibernatecache.database.entity;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
// ...
@Data
@Entity
@Table(name = "notes")
@Cache(
usage = CacheConcurrencyStrategy.READ_WRITE // eviction strategy
)
public class Note {
// ...
}
There are 4 types of eviction strategies:
Read-Only
Read-Write
Nonstrict-Read-Write
Transactional
You can pick one of them based on your requirement.
Hibernate by default converts the entity class reference to its region name (e.g., me.artm2000.hibernatecache.database.entity.Note for Note entity). You can customize it with @Cache and pass the region value to it as below:
// src/main/java/me/artm2000/hibernatecache/database/entity/Note.java
package me.artm2000.hibernatecache.database.entity;
...
@Cache(
usage = CacheConcurrencyStrategy.READ_WRITE,
region = "entity.notes" // new region name
)
public class Note {
// ...
}
In this example, we set the region name to entity.note.
Here is an example for enabling the Hibernate query cache on NoteRepository:
// src/main/java/me/artm2000/hibernatecache/database/repository/NoteRepository.java
package me.artm2000.hibernatecache.database.repository;
// ...
import jakarta.persistence.QueryHint;
import org.springframework.data.jpa.repository.QueryHints;
// ...
@Repository
public interface NoteRepository extends JpaRepository<Note, Long> {
@QueryHints({
@QueryHint(name = "org.hibernate.cacheable", value = "true"), // enable query cache for this query
@QueryHint(name = "org.hibernate.cacheRegion", value = "query.findNotesByTitle") // set region name
})
Optional<Note> findByTitle(String title);
@Override // add query cache on default query methods
@QueryHints({
@QueryHint(name = "org.hibernate.cacheable", value = "true"),
@QueryHint(name = "org.hibernate.cacheRegion", value = "query.findAllNotes")
})
List<Note> findAll();
@QueryHints({
@QueryHint(name = "org.hibernate.cacheable", value = "true"),
@QueryHint(name = "org.hibernate.cacheRegion", value = "query.findAllNotesByArchived")
})
List<Note> findAllByArchived(Boolean archived);
}
It's totally recommended to set a region name as much as possible and prevent using the default region name for more control and efficiency.
By default, org.redisson.hibernate.RedissonRegionFactory reads its configuration from src/main/resources/redisson.json or src/main/resources/redisson.yaml, and you can configure it with this file. Here is more info.
It should work if you have the same properties for different deployments and running environments, but unfortunately, it's not a common case, and the connection properties, credentials, host, and many more parameters are different for each environment. So, suppose you plan to run the application with different profiles (e.g., local, demo, or production). In that case, you will face some problems as this region factory did not load in the scope of the Spring Boot app, and the related profile properties did not affect anything.
So, to solve this issue, you can create a new RegionFactory class that extends the RedissonRegionFactory and override the createRedissonClient method with some tricks to load properties from application.properties or load the active profile configuration.
But before all, we need a Redisson client to load the configuration from our desired source, like application.properties. As it's clean to use this configuration as Bean for further Redis use cases, we create configuration class as follows, with a static method to build a client and use it in a bean method:
// src/main/java/me/artm2000/hibernatecache/common/config/RedisConfig.java
package me.artm2000.hibernatecache.common.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host:#{null}}")
private String redisHost;
@Value("${spring.data.redis.port:#{null}}")
private String redisPort;
@Value("${spring.data.redis.database:0}")
private int redisDatabase;
@Value("${spring.data.redis.password:#{null}}")
private String redisPassword;
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
return getRedissonClient(redisHost, redisPort, redisDatabase, redisPassword, false);
}
public static RedissonClient getRedissonClient(String host, String port, int database, String password, boolean useSsl) {
Config config = new Config();
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress((useSsl ? "rediss://" : "redis://") + host + ":" + port)
.setDatabase(database)
.setKeepAlive(true);
if (!password.isBlank()) {
serverConfig.setPassword(password);
}
return Redisson.create(config);
}
}
Then use the getRedissonClient static method in a new class with the name CustomizeRegionFactory that extends Redisson's RedissonRegionFactory class to override the createRedissonClient to create a Redisson client in our own way:
// src/main/java/me/artm2000/hibernatecache/common/CustomizeRegionFactory.java
package me.artm2000.hibernatecache.common;
import me.artm2000.hibernatecache.common.config.RedisConfig;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.redisson.api.RedissonClient;
import org.redisson.hibernate.RedissonRegionFactory;
import java.util.Map;
public class CustomizeRegionFactory extends RedissonRegionFactory {
@Override
@SuppressWarnings("unchecked")
protected RedissonClient createRedissonClient(StandardServiceRegistry registry, Map properties) {
String host = (String) properties.getOrDefault("spring.data.redis.host", "localhost");
String port = (String) properties.getOrDefault("spring.data.redis.port", "6379");
String databaseStr = (String) properties.getOrDefault("spring.data.redis.database", "0");
String password = (String) properties.getOrDefault("spring.data.redis.password", "");
return RedisConfig.getRedissonClient(host, port, Integer.parseInt(databaseStr), password, false);
}
}
I used the spring.data.redis.* properties, but you can use different keys.
After all sets, we have to change the spring.jpa.properties.hibernate.cache.region.factory_class property to our new class: me.artm2000.hibernatecache.common.CustomizeRegionFactory. Like below:
spring.jpa.properties.hibernate.cache.region.factory_class=me.artm2000.hibernatecache.common.CustomizeRegionFactory
We need to make some changes to our Docker Compose file to have a Redis instance running. Here is the final compose.yaml file:
services:
mysql:
image: mysql:8.0
container_name: mysql_container
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: hibernate_cache_db
MYSQL_USER: user
MYSQL_PASSWORD: password
ports:
- "3306:3306"
volumes:
- hibernate_cache_mysql_data:/var/lib/mysql
# add this part to the last compose.yaml file
redis:
image: redis:8
container_name: redis_container
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
hibernate_cache_mysql_data:
redis_data:
You're now able to run the Spring Boot application with the following properties and see everything works together:
# src/main/resources/application.properties
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.password=
spring.data.redis.database=0
Here is the final code for this part.
Assume that you use only one profile per deployment (e.g., local, demo, or production). We have to check the active profile (if it exists) and load the related properties or config file (like YAML, as your preference). So, let's add the following method to the src/main/java/me/artm2000/hibernatecache/common/CustomizeRegionFactory.java:
// src/main/java/me/artm2000/hibernatecache/common/CustomizeRegionFactory.java
@SuppressWarnings("rawtypes,unchecked")
private void loadActiveProfile(Map properties) {
String activeProfile = System.getProperty("spring.profiles.active");
if (activeProfile == null || activeProfile.isBlank()) {
activeProfile = System.getenv("SPRING_PROFILES_ACTIVE");
}
if (activeProfile == null) return;
try {
for (String property : Arrays.stream((new ClassPathResource(
String.format(
"application-%s.properties",
activeProfile
)))
.getContentAsString(StandardCharsets.UTF_8)
.split("\n"))
.filter(line -> line.trim().contains("=") && !line.trim().startsWith("#"))
.toList())
{
String[] keyValue = property.trim().split("=", 2);
properties.put(keyValue[0], keyValue[1]);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
As you can see, the loadActiveProfile method receives the properties map and tries to put the properties for the active profile into it from application-<profile>.properties. Now, if there is no related properties file for the active profile, it throws an error as expected. You could change this behavior if you want.
Next, you have to call this method at the beginning of the createRedissonClient method, like below:
// src/main/java/me/artm2000/hibernatecache/common/CustomizeRegionFactory.java
protected RedissonClient createRedissonClient(StandardServiceRegistry registry, Map properties) {
loadActiveProfile(properties);
// ....
}
For other property file types like YAML, you have to replace the logic with your own.
Here is the stage final code. You have to run this with spring.profiles.active=local from IDE or with the following command:
./gradlew bootRun --args='--spring.profiles.active=local'
With all these setups, we are now able to use Hibernate's second-level cache on API calls and further use cases. Start with creating a note:
curl -X POST "http://localhost:8080/v1/notes" \
-H 'Content-Type: application/json' \
--data '{"title":"note 1", "content":"this is first content"}'
Now, if you fetch the note via:
curl "http://localhost:8080/v1/notes"
You will see this log in your running application that demonstrates Hibernate hits the database to resolve the query:
... Getting all non-archived notes
Hibernate: select n1_0.id,n1_0.archived,n1_0.content,n1_0.title from notes n1_0 where n1_0.archived=?
But, here is MAGIC. If you do the same action, you will receive the response way more faster than before, and no Hibernate SQL query log appears. You can test it yourself and see the result.
Let's add another note:
curl -X POST "http://localhost:8080/v1/notes" \
-H 'Content-Type: application/json' \
--data '{"title":"note 2", "content":"this is second content"}'
By trying to get notes again, you see that, as the related cache had been evicted, another Hibernate SQL query log showed up, and by doing the same, the new result was fetched from cache, and no database hit occurred.
While using the Hibernate second-level cache will help you in a read-heavy application and remove the cost of manual cache eviction, there are some points that you should be aware of:
The 2nd-level cache reduces database hits, but it doesn’t replace the database.
Hibernate invalidates cached entities or query results when you update data.
UpdateTimestampsCache, so any update on a table flushes related query results.If you need strong consistency, be careful — cache + DB might get out of sync in clustered deployments unless you use a distributed cache like Redis, Infinispan, or Hazelcast.
Each Hibernate Session has its own first-level cache, which is mandatory and cannot be disabled.
Don’t mark everything as cacheable.
The query cache doesn’t cache full entities, only IDs of matching rows.
<region-name> with your target region name):spring.jpa.properties.hibernate.cache.redisson.<region-name>.eviction.max_entriesspring.jpa.properties.hibernate.cache.redisson.<region-name>.expiration.time_to_livespring.jpa.properties.hibernate.cache.redisson.<region-name>.expiration.max_idle_timequery, collection, and naturalid as you could set for region configuration.Caching works differently for collections (@OneToMany, @ManyToMany).
@Cache.If many requests load the same entity (like a Currency or Country object), that’s perfect for cache.
But if you have batch inserts/updates, you might invalidate the cache too frequently — negating the benefit.
hibernate.generate_statistics=true) and check hit/miss ratios.Last word, Hibernate’s 2nd-level cache is a performance optimization, not a guaranteed speed-up. Use it selectively for stable, read-heavy data. Choose the right provider (Redis for clusters, Ehcache/Caffeine for single-node), tune eviction policies, and always monitor hit ratios to make sure it’s helping, not hurting.
Hope you enjoy this content, and it helps you with better development and maintenance.
Alireza Tanoomandian
2025-09-05