Second-Level Cache and Query Cache with Hibernate

Cache Layers in Hibernate

Hibernate has two cache levels:

LevelScopeLifetimeShared?
First-level cacheOne Session (one transaction)TransactionNo — per session
Second-level cacheSessionFactoryApplication lifetimeYes — across sessions

The first-level cache (Article 24) prevents repeated reads within one transaction. The second-level cache prevents repeated reads across transactions — once an entity is loaded, it stays in the shared cache until evicted.


When to Use the Second-Level Cache

Good candidates:

  • Reference / lookup data: countries, currencies, categories, status codes — data that changes rarely
  • Frequently read, rarely written: product catalogue, configuration settings
  • Shared across many users: the same categories are needed by every request

Bad candidates:

  • Frequently written data: orders, stock levels, user activity
  • Large datasets: caching millions of rows wastes memory without much benefit
  • Session-specific data: shopping carts, user preferences

If a table is updated often, the cache is invalidated constantly and provides no benefit. Worse, stale reads become a risk.


Adding EhCache (Hibernate’s Default L2 Provider)

<!-- pom.xml -->
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
</dependency>
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          use_query_cache: true
          region:
            factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
        javax:
          cache:
            provider: org.ehcache.jsr107.EhcacheCachingProvider
            uri: classpath:ehcache.xml
<!-- src/main/resources/ehcache.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns="http://www.ehcache.org/v3"
        xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core.xsd">

    <!-- Default cache for entities not explicitly configured -->
    <default-cache-configuration>
        <expiry>
            <ttl unit="minutes">10</ttl>
        </expiry>
        <resources>
            <heap unit="entries">1000</heap>
        </resources>
    </default-cache-configuration>

    <!-- Category cache — small, rarely changes -->
    <cache alias="com.example.entity.Category">
        <expiry>
            <ttl unit="hours">1</ttl>
        </expiry>
        <resources>
            <heap unit="entries">500</heap>
            <offheap unit="MB">10</offheap>
        </resources>
    </cache>

    <!-- Product cache — moderate churn -->
    <cache alias="com.example.entity.Product">
        <expiry>
            <ttl unit="minutes">5</ttl>
        </expiry>
        <resources>
            <heap unit="entries">5000</heap>
        </resources>
    </cache>

    <!-- Query cache region -->
    <cache alias="org.hibernate.cache.internal.StandardQueryCache">
        <expiry>
            <ttl unit="minutes">5</ttl>
        </expiry>
        <resources>
            <heap unit="entries">200</heap>
        </resources>
    </cache>

    <cache alias="org.hibernate.cache.spi.UpdateTimestampsCache">
        <expiry>
            <none/>
        </expiry>
        <resources>
            <heap unit="entries">1000</heap>
        </resources>
    </cache>

</config>

Annotating Entities for Caching

Enable caching on an entity with @Cache:

import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

@Entity
@Table(name = "categories")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Category {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String slug;

    // ...
}

The cache alias defaults to the fully qualified class name (com.example.entity.Category), matching the ehcache.xml configuration above.

Cache Concurrency Strategies

StrategyUse when
READ_ONLYData never changes (enum-like values). Fastest.
NONSTRICT_READ_WRITERarely updated. Allows brief stale reads.
READ_WRITECan be updated. Prevents stale reads with soft-locking.
TRANSACTIONALFully transactional cache. Requires JTA.

For most reference data: READ_WRITE is the safe default.
For data that truly never changes (countries, ISO codes): READ_ONLY.
Avoid TRANSACTIONAL unless you already use JTA.


Caching Collections

Cache a collection association separately:

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {

    @ManyToMany
    @JoinTable(...)
    @Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
    private Set<Tag> tags = new HashSet<>();
}

The collection cache stores the IDs of the collection elements. Hibernate also needs the entity cache for Tag populated to resolve the full objects.


The Query Cache

The query cache stores the primary keys returned by a JPQL or native query. On subsequent identical queries, Hibernate retrieves the IDs from the query cache and looks up the entities in the second-level entity cache.

Enable per-query:

@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {

    @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
    List<Category> findByParentIsNull();

    @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
    Optional<Category> findBySlug(String slug);
}

Or via @Query:

@Query("SELECT c FROM Category c WHERE c.active = true ORDER BY c.name")
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Category> findActiveCategories();

How the Query Cache Works

  1. Query first executed → result IDs stored in query cache
  2. Same query executed again → IDs retrieved from query cache
  3. Hibernate looks up each ID in the entity cache
  4. If entity cache hit → no SQL
  5. If entity cache miss → SELECT for that entity

The query cache is invalidated whenever any entity of the queried type is inserted, updated, or deleted — even if it doesn’t affect the cached result set. For frequently-updated tables, the query cache is invalidated so often it provides no benefit.

The query cache is only beneficial for:

  • Queries on rarely-updated tables (categories, tags, configuration)
  • Queries with identical parameters called many times per second
  • The entity cache is also populated for the queried type

Cache Statistics

Monitor cache effectiveness with Hibernate statistics:

spring.jpa.properties.hibernate.generate_statistics: true
@Autowired
private EntityManagerFactory entityManagerFactory;

public void printCacheStats() {
    Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
    System.out.println("Second level cache hits:   " + stats.getSecondLevelCacheHitCount());
    System.out.println("Second level cache misses: " + stats.getSecondLevelCacheMissCount());
    System.out.println("Second level cache puts:   " + stats.getSecondLevelCachePutCount());
    System.out.println("Query cache hits:          " + stats.getQueryCacheHitCount());
    System.out.println("Query cache misses:        " + stats.getQueryCacheMissCount());
}

A healthy cache has a high hit rate. If misses dominate, either the TTL is too short or the data changes too frequently.


Evicting the Cache Manually

When you need to force-reload cached data:

@Autowired
private EntityManagerFactory entityManagerFactory;

// Evict a specific entity
public void evictCategory(Long categoryId) {
    entityManagerFactory.getCache().evict(Category.class, categoryId);
}

// Evict all cached Category entities
public void evictAllCategories() {
    entityManagerFactory.getCache().evict(Category.class);
}

// Evict everything
public void evictAll() {
    entityManagerFactory.getCache().evictAll();
}

Useful after bulk updates that bypass the persistence context (e.g., @Modifying queries), which don’t automatically invalidate the second-level cache for affected IDs.

@Modifying(clearAutomatically = true)
@Query("UPDATE Category c SET c.active = false WHERE c.id IN :ids")
int deactivateCategories(@Param("ids") List<Long> ids);

// After this, manually evict
public void deactivateAndEvict(List<Long> ids) {
    categoryRepository.deactivateCategories(ids);
    ids.forEach(id -> entityManagerFactory.getCache().evict(Category.class, id));
}

Complete Configuration Example

@Entity
@Table(name = "categories")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Category extends BaseEntity {

    private String name;
    private String slug;
    private boolean active;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Category parent;

    @OneToMany(mappedBy = "parent")
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
    private List<Category> children = new ArrayList<>();
}
@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {

    @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
    List<Category> findByActiveTrueOrderByName();

    @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
    Optional<Category> findBySlug(String slug);
}
@Service
@Transactional(readOnly = true)
public class CategoryService {

    private final CategoryRepository categoryRepository;

    // First call: DB query → cache populated
    // Subsequent calls: cache hit, no SQL
    public List<Category> getAllActive() {
        return categoryRepository.findByActiveTrueOrderByName();
    }

    @Transactional
    public Category createCategory(CategoryRequest request) {
        Category category = new Category();
        category.setName(request.getName());
        category.setSlug(request.getSlug());
        category.setActive(true);
        return categoryRepository.save(category);
        // Hibernate automatically invalidates the query cache for Category
        // New entity is added to the entity cache by Hibernate
    }
}

Summary

  • The second-level cache is shared across transactions — loaded entities stay cached until evicted or TTL expires.
  • Annotate cacheable entities with @Cache(usage = CacheConcurrencyStrategy.READ_WRITE). Use READ_ONLY for truly immutable data.
  • Configure cache regions in ehcache.xml with appropriate TTLs and heap sizes.
  • The query cache stores result IDs and is only useful for frequently-called queries on rarely-updated tables.
  • Monitor cache hit/miss rates with Hibernate statistics — a low hit rate means caching adds overhead without benefit.
  • After bulk @Modifying queries, manually evict affected entities from the cache since JPA bypasses automatic invalidation.

Next: Article 26 tackles the N+1 problem — the single most common JPA performance bug, with every detection and solution technique.