Second-Level Cache

Sharing Data Between Sessions

The second-level cache belongs to the SessionFactory, so it survives after a transaction ends and is shared by every request. It is off by default. You turn it on and then mark each entity that should be cached.

Good candidates are entities that are read often and changed rarely: Category, product catalogue data, countries, tax slabs. Bad candidates: Order, CartItem, stock counters that change every second.

Note: The L2 cache does not store your Java objects. It stores a dehydrated copy (an array of column values). On a hit, Hibernate builds a new managed object from that array, so every session still gets its own instance.

How a Lookup Works

Example

A simulation of find() with both caches. The second request is a new session (empty L1) but it finds the data in the shared L2 region:

import java.util.*;

public class Main {
    static final Map<Long, Object[]> DB = new TreeMap<>(Map.of(1L, new Object[]{"Electronics"}, 2L, new Object[]{"Fashion"}));
    static final Map<Long, Object[]> L2_CATEGORY_REGION = new HashMap<>();   // shared by all sessions
    static int hits, misses, puts, selects;

    public static void main(String[] args) {
        System.out.println("-- request 1 --");
        Session s1 = new Session();
        Category a = s1.find(1L);
        s1.find(1L);

        System.out.println("-- request 2 (new session) --");
        Session s2 = new Session();
        Category b = s2.find(1L);
        System.out.println("same Java object? " + (a == b) + ", same data? " + a.name.equals(b.name));

        System.out.printf("L2 hits=%d misses=%d puts=%d, SQL selects=%d%n", hits, misses, puts, selects);
    }
}

class Category {
    final Long id; final String name;
    Category(Long id, String name) { this.id = id; this.name = name; }
}

class Session {
    private final Map<Long, Category> l1 = new HashMap<>();

    Category find(Long id) {
        if (l1.containsKey(id)) { System.out.println("L1 hit"); return l1.get(id); }
        Object[] state = Main.L2_CATEGORY_REGION.get(id);
        if (state != null) {
            Main.hits++; System.out.println("L2 hit, building a new object from cached state");
        } else {
            Main.misses++; Main.selects++;
            System.out.println("L2 miss -> select ... from categories where id=" + id);
            state = Main.DB.get(id);
            Main.L2_CATEGORY_REGION.put(id, state.clone()); Main.puts++;
        }
        Category c = new Category(id, (String) state[0]);   // "hydrate"
        l1.put(id, c);
        return c;
    }
}

Output

-- request 1 --
L2 miss -> select ... from categories where id=1
L1 hit
-- request 2 (new session) --
L2 hit, building a new object from cached state
same Java object? false, same data? true
L2 hits=1 misses=1 puts=1, SQL selects=1

Cache Concurrency Strategies

Each cached entity chooses how the cache stays consistent with the database when data changes:

StrategyUse whenNotes
READ_ONLYdata never changes (countries, GST slabs)fastest; updating the entity throws an exception
NONSTRICT_READ_WRITErare updates, a short stale window is finecache entry is invalidated after commit; no locks
READ_WRITEnormal read-mostly data (categories, products)soft locks keep readers from seeing uncommitted data; the usual choice
TRANSACTIONALJTA + a transactional cache (Infinispan)strict consistency, rarely needed in Spring Boot apps