What a two-level cache is and when it earns its complexity
A Spring Boot two level cache puts an in-process Caffeine cache (L1) in front of a shared Redis cache (L2). A read checks Caffeine first. On a miss it checks Redis, and on a second miss it calls the source of record, usually a database or downstream service. The result is written back into both tiers so later reads stop at L1.
Teams add the second tier because Redis is fast but remote. Every Redis hit still costs a network round trip, a serialization step and a connection from the pool. For a handful of hot keys read thousands of times per second, Caffeine serves the value from the JVM heap with no I/O. Redis remains the shared tier, so a new or restarted instance warms from L2 instead of hitting the database. The pattern fits services running several instances with read-heavy traffic, latency-sensitive endpoints and a skewed key distribution.
The hard part is not wiring up two cache managers. Each instance holds its own private copy of L1, so a write on one node leaves the others serving stale data until something tells them to evict. This article treats cross-instance cache invalidation and an explicit staleness bound as the core design problem. Everything else is plumbing.
Skip L1 in three cases:
- Low-traffic services, where Redis latency is already negligible.
- Caches over cheap source queries, where a second tier adds risk without saving much.
- Data that needs read-your-writes consistency, because an L1 copy can lag behind a write made moments earlier.
Who should use a two-level cache
A two-level cache is a good fit for teams that meet most of these conditions:
- You already run Redis as a shared cache for a Spring Boot service, and the service runs as more than one instance. With a single instance, Caffeine alone gives you in-process speed without any invalidation problem. Without Redis, you have no shared tier to warm from.
- Redis round trips show up in your latency profile or in Redis load. A request reads the same few keys repeatedly, or a small set of hot keys accounts for most Redis traffic. A per-tier hit ratio will tell you whether that is true. Measure it before you build anything.
- The cached data can tolerate a bounded staleness window. Product catalogs, configuration, reference data, permission lookups and rendered fragments usually qualify. Account balances, inventory reservations and anything a user expects to see immediately after their own write usually do not.
- The team can own a small amount of infrastructure code. You will maintain a composite
Cache, aCacheManager, a pub/sub listener and integration tests through Spring and driver upgrades.
If you are a single-instance service, a low-traffic service, or a team that cannot state an acceptable staleness bound for each cache, stay with Redis-only or Caffeine-only caching.
How it works at a glance
The design has five parts. Each one is covered in detail in the sections that follow.
- Two tiers configured per cache name. A Caffeine cache per name, bounded by
maximumSizeand expiring after write. ARedisCacheManagerwith per-nameentryTtl, a JSON serializer and a versioned key prefix. The L1 TTL is always shorter than the L2 TTL. - A composite
Cache. It wraps one Caffeine cache and one Redis cache. Reads go L1, then L2, then the method body. Writes and evictions go L2, then L1. Spring's@Cacheable,@CachePutand@CacheEvictannotations call it like any other cache, so business code does not change. - A composite
CacheManager. It builds one composite cache per name and returns the same instance on every lookup. - Pub/sub invalidation. After a successful Redis write or eviction, the composite cache publishes a small message. Every other node evicts the key from its own L1, and only from L1.
- Safety nets. A
CacheErrorHandlerturns Redis failures into cache misses, L1 is cleared when the Redis subscription reconnects, and the L1 TTL caps staleness when a message is lost.
The request flow and the ordering rules that make this correct are below.
How the read and write paths work across both tiers
Read path
A read checks the local Caffeine cache (L1) first. On a hit, it returns without a network call. On a miss, it queries Redis (L2). If L2 has the value, the node copies it into L1 before returning, so the next read on that node stays in-process. If both tiers miss, the method runs against the source of truth, and the result is written to L2 and then to L1.
Write path
A write updates the database first, then evicts the key from L2, then publishes an invalidation message on a Redis channel. Every node, including the writer, evicts its own L1 entry when the message arrives.
The order matters. If a node received the invalidation before L2 was cleared, it would drop its L1 entry, miss, read the old value from L2, and re-cache stale data locally. Clearing L2 first means any node that misses L1 after the message falls through to the database.
The stale window
Between the L2 eviction and each node processing the message, other nodes still serve the old value from L1. That gap depends on network latency and subscriber load. You can shrink it, but you cannot remove it.
Redis pub/sub is fire-and-forget. Messages are not persisted. A node that is disconnected, restarting, or slow to reconnect never receives the ones it missed. The invalidation channel is a best-effort optimization. The L1 expire-after-write TTL is the actual upper bound on staleness, so set it to the longest staleness you can tolerate.
READ PATH (on any node)
+--------+ get +-------------+ miss +-------------+ miss +--------------+
| caller |----->| L1 Caffeine |----->| L2 Redis |----->| DB (source |
+--------+ | (in-process)| | (shared) | | of truth) |
+-------------+ +-------------+ +--------------+
^ ^ | |
| +- L2 hit, fill L1+ |
| both miss: load, write L2, then L1 |
+--------------------------------------------+
WRITE PATH (order: 1 -> 2 -> 3)
+--------+ 1 update DB +----------+
| writer |------------->| DB |
| node | 2 evict key +----------+
| |------------->| L2 Redis |
| | 3 publish +----------+ +---------------------------+
| |------------->| channel |---->| node A: evict own L1 key |
+--------+ invalidate +----------+--+ +---------------------------+
| +---------------------------+
+->| node B: evict own L1 key |
| +---------------------------+
| +---------------------------+
+->| writer: evict own L1 key |
+---------------------------+
STALE WINDOW (per node)
t0 L2 evicted ----> t1 node A evicts L1 ----> t2 node B evicts L1
|<---- node B still serves the old value from its L1 ---->|
Missed message (pub/sub is not persisted): stale until L1 TTL expires.
Reads fall through L1, L2, then the database. Writes update the database, evict L2, then publish an invalidation so each node evicts its own L1 entry; the L1 TTL bounds staleness.
Configuring Caffeine and RedisCacheManager per cache name
Each tier needs its own settings per cache name. A single global default sizes some caches too large and leaves others stale for too long.
Caffeine (L1)
Set maximumSize per cache, based on how many entries the heap can afford, multiplied by the number of instances. Use expire-after-write for anything another instance can change. It puts a fixed upper bound on staleness, which access-based expiry does not: a hot key read every second never expires under expire-after-access. Reserve expire-after-access for immutable or instance-local data, where the only goal is evicting cold entries.
RedisCacheManager (L2)
Build a RedisCacheConfiguration per cache name with entryTtl, and register them through the builder's withCacheConfiguration. Replace the default JDK serializer with an explicit JSON serializer via serializeValuesWith. JDK serialization ties stored bytes to class structure and is opaque to anything outside the JVM.
The L1 TTL must be shorter than the L2 TTL. Otherwise an instance keeps serving a value after Redis has expired or replaced it, and the instance never re-reads it.
| Setting | Caffeine (L1) | Redis (L2) |
|---|---|---|
| Bound | maximumSize per cache |
Memory policy on the server |
| Expiry | Expire-after-write, short | entryTtl, longer than L1 |
| Format | Live objects | JSON bytes |
During a rolling upgrade, old and new versions read the same keys. Put a schema version in the prefix with computePrefixWith, for example orders:v3::. When you make an incompatible model change, bump the version so each release reads only its own keys.
As a final safeguard, catch SerializationException in the composite cache's get. Evict the key and return a miss so the method recomputes the value instead of failing the request.
@Configuration
public class TwoLevelCacheConfig {
// Bump on any incompatible model change so old and new releases read different keys
static final String SCHEMA = "v3";
// L1: per-cache size bound and write-based expiry (bounded staleness), always shorter than L2
@Bean
public Map<String, CaffeineCache> l1Caches() {
return Map.of(
"orders", l1("orders", 10_000, Duration.ofSeconds(30)),
"products", l1("products", 50_000, Duration.ofMinutes(2)));
}
private static CaffeineCache l1(String name, long maxEntries, Duration ttl) {
return new CaffeineCache(name, Caffeine.newBuilder()
.maximumSize(maxEntries)
.expireAfterWrite(ttl)
.build());
}
// L2: JSON values instead of JDK serialization, versioned prefix, per-cache TTLs
@Bean
public RedisCacheManager l2CacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig()
.computePrefixWith(cacheName -> cacheName + ":" + SCHEMA + "::") // orders:v3::
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
// RedisCacheConfiguration is immutable: each entryTtl call returns a new config
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(base.entryTtl(Duration.ofMinutes(10)))
.withCacheConfiguration("orders", base.entryTtl(Duration.ofMinutes(5))) // L1 is 30s
.withCacheConfiguration("products", base.entryTtl(Duration.ofMinutes(30))) // L1 is 2m
.build();
}
}
Per-cache Caffeine size and expire-after-write settings next to a RedisCacheManager with a JSON value serializer, a schema-versioned key prefix, and per-cache TTLs that are longer than the matching L1 TTLs.
Implementing a composite Cache and CacheManager
Spring's CompositeCacheManager does not layer caches. It asks each delegate manager for a cache by name and returns the first match, so a lookup never falls through from Caffeine to Redis. To get two tiers you write your own Cache that wraps one Caffeine cache and one Redis cache. A small CacheManager builds one of these per cache name and caches the instances so every call for a name gets the same object.
Read path
get(key)checks L1 first. On a miss it checks L2. On an L2 hit it copies the wrapper into L1 and returns it.- Cached nulls. A returned wrapper holding
nullis a cached null. Anullwrapper is a miss. Treat the two differently. Configure both tiers with the sameallowNullValuessetting and store the same null marker in each, so a null cached in L1 always reflects what L2 holds and never hides real data there. get(key, valueLoader)delegates to Caffeine's per-key loading. Inside that function, check L2. Only on an L2 miss, call the loader and write the result to L2.
Write path
putwrites L2, then L1.evictremoves the key from L2, then L1.clearempties both tiers. On a large Redis keyspace this can be expensive.
Stampede prevention
@Cacheable(sync = true) routes calls through get(key, valueLoader). Caffeine then runs the loader at most once per key per JVM. Across ten instances, up to ten loaders can still run at the same time. If that load is unacceptable, add a distributed lock.
Annotation semantics
sync = true cannot be combined with unless. More broadly, condition, unless and null handling now depend on code you own, so cover each with integration tests.
import java.util.concurrent.Callable;
import org.springframework.cache.Cache;
// L1 = local CaffeineCache, L2 = shared RedisCache. Build both with the SAME allowNullValues.
public class TwoLevelCache implements Cache {
private final Cache l1, l2;
public TwoLevelCache(Cache l1, Cache l2) { this.l1 = l1; this.l2 = l2; }
public String getName() { return l2.getName(); }
public Object getNativeCache() { return this; }
public ValueWrapper get(Object key) {
ValueWrapper w = l1.get(key); // null wrapper = miss; wrapper holding null = cached null
if (w != null) return w;
w = l2.get(key);
if (w != null) l1.put(key, w.get()); // promote to L1, including a cached null
return w;
}
@SuppressWarnings("unchecked")
public <T> T get(Object key, Class<T> type) {
ValueWrapper w = get(key);
return w == null ? null : (T) w.get();
}
@SuppressWarnings("unchecked")
public <T> T get(Object key, Callable<T> loader) {
// @Cacheable(sync = true) lands here; Caffeine runs this at most once per key per JVM
return l1.get(key, () -> {
ValueWrapper w = l2.get(key);
if (w != null) return (T) w.get(); // L2 hit, possibly a cached null
T value = loader.call(); // only on an L2 miss
l2.put(key, value);
return value;
});
}
public void put(Object key, Object value) { l2.put(key, value); l1.put(key, value); }
public void evict(Object key) { l2.evict(key); l1.evict(key); } // other JVMs' L1 is untouched
public void clear() { l2.clear(); l1.clear(); } // can be costly on a large Redis keyspace
}
A composite Spring Cache that reads L1 then L2, promotes L2 hits (including cached nulls) into Caffeine, routes loader calls through Caffeine's per-key loading, and writes or evicts L2 before L1.
Cross-instance cache invalidation with Redis pub/sub
Wire invalidation into the composite Cache itself rather than into business code. That way @CachePut and @CacheEvict reach both tiers with no extra annotations.
On a write or evict. put writes Redis, then local Caffeine, then publishes a message. evict removes the key from Redis, then from Caffeine, then publishes. Use a StringRedisTemplate and keep the payload small:
- the cache name
- the key, serialized the same way it is in Redis
- an operation type
- the originating node's ID, generated once at startup
Publish only after the Redis write succeeds. Otherwise peers may evict and immediately reload the old value. A put message does not carry the new value. Peers simply drop their entry and reload from Redis on the next read, which avoids shipping large payloads and serialization mismatches over pub/sub.
On a received message. Every node subscribes through a RedisMessageListenerContainer. The handler compares the origin ID with its own and ignores its own messages. Without that check, a node would evict the value it just cached. Otherwise it evicts the key from the local Caffeine cache only. It must never touch Redis, because Redis already holds the correct value and a Redis eviction from the listener would remove fresh data.
Handling clear(). Publish a message with the operation set to clear and no key. Receivers call invalidateAll() on that cache.
Missed messages. Pub/sub is fire-and-forget, so a node that is disconnected misses messages and never receives them later. Two measures limit the damage:
- Keep Caffeine's
expireAfterWriteshort so the TTL caps how long a stale entry can survive. - Invalidate all L1 caches whenever the subscription is re-established.
A restarting node starts with an empty L1, so it is safe.
Read-your-writes. Even when everything works, a read on node B can arrive before B processes the message from node A. For caches that need read-your-writes consistency, skip L1 entirely and read straight from Redis.
import com.github.benmanes.caffeine.cache.Cache;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
// Payload: "<cacheName>|<op>|<originId>|<key>". The composite cache publishes it with
// stringRedisTemplate.convertAndSend("cache-invalidation", payload) after the Redis write succeeds.
// Register this listener on that channel in a RedisMessageListenerContainer.
public class L1InvalidationListener implements MessageListener {
private final String nodeId; // generated once at startup
private final Map<String, Cache<Object, Object>> l1; // cache name -> local Caffeine cache
public L1InvalidationListener(String nodeId, Map<String, Cache<Object, Object>> l1) {
this.nodeId = nodeId;
this.l1 = l1;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String body = new String(message.getBody(), StandardCharsets.UTF_8);
String[] parts = body.split("\\|", 4); // limit 4: the key itself may contain '|'
if (parts.length < 3) return;
String cacheName = parts[0], op = parts[1], origin = parts[2];
if (nodeId.equals(origin)) return; // our own message: L1 already holds the fresh value
Cache<Object, Object> local = l1.get(cacheName);
if (local == null) return;
// Only touch Caffeine. Redis already holds the correct value.
if ("CLEAR".equals(op)) {
local.invalidateAll();
} else if (parts.length == 4) { // PUT or EVICT: drop it; the next read reloads from Redis
local.invalidate(parts[3]);
}
}
}
A Redis pub/sub listener that ignores messages from its own node and evicts only the local Caffeine entry. On a clear message it invalidates the whole local cache, and it never writes to or evicts from Redis.
Failure handling, observability and testing
Degrading instead of failing
Spring's default SimpleCacheErrorHandler rethrows, so a Redis timeout becomes a 500 even though the database is healthy. Return a custom CacheErrorHandler from your CachingConfigurer that logs handleCacheGetError and handleCachePutError at WARN, with the cache name and key, and then returns. A failed get is treated as a miss and the method body runs against the source. A failed evict is different. Log it at ERROR and count it, because a swallowed evict means stale data.
Keeping L1 serving during an outage is usually the right call, but understand the cost. Pub/sub is not durable, so invalidations published while a node was disconnected are lost. Clear L1 when the Redis connection is re-established, and keep L1 TTLs short enough that the worst-case staleness is acceptable.
Metrics per tier
A composite cache reports one blended hit rate, which hides the useful question. Enable recordStats() on Caffeine and statistics on the RedisCacheManager builder. Then bind each tier's underlying cache to the MeterRegistry yourself, tagged tier=l1 or tier=l2. Boot's automatic binding won't recognize your composite type. If L2 hits stay near zero, L1 is absorbing the load and Redis may not be worth its operational cost.
Proving invalidation works
Start one Redis container with Testcontainers. Build two contexts with SpringApplicationBuilder, both pointing at it. Populate the same key on both nodes, then evict on node A. Use Awaitility to poll node B's Caffeine cache until the entry is absent. Delivery is asynchronous, so a fixed sleep will make the test flaky.
How a two-level cache saves engineering time
The latency gain is the visible benefit. The engineering-time savings come from where the design puts the caching logic and how it makes problems observable.
- Invalidation lives in one place. Because the composite cache publishes invalidations on every
put,evictandclear, application developers keep using@Cacheable,@CachePutand@CacheEvictas before. Nobody has to remember to add a custom eviction call next to each write, and code review doesn't need to check for it. - Staleness has a stated upper bound. Each cache name has an explicit L1 TTL that is shorter than its L2 TTL. When someone reports stale data, the first question has a fixed answer ("at most 30 seconds for
orders"), and anything older than that points directly at a bug rather than at an undocumented local cache somewhere. - Hand-rolled local caches go away. Without a shared pattern, teams tend to add ad-hoc
ConcurrentHashMapcaches or per-service Caffeine instances that nothing invalidates. Replacing them with one tested composite cache removes a class of hard-to-reproduce bugs. - Redis failures stop becoming outages. The custom
CacheErrorHandlerturns Redis timeouts into cache misses, so a Redis incident becomes slower requests and a WARN log instead of a page for every service that caches. - Tuning decisions use data. Per-tier hit ratios answer the questions teams otherwise argue about: whether L1 is sized correctly, whether a TTL is too short, and whether Redis is earning its place. Changing a size or TTL becomes a configuration change checked against a metric.
- Invalidation regressions are caught in CI. The two-context Testcontainers test reproduces the multi-instance case on a developer machine, so a broken listener or wrong eviction order fails a build instead of surfacing as an intermittent production report.
These savings depend on the team keeping the configuration disciplined and the composite classes maintained, which the next section covers.
Trade-offs, alternatives and impact on engineering cost
| Option | Read latency on hit | Cross-instance staleness | Code you own |
|---|---|---|---|
| Redis-only | Network round trip | None beyond Redis TTL | Configuration only |
| Caffeine-only | In-process | Unbounded until TTL or restart | Configuration only |
| Short-TTL local, no pub/sub | In-process | Up to the local TTL | Configuration only |
| Two-level with pub/sub | In-process on L1 hit | Pub/sub delivery delay | Composite cache, listener, tests |
The two-level setup has real costs. L1 heap is multiplied by instance count, and churn in a large Caffeine cache adds GC pressure. Caffeine returns the stored reference, so a caller that mutates a cached object corrupts it for every later reader on that node. Use immutable values or copy on read. The configuration surface also doubles. If the L1 TTL is longer than the L2 TTL, one node can serve data that Redis has already expired, and that bug looks random until someone compares the two settings. The composite classes also need an owner through every Spring upgrade.
The payoff comes from discipline. Explicit eviction cuts the staleness incidents debugged in production. Per-tier hit ratios settle tuning arguments with data, so the team doesn't have to rely on intuition.
Illustrative estimate (assumed numbers, not a benchmark). Saved time per second = requests/s × L1 hit ratio × (Redis RTT − local lookup). Assume 5,000 req/s, a 0.8 hit ratio and 1 ms saved per hit. That gives 4 s of request time saved per wall-clock second. Heap cost = entries × average size × instances. With 100,000 entries × 1 KB × 12 instances, that is about 1.2 GB.
If your latency budget doesn't need that saving, choose the short-TTL local option.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to spring boot two level cache, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.