How to cache non-existing entity fetch results with JPA and Hibernate

(Last Updated On: February 22, 2018)

Introduction

Sergey Chupov asked me a very good question on Twitter:

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.EntityNullResultCacheTest$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.EntityNullResultCacheTest$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.EntityNullResultCacheTest$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.EntityNullResultCacheTest$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:

Challange 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();
    });
}

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.

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.

Subscribe to our Newsletter

* indicates required
10 000 readers have found this blog worth following!

If you subscribe to my newsletter, you'll get:
  • A free sample of my Video Course about running Integration tests at warp-speed using Docker and tmpfs
  • 3 chapters from my book, High-Performance Java Persistence, 
  • a 10% discount coupon for my book. 
Get the most out of your persistence layer!

Advertisements

4 thoughts on “How to cache non-existing entity fetch results with JPA and Hibernate

Leave a Reply

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