Transactions, Caching & Performance

Query Cache & Caching Strategies

18 min Lesson 8 of 13

Query Cache & Caching Strategies

The previous lesson introduced the second-level (L2) cache, which stores individual entity instances keyed by their primary key. The query cache goes one step further: it stores the result set of a named or criteria query — specifically, the ordered list of primary keys returned — and re-uses it on subsequent identical executions without hitting the database. Understanding when to enable it and which caching strategy to assign each entity is what separates a well-tuned data layer from one that silently wastes resources.

How the Query Cache Works

When you mark a query as cacheable and it executes for the first time, Hibernate stores a cache entry whose key is the query string plus the bound parameter values. The value stored is not the hydrated entity objects — it is just the list of primary keys (and for scalar queries, the raw column values). On the next call with the same query and parameters, Hibernate retrieves the key list from the query cache, then looks up each entity in the L2 entity cache (or loads it from the database if the entity is not cached there).

The query cache depends on the entity cache. If you enable the query cache but forget to make the queried entity @Cacheable, Hibernate will hit the database for every entity on every query-cache hit — often worse than no caching at all, because you still pay the cache-lookup overhead.

Enable the query cache in application.properties:

# Caffeine (or Ehcache) as the L2 provider is already configured 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.hibernate.cache.jcache.JCacheCacheRegionFactory

Mark a JPQL query as cacheable via the @QueryHints annotation or the JPA hint constant:

import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.jpa.repository.Query; import jakarta.persistence.QueryHint; import org.hibernate.jpa.HibernateHints; public interface ProductRepository extends JpaRepository<Product, Long> { @Query("SELECT p FROM Product p WHERE p.category = :category ORDER BY p.name") @QueryHints(@QueryHint( name = HibernateHints.HINT_CACHEABLE, value = "true" )) List<Product> findByCategory(@Param("category") String category); }

From a Session or EntityManager you can also set the hint programmatically:

TypedQuery<Product> q = em.createQuery( "SELECT p FROM Product p WHERE p.category = :cat", Product.class); q.setParameter("cat", "electronics"); q.setHint(HibernateHints.HINT_CACHEABLE, true); List<Product> results = q.getResultList();

Cache Regions

Each entity class and each cacheable query is stored in a named cache region. The default region name for an entity is its fully qualified class name (e.g. com.example.shop.Product). The default region for query results is default-query-results-region. You can override the region on a query:

q.setHint(HibernateHints.HINT_CACHE_REGION, "product.byCategory");

Named regions let you configure different TTL and eviction policies per data set in your JCache / Ehcache / Caffeine configuration. Fast-moving data (e.g. current stock level) gets a 30-second TTL; slow reference data (e.g. country codes) can live for an hour.

Choosing a Caching Strategy

Hibernate exposes four concurrency strategies for the L2 cache. Picking the right one is the single most important caching decision you make per entity.

  • READ_ONLY — the entity is never updated after it is first persisted (think: lookup tables, currency codes, product categories). Highest throughput, zero lock overhead. Any attempt to update throws an exception. Use this whenever you can.
  • NONSTRICT_READ_WRITE — entity is occasionally updated but stale reads are acceptable for a brief window. Cache is invalidated after an update, but no lock is held during the invalidation. Suitable for low-concurrency writes on non-critical data.
  • READ_WRITE — uses a soft lock to maintain read-committed semantics: the entry is locked during the write, other threads see the database value (or nothing) instead of the stale cached value. Safe for entities updated under moderate concurrency. Slight performance cost per write.
  • TRANSACTIONAL — full transactional cache, integrated with JTA. Guarantees repeatable-read cache semantics across cluster nodes. Requires a JTA transaction manager and a JTA-capable cache provider. Rarely needed; adds significant complexity.

Apply the strategy with @Cache:

import jakarta.persistence.*; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; @Entity @Cacheable @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Product { @Id @GeneratedValue private Long id; private String name; private String category; private int stockLevel; // getters / setters omitted }
Default-to READ_ONLY for reference data, READ_WRITE for mutable entities. Only reach for NONSTRICT_READ_WRITE if profiling shows write contention on a READ_WRITE entity and your domain truly tolerates brief stale reads.

Collection Caching

Entity collections (e.g. @OneToMany sets) have their own cache region and must be annotated separately:

@Entity @Cacheable @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Order { @Id @GeneratedValue private Long id; @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) private List<OrderLine> lines = new ArrayList<>(); }

Without the second @Cache on the collection, Hibernate skips the collection when writing to the cache, causing an N+1 load on every cache hit for the parent entity.

Cache Invalidation

The query cache region is automatically invalidated when any entity of a type involved in the cached query is written. This means a high write rate on Product will constantly invalidate every cached query that touches Product, turning the cache into overhead. Monitor cache hit rates with Spring Boot Actuator or Micrometer before committing to the query cache in a write-heavy scenario.

# Expose cache metrics via Actuator management.endpoints.web.exposure.include=health,info,metrics,caches management.metrics.cache.instrument-defaults=true

To evict a specific region programmatically:

import org.springframework.cache.CacheManager; @Service public class CatalogService { @Autowired private CacheManager cacheManager; public void invalidateProductCache() { Cache c = cacheManager.getCache("product.byCategory"); if (c != null) c.clear(); } }

When Not to Use the Query Cache

The query cache is worth enabling only when:

  1. The same query with the same parameters is executed repeatedly.
  2. The underlying data changes rarely relative to the read frequency.
  3. The result set is reasonably small (thousands of rows, not millions).
The query cache is a frequent source of surprise regressions. Because any write to a participating entity type invalidates the entire region, an apparently well-cached read path can actually perform worse than no cache in a system with concurrent writes. Always measure before enabling it in production.

Summary

The query cache stores query result key lists and is only effective when paired with a correctly configured entity L2 cache. Choose READ_ONLY for immutable reference data, READ_WRITE for safely mutable entities, and reserve TRANSACTIONAL for distributed JTA environments. Cache collections explicitly or face N+1 loads on cache hits. Use Actuator metrics to verify your hit rate before and after enabling caching, and remember that high-write tables make the query cache a liability rather than an asset.