Second-Level Cache in Hibernate — When It Helps and When It's a Trap

by Eric Hanson, Backend Developer at Clean Systems Consulting

The three levels of Hibernate caching

Hibernate has three distinct caching levels, each with different scope and lifetime:

First-level cache (L1) — the persistence context (EntityManager / Session). Every entity loaded within a transaction is cached here. Repeated calls to entityManager.find(Order.class, id) within the same transaction return the same instance. This cache is always active, per-transaction, and requires no configuration.

Second-level cache (L2) — a shared cache across sessions and transactions. When an entity is loaded and the session closes, the data remains in the L2 cache. The next session that requests the same entity by primary key finds it in the L2 cache without hitting the database. This requires explicit configuration and opt-in per entity.

Query cache — caches the result sets of named queries. Requires L2 to be active. When a query is run and the result is cached, subsequent identical queries return from cache. The query cache invalidates when any entity type in the query result is modified.

This article focuses on L2 — the configuration decisions with the most production consequences.

How the L2 cache works

When Hibernate loads an entity via find() or by navigating an association, it checks the L2 cache first. On a cache miss, it queries the database and stores the loaded entity data (not the entity object itself — Hibernate stores the column values) in the L2 cache under the entity's primary key.

Storing column values, not entity instances, is significant: the L2 cache is not a Java object cache. It stores the raw data; Hibernate reconstructs the entity when reading from the cache. This means the cache is safe across class loader boundaries and JVM restarts (for persistent providers like Redis or Ehcache with persistence).

The L2 cache is invalidated by Hibernate when:

  • An entity is updated via Hibernate (entityManager.persist, merge, save)
  • An entity is deleted via Hibernate
  • A collection element (a @OneToMany association) is added or removed

It is not invalidated by:

  • Direct SQL updates (UPDATE orders SET status = 'shipped' WHERE id = 123)
  • @Query with nativeQuery = true
  • Database changes from other applications or batch tools
  • Spring Data's @Modifying queries (these require explicit @CacheEvict or cache invalidation)

This last point is the most common source of L2 cache stale data bugs.

Configuration — Ehcache and Redis providers

Spring Boot with Hibernate L2 requires a cache provider. Two production-worthy options:

EhCache (in-process, for single-node or when sharing isn't required):

<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <classifier>jakarta</classifier>
</dependency>
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
<!-- ehcache.xml -->
<config xmlns='http://www.ehcache.org/v3'>
    <cache alias="com.example.Product">
        <expiry>
            <ttl unit="hours">1</ttl>
        </expiry>
        <heap unit="entries">10000</heap>
    </cache>
    <cache alias="com.example.Order">
        <expiry>
            <ttl unit="minutes">10</ttl>
        </expiry>
        <heap unit="entries">50000</heap>
    </cache>
</config>

Redis (for distributed deployments):

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-hibernate-6</artifactId>
</dependency>
spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          region:
            factory_class: org.redisson.hibernate.RedissonRegionFactory
        redisson:
          config: redisson.yaml

Redisson is the standard Redis provider for Hibernate L2 — it handles serialization, eviction, and distributed invalidation. Alternatives like Hazelcast and Infinispan exist but Redisson is most commonly maintained.

Enabling caching per entity

The L2 cache is opt-in per entity. Annotate with @Cache:

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

@Entity
@Table(name = "products")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
    @Id
    private Long id;
    private String name;
    private BigDecimal price;

    @OneToMany(mappedBy = "product")
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)  // cache the collection too
    private List<ProductVariant> variants;
}

CacheConcurrencyStrategy determines how concurrent reads and writes are handled:

READ_ONLY — for entities that never change after creation. Configuration, reference data, enum-like entities. Highest performance, no write support.

READ_WRITE — for entities that change, with soft locking to prevent stale reads during updates. The standard choice for most mutable entities.

NONSTRICT_READ_WRITE — no locking, slightly stale reads possible during concurrent updates. Higher performance than READ_WRITE when occasional staleness is acceptable.

TRANSACTIONAL — full transactional cache (JTA required). Rarely used outside of JTA environments.

For most entities, READ_WRITE is the correct choice. READ_ONLY for immutable reference data (country codes, product categories that never change). Avoid NONSTRICT_READ_WRITE unless you've profiled that the locking overhead of READ_WRITE is a bottleneck — the stale read risk is usually not worth it.

The query cache — use with caution

The query cache stores query result sets keyed by the query string and parameters. When the same query runs again, Hibernate returns the cached result without executing SQL:

// Enable query cache for a specific query
@Query("SELECT p FROM Product p WHERE p.category = :category")
@QueryHints({@QueryHint(name = "org.hibernate.cacheable", value = "true"),
             @QueryHint(name = "org.hibernate.cacheRegion", value = "product-queries")})
List<Product> findByCategory(@Param("category") String category);

The query cache stores entity primary keys, not entity data. Hibernate then loads each entity from L2 or the database. A cached query result for findByCategory("electronics") stores [1L, 2L, 5L, 42L] — the IDs. Retrieving the entities is a second step.

The query cache invalidation trap. The query cache for a region is invalidated whenever any entity in the query's result type is modified — regardless of whether the modification affects any of the cached results. A query cache region for Product queries is cleared every time any Product is saved, updated, or deleted. For tables with frequent writes, the query cache hit rate approaches zero.

The query cache is appropriate for:

  • Queries on truly static reference data (country codes, configuration)
  • Queries where the result set changes very infrequently relative to the read frequency
  • Named queries with known, bounded parameters

For most application queries on actively-updated data, the query cache provides no benefit and adds overhead. Measure hit rate before concluding it's helping.

The distributed deployment problem

The L2 cache in a single JVM application is straightforward — one cache, one JVM, Hibernate manages all mutations. In a distributed deployment with multiple instances:

EhCache (in-process) does not invalidate across instances. Instance A updates a Product. Instance A's L2 cache is invalidated. Instance B's L2 cache still has the old value. Requests routed to Instance B return stale data.

This is not a bug — it's the defined behavior of in-process caches. EhCache can be configured with distributed capabilities (Terracotta cluster, JGroups), but this adds infrastructure complexity.

Redisson (Redis-backed) invalidates across instances. When Instance A updates a Product, Hibernate publishes an invalidation message. Redisson subscribes to these messages across all instances. Instance B receives the invalidation and removes the cached entry. Subsequent reads from Instance B fetch from the database.

For any deployment with more than one application instance, use Redis-backed L2 cache (Redisson) or disable L2 entirely. Running EhCache in a multi-instance deployment produces silent stale data.

When L2 cache helps

L2 cache provides meaningful benefit for:

Frequently read, infrequently updated reference data. Product catalogs, configuration entities, user roles, country/currency lookups — these are read thousands of times per request cycle and change rarely. A cache hit rate of 99%+ is achievable and the benefit is real.

Many-to-one associations traversed frequently. order.getProduct(), invoice.getUser(), lineItem.getCategory() — these associations are traversed on every response serialization for their parent entities. Without L2 cache, each traversal fires a query (unless JOIN FETCH'd). With L2 cache, the associated entity is returned from cache.

Entities loaded by primary key in hot paths. productRepository.findById(id) in a tight loop benefits from L2 — the first load queries the database, subsequent loads within the cache TTL return from cache.

When L2 cache is a trap

Frequently updated entities. An Order that updates status, payment state, and fulfillment data frequently will be invalidated constantly. The L2 cache churns without benefit — cache miss rate stays high, invalidation overhead is pure cost.

Entities loaded primarily via complex queries. If most reads go through findByStatusAndUserId() rather than findById(), the L2 entity cache provides no benefit — it only caches by primary key. The query cache would be needed, but its invalidation behavior (above) makes it impractical for frequently-updated entities.

Tables updated outside Hibernate. Any table that receives direct SQL updates, batch tool modifications, or changes from other applications will have persistent stale data in the L2 cache. The cache is never invalidated because Hibernate doesn't know about the updates.

Large entities with frequently-changing sub-graphs. An entity with a large @OneToMany collection that changes frequently will invalidate the collection cache on every change, causing constant cache churn on the collection while the entity root remains valid.

The practical decision

Enable L2 cache for entities that satisfy all three conditions:

  1. Loaded frequently by primary key
  2. Updated infrequently relative to read frequency
  3. Modified exclusively through Hibernate (no direct SQL, no external tools)

For everything else, the @Cacheable application-level cache on service methods gives more control over what's cached, how it's keyed, and when it's invalidated — without the silent-staleness risks of L2 in distributed deployments.

Measure before enabling. Add L2 cache to a staging environment with production-representative traffic, enable Hibernate's statistics:

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true

Then check: SessionFactory.getStatistics().getSecondLevelCacheHitCount() vs miss count. A hit rate below 80% on an entity suggests L2 cache is not worth the configuration complexity for that entity.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Backend Systems Fail at Scale

“It worked perfectly… until we got users.” Scale doesn’t break systems — it reveals what was already fragile.

Read more

The Hidden Expenses Every Remote Contractor Must Consider

Remote contracting sounds simple: work from anywhere, get paid, repeat. But behind the freedom is a list of costs most people don’t see coming.

Read more

When “Don’t Touch This Code” Becomes a Team Culture

Some code becomes untouchable—not because it’s perfect, but because it’s fragile. And when that mindset spreads, it shapes the entire team culture.

Read more

Why AI Doesn’t Replace the Judgment of a Tech Lead

AI can generate code, suggest patterns, and even review pull requests. But it cannot replace the nuanced judgment a human tech lead brings to a team.

Read more