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:

https://twitter.com/scadgek/status/946838214744203269

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:

https://twitter.com/scadgek/status/966352489653133313

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?

I'm running an online workshop on the 20-21 and 23-24 of November about High-Performance Java Persistence.

If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.

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.

Transactions and Concurrency Control eBook

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.