How to cache non-existing entity fetch results with JPA and Hibernate
Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?
Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.
So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!
Introduction
Sergey Chupov asked me a very good question on Twitter:
My use-case is a search by complex primary key, but the performance test still showed a degrade. I'm now using a query cache instead, which helps, but it doesn't look right to have a separate query for the search by PK. So I'm wondering if there's a better approach
— Sergey Chupov (@scadgek) December 29, 2017
In this article, I’m going to show you how to cache null results when using JPA and Hibernate.
Domain Model
Assuming we have a bookstore, and the most important entity in our Domain Modell is the Book
:
The entity mapping looks as follows:
@Entity(name = "Book") @Table(name = "book") public class Book { @Id private String isbn; private String title; private String author; //Getters and setters omitted for brevity }
The Book
identifier is the ISBN code which is used by clients to locate books.
Fetching an existing Book entity
If we have the following Book
entity in our database:
doInJPA(entityManager -> { Book book = new Book(); book.setIsbn( "978-9730228236" ); book.setTitle( "High-Performance Java Persistence" ); book.setAuthor( "Vlad Mihalcea" ); entityManager.persist(book); });
And since High-Performance Java Persistence is a hit on Amazon,
we have multiple concurrent clients trying to purchase it:
doInJPA(entityManager -> { Book book = entityManager.find(Book.class, "978-9730228236"); assertEquals( "Vlad Mihalcea", book.getAuthor() ); executeSync(() -> { doInJPA(_entityManager -> { Book _book = _entityManager.find(Book.class, "978-9730228236"); assertEquals( "High-Performance Java Persistence", _book.getTitle() ); }); }); });
However, if we inspect the SQL log, we can see that each user goes to the database to load this Book
:
-- [Alice]: Query:[" SELECT b.isbn AS isbn1_0_0_, b.author AS author2_0_0_, b.title AS title3_0_0_ FROM book b WHERE b.isbn = ? "], Params:[ (978-9730228236) ] -- [Bob]: Query:[" SELECT b.isbn AS isbn1_0_0_, b.author AS author2_0_0_, b.title AS title3_0_0_ FROM book b WHERE b.isbn = ? "], Params:[ (978-9730228236) ]
So, we have the same two queries going to the database and fetching the same entity.
To prevent hitting the database multiple times, we can cache the Book
entity as follows:
@Entity(name = "Book") @Table(name = "book") @org.hibernate.annotations.Cache( usage = CacheConcurrencyStrategy.READ_WRITE ) public class Book { @Id private String isbn; private String title; private String author; //Getters and setters omitted for brevity }
Notice the Hibernate-specific @Cache
annotation setting the cache concurrency strategy.
We are going to adjust the previous test case as follows:
doInJPA(entityManager -> { entityManager.getEntityManagerFactory().getCache().evictAll(); printCacheRegionStatistics(Book.class.getName()); Book book = entityManager.find(Book.class, "978-9730228236"); assertEquals( "Vlad Mihalcea", book.getAuthor() ); printCacheRegionStatistics(Book.class.getName()); executeSync(() -> { doInJPA(_entityManager -> { Book _book = _entityManager.find(Book.class, "978-9730228236"); assertEquals( "High-Performance Java Persistence", _book.getTitle() ); }); }); });
First, we will evict all entries from the cache to prove that only the first user will hit the database while any subsequent entity fetch request will be served from the cache.
Second, we want to print the Book
cache region to prove that the entity is stored in the cache now:
Now, when running the aforementioned test case, only Alice is going to fetch from the database since Bob will fetch the entity from the second-level cache:
Region: com.vladmihalcea.book.hpjp.hibernate.cache.Book, Entries: {} -- [Alice]: Query:[" SELECT b.isbn AS isbn1_0_0_, b.author AS author2_0_0_, b.title AS title3_0_0_ FROM book b WHERE b.isbn = ? "], Params:[ (978-9730228236) ] Region: com.vladmihalcea.book.hpjp.hibernate.cache.Book, Entries: { 978-9730228236=Item{ value=CacheEntry(Book { Vlad Mihalcea, High-Performance Java Persistence } ), version=null, timestamp=6222399591284736} }
Brilliant!
Fetching a non-existing Book entity
However, let’s assume that the second edition of the High-Performance Java Persistence Book
has just been released but our bookstore didn’t get the Book
from the publisher. When people will try to locate the new Book
using its associated ISBN they found on GoodReads:
doInJPA(entityManager -> { printCacheRegionStatistics(Book.class.getName()); Book book = entityManager.find(Book.class, "978-9730456472"); assertNull(book); printCacheRegionStatistics(Book.class.getName()); executeSync(() -> { doInJPA(_entityManager -> { Book _book = _entityManager.find(Book.class, "978-9730456472"); assertNull(_book); }); }); });
The cache does not help us anymore and the database is hit twice:
Region: com.vladmihalcea.book.hpjp.hibernate.cache.Book, Entries: {} -- [Alice]: Query:[" SELECT b.isbn AS isbn1_0_0_, b.author AS author2_0_0_, b.title AS title3_0_0_ FROM book b WHERE b.isbn = ? "], Params:[ (978-9730228236) ] Region: com.vladmihalcea.book.hpjp.hibernate.cache.Book, Entries: {} -- [Bob]: Query:[" SELECT b.isbn AS isbn1_0_0_, b.author AS author2_0_0_, b.title AS title3_0_0_ FROM book b WHERE b.isbn = ? "], Params:[ (978-9730228236) ]
So, the Hibernate cache does not cache non-existing entity requests. It only caches existing entities because the cache entry is based on the hydrated form of the ResultSet
that was fetched from the database.
Using the Query Cache for non-existing results
To fix this issue with non-existing results, we need to use the Query Cache.
As explained in this article, we need to enable it first:
<property name="hibernate.cache.use_query_cache" value="true" />
Now, our test case can be written as follows:
doInJPA(entityManager -> { printQueryCacheRegionStatistics(); try { Book book = entityManager.createQuery( "select b " + "from Book b " + "where b.isbn = :isbn", Book.class) .setParameter("isbn", "978-9730456472") .setHint(QueryHints.CACHEABLE, true) .getSingleResult(); } catch (NoResultException expected) {} printQueryCacheRegionStatistics(); executeSync(() -> { doInJPA(_entityManager -> { try { Book _book = _entityManager.createQuery( "select b " + "from Book b " + "where b.isbn = :isbn", Book.class) .setParameter("isbn", "978-9730456472") .setHint(QueryHints.CACHEABLE, true) .getSingleResult(); } catch (NoResultException expected) {} }); }); });
And, when running the test case above, Hibernate generates the following output:
DEBUG [Alice]: c.v.b.h.h.c.EntityNullResultCacheTest - Region: org.hibernate.cache.internal.StandardQueryCache, Entries: {} DEBUG [Alice]: o.h.c.i.StandardQueryCache - Checking cached query results in region: org.hibernate.cache.internal.StandardQueryCache DEBUG [Alice]: o.h.c.e.i.r.EhcacheGeneralDataRegion - key: sql: select entitynull0_.isbn as isbn1_0_, entitynull0_.author as author2_0_, entitynull0_.title as title3_0_ from book entitynull0_ where entitynull0_.isbn=?; parameters: ; named parameters: {isbn=978-9730456472}; transformer: org.hibernate.transform.CacheableResultTransformer@110f2 DEBUG [Alice]: o.h.c.e.i.r.EhcacheGeneralDataRegion - Element for key sql: select entitynull0_.isbn as isbn1_0_, entitynull0_.author as author2_0_, entitynull0_.title as title3_0_ from book entitynull0_ where entitynull0_.isbn=?; parameters: ; named parameters: {isbn=978-9730456472}; transformer: org.hibernate.transform.CacheableResultTransformer@110f2 is null DEBUG [Alice]: o.h.c.i.StandardQueryCache - Query results were not found in cache -- [Alice]: Query:[" SELECT b.isbn AS isbn1_0_0_, b.author AS author2_0_0_, b.title AS title3_0_0_ FROM book b WHERE b.isbn = ? "], Params:[ (978-9730228236) ] DEBUG [Alice]: o.h.c.i.StandardQueryCache - Caching query results in region: org.hibernate.cache.internal.StandardQueryCache; timestamp=6222407900971008 DEBUG [Alice]: o.h.c.e.i.r.EhcacheGeneralDataRegion - key: sql: select entitynull0_.isbn as isbn1_0_, entitynull0_.author as author2_0_, entitynull0_.title as title3_0_ from book entitynull0_ where entitynull0_.isbn=?; parameters: ; named parameters: {isbn=978-9730456472}; transformer: org.hibernate.transform.CacheableResultTransformer@110f2 value: [6222407900971008] DEBUG [Alice]: c.v.b.h.h.c.EntityNullResultCacheTest - Region: org.hibernate.cache.internal.StandardQueryCache, Statistics: SecondLevelCacheStatistics[hitCount=0,missCount=1,putCount=1,elementCountInMemory=1,elementCountOnDisk=0,sizeInMemory=1048], Entries: { sql: select entitynull0_.isbn as isbn1_0_, entitynull0_.author as author2_0_, entitynull0_.title as title3_0_ from book entitynull0_ where entitynull0_.isbn=?; parameters: ; named parameters: {isbn=978-9730456472}; transformer: org.hibernate.transform.CacheableResultTransformer@110f2=[6222407900971008] } DEBUG [Bob]: o.h.c.i.StandardQueryCache - Checking cached query results in region: org.hibernate.cache.internal.StandardQueryCache DEBUG [Bob]: o.h.c.e.i.r.EhcacheGeneralDataRegion - key: sql: select entitynull0_.isbn as isbn1_0_, entitynull0_.author as author2_0_, entitynull0_.title as title3_0_ from book entitynull0_ where entitynull0_.isbn=?; parameters: ; named parameters: {isbn=978-9730456472}; transformer: org.hibernate.transform.CacheableResultTransformer@110f2 DEBUG [Bob]: o.h.c.i.StandardQueryCache - Checking query spaces are up-to-date: [book] DEBUG [Bob]: o.h.c.e.i.r.EhcacheGeneralDataRegion - key: book DEBUG [Bob]: o.h.c.e.i.r.EhcacheGeneralDataRegion - Element for key book is null DEBUG [Bob]: o.h.c.i.StandardQueryCache - Returning cached query results
And, it works!
Bob’s request is served from the cache and does not hit the database.
Generifying the cacheable query
After reading this article, Sergey said that it would be useful if we had a generic approach:
Still seems there's no better option than to have a separate query for each such table. Anyway thanks for documenting this along with samples! Is there a chance that this behavior will change sometime or should it stay so 'by design'?
— Sergey Chupov (@scadgek) February 21, 2018
Challenge accepted!
We can generify the cacheable query using Criteria API, like this:
public <T> T getCacheableEntity( Class<T> entityClass, String identifierName, Object identifierValue) { return doInJPA(entityManager -> { CriteriaBuilder builder = entityManager.getCriteriaBuilder(); CriteriaQuery<T> criteria = builder.createQuery(entityClass); Root<T> fromClause = criteria.from(entityClass); criteria.where( builder.equal( fromClause.get(identifierName), identifierValue ) ); return entityManager .createQuery(criteria) .setHint(QueryHints.CACHEABLE, true) .getSingleResult(); }); }
Writing JPA Criteria API queries is not very easy. The Codota IDE plugin can guide you on how to write such queries, therefore increasing your productivity.
For more details about how you can use Codota to speed up the process of writing Criteria API queries, check out this article.
And, our test case becomes:
try { Book book = getCacheableEntity( Book.class, "isbn", "978-9730456472" ); } catch (NoResultException expected) {} printQueryCacheRegionStatistics(); executeSync(() -> { try { Book _book = getCacheableEntity( Book.class, "isbn", "978-9730456472" ); } catch (NoResultException expected) {} });
Cool, right?
If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.
And there is more!
You can earn a significant passive income stream from promoting all these amazing products that I have been creating.
If you're interested in supplementing your income, then join my affiliate program.
Conclusion
The Hibernate second-level cache can be very useful for various scenarios.
If caching existing entities is a well-known technique, you should be aware that you can even cache non-existing query results with Hibernate.
