💾 Section 16 · Question #10

What is the Second-Level Cache and When to Use It

The Second-Level Cache (L2 Cache) in Hibernate is an optional, application-wide, thread-safe cache operating at the session factory level (EntityManagerFactory in JPA or Session...

The Second-Level Cache (L2 Cache) in Hibernate is an optional, application-wide, thread-safe cache operating at the session factory level (EntityManagerFactory in JPA or SessionFactory in Hibernate). Unlike the session-scoped First-Level Cache (L1), the L2 cache survives across independent sessions, transactions, and HTTP requests, significantly offloading relational databases for read-heavy, infrequently changing datasets.


🟢 Junior Level

What is the Second-Level Cache and How Does It Differ from L1?

The First-Level Cache is strictly bound to a single EntityManager instance and is discarded when the session closes. If 1,000 users concurrently request the same product category, without an L2 cache Hibernate will execute 1,000 identical SQL SELECT queries against the database.

The Second-Level Cache solves this bottleneck: the first session loads the record from the database and populates the shared L2 cache; the subsequent 999 sessions retrieve the data directly from RAM without querying the database.

                          L1 AND L2 CACHE ARCHITECTURE
                          
   [ Session 1 (Thread 1) ]             [ Session 2 (Thread 2) ]
   ┌──────────────────────┐             ┌──────────────────────┐
   │    L1 Cache (Map)    │             │    L1 Cache (Map)    │
   └──────────┬───────────┘             └──────────┬───────────┘
              │ (L1 Miss)                          │ (L1 Miss)
              └───────────────┐     ┌──────────────┘
                              ▼     ▼
               ┌───────────────────────────────────┐
               │    L2 Cache (SessionFactory)      │
               │   (Ehcache, Hazelcast, Infinispan)│
               │   User#1 -> ["Ivan", "ivan@ya.ru"]│
               └─────────────────┬─────────────────┘
                                 │ (L2 Miss)
                                 ▼
                     [ Database (SQL RDBMS) ]

Comparison Matrix: L1 vs L2

Feature First-Level Cache (L1) Second-Level Cache (L2)
Scope EntityManager / Session (Single Thread) EntityManagerFactory / SessionFactory (Entire JVM / Cluster)
Default State ✅ Always enabled, cannot be disabled ❌ Disabled by default, requires an external provider
Stored Content Live Java object references (Managed entities) Dehydrated primitive state arrays (Object[])
Lifecycle Duration of a single transaction / session Application uptime (governed by eviction policies, TTL/TTI)
Providers Built-in Hibernate Core Ehcache, Hazelcast, Infinispan, Redis (via Redisson)

When to Use vs When to Avoid L2 Cache

✅ Prime Use Cases:

  • Infrequently modified reference data: countries, currencies, product categories, subscription plans, system settings.
  • Datasets with a Read-to-Write ratio of at least 80/20 or 90/10.
  • High-volume lookups by primary key (find(), findById()).

❌ Scenarios to Avoid:

  • High-frequency write tables: financial ledgers, account balances, shopping carts, live event counters.
  • Tables modified by external systems bypassing Hibernate (direct ETL jobs, external microservices, raw JDBC scripts) — the L2 cache will inevitably become stale.
  • Massive datasets with uniform, rare access patterns (causes JVM heap memory exhaustion without delivering cache hits).

🟡 Middle Level

What is Stored in L2 Cache: The “Dehydrated” State

The Second-Level Cache does NOT store live Java entity objects. Storing live object references in a shared multi-threaded cache would allow one thread to mutate an object while another thread reads it, resulting in severe race conditions and thread-safety violations.

Instead, Hibernate serializes entities into a dehydrated state:

  • A flat array of primitive values, strings, and foreign keys: Object[] { "Ivan", 30, 100L }.
  • The entity version number (@Version).
  • When Session 2 executes find(User.class, 1L), Hibernate fetches the dehydrated array from L2, instantiates a brand new User object in Session 2’s local JVM heap, hydrates (populates) its fields, and places it into Session 2’s private L1 cache.

Cache Concurrency Strategies (CacheConcurrencyStrategy)

When configuring an entity with Hibernate’s @org.hibernate.annotations.Cache annotation, you must declare a concurrency strategy:

@Entity
@Table(name = "countries")
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
public class Country {
    @Id
    private Long id;
    private String name;
}
Strategy Behavior & Guarantees Recommended Use Case
READ_ONLY Data is strictly immutable. Hibernate throws an exception if the application attempts to update the entity. Fastest strategy with zero locking overhead. Lookup tables, countries, currencies, static database enums.
NONSTRICT_READ_WRITE Allows infrequent updates without acquiring locks. Risk of transient stale reads during concurrent writes. Non-critical catalog data where eventual consistency is acceptable.
READ_WRITE Enforces Read Committed isolation using Soft Locks to prevent stale reads during concurrent updates. Read-mostly business entities where stale or dirty reads are unacceptable.
TRANSACTIONAL Guarantees full ACID transactional consistency coordinated via JTA (2PC). Requires an XA-compliant distributed cache provider. Enterprise systems running under full JTA environments (WildFly, WebLogic).

Collection Caching Nuances

Caching a parent entity does not cache its associated collections automatically:

@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Department {
    @Id
    private Long id;

    // ATTENTION: Without an explicit @Cache annotation, this collection is NOT cached in L2!
    @OneToMany(mappedBy = "department")
    @org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
    private List<Employee> employees = new ArrayList<>();
}

Collection Caching Invariant: The L2 cache stores only an array of foreign primary keys for the collection: [101, 102, 103]. To load the collection without issuing SQL queries, the target entity (Employee) must also be annotated with @Cacheable. Otherwise, Hibernate retrieves the list of IDs from L2 and executes N individual SQL queries to load each Employee from the database (triggering the N+1 problem)!


🔴 Senior Level

Soft Lock Mechanics in the READ_WRITE Strategy

The READ_WRITE concurrency strategy eliminates race conditions without imposing heavy physical table or row locks on the relational database:

               SOFT LOCK TIMELINE DURING ENTITY UPDATE
               
Transaction 1 (UPDATE)             L2 Cache Region                Transaction 2 (SELECT)
        │                                │                                │
        │── 1. flush() begins ──────────>│                                │
        │   Acquires Soft Lock           │ Cache entry marked             │
        │   (Entity -> SoftLock entry)   │ (state: LOCKED)                │
        │                                │                                │
        │── 2. Physical SQL UPDATE ─────>│                                │
        │                                │                                │
        │                                │<── 3. find() read attempt ─────│
        │                                │    Soft Lock detected!         │
        │                                │    L2 returns CACHE MISS       │
        │                                │──> Reads directly from DB ─────│
        │                                │    (waits for T1 commit or     │
        │                                │     reads committed row)       │
        │── 4. Transaction 1 commits ───>│                                │
        │   Releases Soft Lock           │ Fresh dehydrated state         │
        │   Writes updated state ────────> stored in cache region         │
  1. Pre-Commit Phase (lock): When an entity is modified during flush(), Hibernate replaces the cached entry in L2 with a special marker object called a SoftLock.
  2. Concurrent Read Phase: If a parallel transaction attempts to read the entity from L2 and encounters a SoftLock, it recognizes that cached data is uncommitted or obsolete. It treats this as a Cache Miss and fetches the row directly from the database.
  3. Post-Commit Phase (afterUpdate): Once Transaction 1 successfully commits, the SoftLock is removed, and the freshly dehydrated entity state is stored in L2.
  4. Rollback Handling: If Transaction 1 rolls back, the SoftLock is simply evicted from the cache, forcing subsequent transactions to re-read from the database.

Invalidation Hazards in Bulk Operations

One of the most dangerous operational pitfalls of L2 caching involves bulk DML statements:

// Mass update via JPQL:
int updated = entityManager.createQuery(
        "UPDATE User u SET u.active = false WHERE u.lastLogin < :threshold")
    .setParameter("threshold", cutoffDate)
    .executeUpdate();

Internal Consequences:

  1. The executeUpdate() call translates directly into a single SQL UPDATE statement executed by the database.
  2. Hibernate does not load entities into memory and cannot determine which specific primary keys were modified.
  3. To protect data integrity from stale reads, Hibernate evicts the entire L2 cache region for the User entity (cache.evictEntityRegion(User.class)).
  4. Architectural Impact: High-frequency bulk updates completely dismantle L2 cache hit ratios, degrading performance across the entire application cluster.

Interaction Between L2 Cache and the Query Cache

The Query Cache does not store entity fields; it stores the results of queries as lists of primary keys:

  • Cache Key: Hash(SQL/JPQL query text + parameter values + pagination limits).
  • Cache Value: List<Long> { 1, 5, 23 }.

When a query cache hit occurs:

  1. Hibernate retrieves the primary key array from the Query Cache: [1, 5, 23].
  2. It then looks up each primary key in the Second-Level Cache.
  3. The Query Cache Catastrophe: If the Query Cache is enabled but the target entity is NOT cached in L2, Hibernate obtains the list of IDs from the query cache and executes N separate SQL SELECT queries by primary key (WHERE id = ?), producing a catastrophic N+1 query storm!

4 Tricky Questions

1. What is physically stored in the Second-Level Cache, and why doesn’t returning the same entity from L2 to two parallel sessions violate the Identity Guarantee (==)?

Answer:

  1. The L2 cache stores a dehydrated array of primitive values and foreign keys (Object[]), not live Java entity instances.
  2. When Session 1 and Session 2 request the entity, Hibernate instantiates two distinct Java objects on the JVM heap: User@0x111 for Session 1 and User@0x222 for Session 2.
  3. Inside Session 1, find(User.class, 1L) == find(User.class, 1L) remains strictly true because of its private L1 Identity Map. The sessions are fully isolated, preventing cross-thread state pollution and race conditions.

2. Why is CacheConcurrencyStrategy.READ_WRITE unsupported in uncoordinated distributed cluster caches?

Answer:

  1. The READ_WRITE strategy relies on temporary Soft Locks placed in the cache during the flush() phase prior to database commit.
  2. In a multi-node cluster, soft locking requires a distributed locking mechanism (e.g., provided by Infinispan or Hazelcast).
  3. Without cluster-wide lock coordination, Node A places a soft lock locally while Node B continues reading stale cached entries from its local replica, violating Read Committed transaction isolation.

3. We update a database record directly via native SQL: entityManager.createNativeQuery("UPDATE users SET balance = 0 WHERE id = 1").executeUpdate(). What happens to the L2 cache?

Answer:

  1. When executing native SQL, Hibernate cannot reliably parse custom database dialects or table relationships.
  2. By default, Hibernate assumes that the native SQL query could have modified any table in the schema and purges (evicts) the ENTIRE Second-Level Cache across all entity regions!
  3. To prevent total cache eviction, the query must explicitly declare its modified query spaces using org.hibernate.query.NativeQuery#addSynchronizedEntityClass(User.class) or addSynchronizedQuerySpace("users").

4. Why doesn’t the L2 cache avoid database queries when persisting new entities that use GenerationType.IDENTITY?

Answer:

  1. The Second-Level Cache indexes entity data strictly by primary key (EntityKey).
  2. With GenerationType.IDENTITY, the primary key is generated by the database engine upon actual SQL execution (AUTO_INCREMENT / SERIAL).
  3. Until the physical INSERT executes against the database, the entity’s ID is null. Therefore, Hibernate cannot pre-populate the L2 cache and must perform an immediate, synchronous database write.

🎯 Interview Cheat Sheet

30-Second Elevator Pitch

“The Second-Level Cache (L2) in Hibernate is an optional, thread-safe cache operating at the SessionFactory level, shared across all application sessions. Unlike L1, L2 does not store live Java entity objects; it stores disassembled ‘dehydrated’ primitive arrays (Object[]), reconstituting fresh entity instances in each session’s local L1 cache to maintain thread safety. L2 is optimal for read-heavy reference data with an 80/20+ read/write ratio. Its primary concurrency strategies are READ_ONLY (zero locks for static data) and READ_WRITE (uses soft locks during flush to guarantee Read Committed isolation). A crucial architectural danger is enabling Query Cache without caching the target entities in L2, which triggers a severe N+1 query storm.”

Key Production Metrics & Health Checks

  • L2 Cache Hit Ratio (stats.getSecondLevelCacheHitCount() / (hits + misses)): Target > 90% for lookup/reference data; target > 70% for standard read-heavy business entities.
  • Put-to-Hit Ratio (stats.getSecondLevelCachePutCount() vs HitCount): If Puts exceed Hits, the cache is counterproductive and wasting CPU/memory.
  • Region Invalidation Rate: Frequent invalidations indicate that bulk updates or high-frequency writes are flushing the cache prematurely.

Red Flags to Avoid

  • ❌ “The Second-Level Cache stores live Java object references shared across all threads.” — Storing live references would cause race conditions. It stores dehydrated state arrays.
  • ❌ “I cache financial transactions and user balances in L2 with READ_ONLY.” — Stale reads on rapidly changing financial data cause catastrophic business errors.
  • ❌ “Enabling the Query Cache automatically caches the underlying entities.” — Query Cache only stores lists of IDs. Without L2 entity caching, it causes N+1 queries.
  • ❌ “L2 cache is enabled by default in Spring Boot applications.” — It is disabled by default and requires explicit dependencies (Ehcache/Hazelcast/Infinispan) and configuration.