How does Hibernate READ_ONLY CacheConcurrencyStrategy work
Are you struggling with performance issues in your Spring, Jakarta EE, or Java EE application?
What if there were a tool that could automatically detect what caused performance issues in your JPA and Hibernate data access layer?
Wouldn’t it be awesome to have such a tool to watch your application and prevent performance issues during development, long before they affect production systems?
Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, Micronaut, or Play Framework.
So, rather than fixing performance issues in your production system on a Saturday night, you are better off using Hypersistence Optimizer to help you prevent those issues so that you can spend your time on the things that you love!
Introduction
As I previously explained, enterprise caching requires diligence. Because data is duplicated between the database (system of record) and the caching layer, we need to make sure the two separate data sources don’t drift apart.
If the cached data is immutable (neither the database nor the cache is able to modify it), we can safely cache it without worrying about any consistency issues. Read-only data is always a good candidate for application-level caching, improving read performance without having to relax consistency guarantees.
Read-only second-level caching
For testing the read-only second-level cache strategy, we going to use the following domain model:
The Repository is the root entity, being the parent of any Commit entity. Each Commit has a list of Change components (embeddable value types).
All entities are cached as read-only elements:
@org.hibernate.annotations.Cache(
usage = CacheConcurrencyStrategy.READ_ONLY
)
Persisting entities
In Hibernate 4, the read-only second-level cache uses a read-through caching strategy, entities being cached upon fetching.
doInTransaction(session -> {
Repository repository =
new Repository("High-Performance Hibernate");
session.persist(repository);
});
When an entity is persisted only the database contains a copy of this entity. The system of record is passed to the caching layer when the entity gets fetched for the first time.
@Test
public void testRepositoryEntityLoad() {
LOGGER.info("Read-only entities are read-through");
doInTransaction(session -> {
Repository repository = (Repository)
session.get(Repository.class, 1L);
assertNotNull(repository);
});
doInTransaction(session -> {
LOGGER.info("Load Repository from cache");
session.get(Repository.class, 1L);
});
}
This test generates the output:
--Read-only entities are read-through
SELECT readonlyca0_.id AS id1_2_0_,
readonlyca0_.NAME AS name2_2_0_
FROM repository readonlyca0_
WHERE readonlyca0_.id = 1
--JdbcTransaction - committed JDBC Connection
--Load Repository from cache
--JdbcTransaction - committed JDBC Connection
Once the entity is loaded into the second-level cache, any subsequent call will be served by the cache, therefore bypassing the database.
In Hibernate 5, READ_ONLY entities are write-through when using a SEQUENCE or a TABLE generator, while they are read-through for IDENTITY generator.
Updating entities
Read-only cache entries are not allowed to be updated. Any such attempt ends up with an exception being thrown:
@Test
public void testReadOnlyEntityUpdate() {
try {
LOGGER.info("Read-only cache entries cannot be updated");
doInTransaction(session -> {
Repository repository = (Repository)
session.get(Repository.class, 1L);
repository.setName(
"High-Performance Hibernate"
);
});
} catch (Exception e) {
LOGGER.error("Expected", e);
}
}
Running this test generates the following output:
--Read-only cache entries cannot be updated
SELECT readonlyca0_.id AS id1_2_0_,
readonlyca0_.NAME AS name2_2_0_
FROM repository readonlyca0_
WHERE readonlyca0_.id = 1
UPDATE repository
SET NAME = 'High-Performance Hibernate'
WHERE id = 1
--JdbcTransaction - rolled JDBC Connection
--ERROR Expected
--java.lang.UnsupportedOperationException: Can't write to a read-only object
Because read-only cache entities are practically immutable it’s good practice to attribute them the Hibernate specific @Immutable annotation.
Deleting entities
Read-only cache entries are removed when the associated entity is deleted as well:
@Test
public void testReadOnlyEntityDelete() {
LOGGER.info("Read-only cache entries can be deleted");
doInTransaction(session -> {
Repository repository = (Repository)
session.get(Repository.class, 1L);
assertNotNull(repository);
session.delete(repository);
});
doInTransaction(session -> {
Repository repository = (Repository)
session.get(Repository.class, 1L);
assertNull(repository);
});
}
Generating the following output:
--Read-only cache entries can be deleted
SELECT readonlyca0_.id AS id1_2_0_,
readonlyca0_.NAME AS name2_2_0_
FROM repository readonlyca0_
WHERE readonlyca0_.id = 1;
DELETE FROM repository
WHERE id = 1
--JdbcTransaction - committed JDBC Connection
SELECT readonlyca0_.id AS id1_2_0_,
readonlyca0_.NAME AS name2_2_0_
FROM repository readonlyca0_
WHERE readonlyca0_.id = 1;
--JdbcTransaction - committed JDBC Connection
The remove entity state transition is enqueued by PersistenceContext, and at flush time, both the database and the second-level cache will delete the associated entity record.
Collection caching
The Commit entity has a collection of Change components.
@ElementCollection
@CollectionTable(
name="commit_change",
joinColumns=@JoinColumn(name="commit_id")
)
private List<Change> changes = new ArrayList<>();
Although the Commit entity is cached as a read-only element, the Change collection is ignored by the second-level cache.
@Test
public void testCollectionCache() {
LOGGER.info("Collections require separate caching");
doInTransaction(session -> {
Repository repository = (Repository)
session.get(Repository.class, 1L);
Commit commit = new Commit(repository);
commit.getChanges().add(
new Change("README.txt", "0a1,5...")
);
commit.getChanges().add(
new Change("web.xml", "17c17...")
);
session.persist(commit);
});
doInTransaction(session -> {
LOGGER.info("Load Commit from database");
Commit commit = (Commit)
session.get(Commit.class, 1L);
assertEquals(2, commit.getChanges().size());
});
doInTransaction(session -> {
LOGGER.info("Load Commit from cache");
Commit commit = (Commit)
session.get(Commit.class, 1L);
assertEquals(2, commit.getChanges().size());
});
}
Running this test generates the following output:
--Collections require separate caching
SELECT readonlyca0_.id AS id1_2_0_,
readonlyca0_.NAME AS name2_2_0_
FROM repository readonlyca0_
WHERE readonlyca0_.id = 1;
INSERT INTO commit
(id, repository_id)
VALUES (DEFAULT, 1);
INSERT INTO commit_change
(commit_id, diff, path)
VALUES (1, '0a1,5...', 'README.txt');
INSERT INTO commit_change
(commit_id, diff, path)
VALUES (1, '17c17...', 'web.xml');
--JdbcTransaction - committed JDBC Connection
--Load Commit from database
SELECT readonlyca0_.id AS id1_2_0_,
readonlyca0_.NAME AS name2_2_0_
FROM repository readonlyca0_
WHERE readonlyca0_.id = 1;
SELECT changes0_.commit_id AS commit_i1_0_0_,
changes0_.diff AS diff2_1_0_,
changes0_.path AS path3_1_0_
FROM commit_change changes0_
WHERE changes0_.commit_id = 1
--JdbcTransaction - committed JDBC Connection
--Load Commit from cache
SELECT changes0_.commit_id AS commit_i1_0_0_,
changes0_.diff AS diff2_1_0_,
changes0_.path AS path3_1_0_
FROM commit_change changes0_
WHERE changes0_.commit_id = 1
--JdbcTransaction - committed JDBC Connection
Although the Commit entity is retrieved from the cache, the Change collection is always fetched from the database. Since the Changes are immutable too, we would like to cache them as well, to save unnecessary database round-trips.
Enabling Collection cache support
Collections are not cached by default, and to enable this behavior, we have to annotate them with the cache concurrency strategy:
@ElementCollection
@CollectionTable(
name="commit_change",
joinColumns=@JoinColumn(name="commit_id")
)
@org.hibernate.annotations.Cache(
usage = CacheConcurrencyStrategy.READ_ONLY
)
private List<Change> changes = new ArrayList<>();
Re-running the previous test generate the following output:
--Collections require separate caching
SELECT readonlyca0_.id AS id1_2_0_,
readonlyca0_.NAME AS name2_2_0_
FROM repository readonlyca0_
WHERE readonlyca0_.id = 1;
INSERT INTO commit
(id, repository_id)
VALUES (DEFAULT, 1);
INSERT INTO commit_change
(commit_id, diff, path)
VALUES (1, '0a1,5...', 'README.txt');
INSERT INTO commit_change
(commit_id, diff, path)
VALUES (1, '17c17...', 'web.xml');
--JdbcTransaction - committed JDBC Connection
--Load Commit from database
SELECT readonlyca0_.id AS id1_2_0_,
readonlyca0_.NAME AS name2_2_0_
FROM repository readonlyca0_
WHERE readonlyca0_.id = 1;
SELECT changes0_.commit_id AS commit_i1_0_0_,
changes0_.diff AS diff2_1_0_,
changes0_.path AS path3_1_0_
FROM commit_change changes0_
WHERE changes0_.commit_id = 1
--JdbcTransaction - committed JDBC Connection
--Load Commit from cache
--JdbcTransaction - committed JDBC Connection
Once the collection is cached, we can fetch the Commit entity along with all its Changes without hitting the database.
If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.
Conclusion
Read-only entities are safe for caching, and we can load an entire immutable entity graph using the second-level cache only. Because the cache is read-through, entities are cached upon being fetched from the database. The read-only cache is not write-through because persisting an entity only materializes into a new database row without propagating to the cache as well.







