Spring Multi-Level Cache: Production Design & Management with Caffeine + Redis
Spring’s multi‑level caching combines Caffeine’s ultra‑fast local store with Redis’s distributed capacity to tackle high‑concurrency challenges such as read amplification, cache storms, consistency, and capacity management, offering a production‑grade design, implementation details, risk boundaries, and evolution paths for robust Spring applications.
Why multi‑level caching is essential
Introducing a single Redis cache often relieves database pressure, but as traffic grows Redis becomes a new bottleneck due to CPU, network RTT, connection pool limits and serialization cost. Adding a local in‑process cache (Caffeine) absorbs instance‑local hot reads, eliminates network and serialization overhead, and isolates load, while Redis remains the shared, scalable backing store.
Typical product‑detail use case
A product‑detail page aggregates dozens of data sources (basic info, price snapshot, stock, marketing tags, shop info, delivery promise, risk flags). Normal traffic reaches ~20 k QPS and spikes up to 50 k QPS. A naïve chain of service calls suffers from three problems: jitter amplification, high CPU/network cost for object assembly and serialization, and repeated hot‑spot computation.
Cache layers
L1 – Caffeine
Zero network latency, no request queuing, no serialization.
Uses W‑TinyLFU eviction to keep true hot entries while protecting against scan‑type traffic.
Asynchronous maintenance avoids blocking reads.
L2 – Redis
Provides distributed sharing, large capacity and a mature ecosystem.
Supports rich data structures (sets, locks, Bloom filters, leaderboards, delayed queues, lightweight notifications).
Acts as the consistency buffer between instances.
Five‑path collaborative design
Read path – request flows L1 → L2 → DB.
Write path – after DB update, invalidate caches.
Back‑source path – prevent concurrent DB hits on miss (single‑flight, distributed lock, logical expiration).
Invalidation path – broadcast L1 eviction after DB commit.
Governance path – monitoring, tuning, rate‑limiting, degradation, capacity planning.
Read path implementation
request → query L1 (Caffeine)
→ hit: return
→ miss: query L2 (Redis)
→ hit: deserialize & back‑fill L1
→ miss: acquire single‑flight / distributed lock
→ lock holder loads from DB, writes L2 then L1, returns
→ others wait briefly, then read L2 again or serve stale valueBack‑filling L2 before L1 keeps instances consistent; writing L1 first could leave other instances with stale data if the L2 write fails.
Write path and consistency
Most read‑heavy scenarios accept eventual consistency, so the recommended pattern is Cache‑Aside + event‑driven invalidation + optional delayed double‑delete.
update DB → on transaction commit publish invalidation event → delete Redis key → broadcast L1 evictionDelayed double‑delete is useful when concurrent reads may still hold old values after a DB update; a short delayed second delete narrows the stale window.
Production‑grade cache service
Define cache metadata with an immutable CacheSpec record that holds cache name, TTLs, null‑value handling, stale‑value allowance and a flag to enable the local cache.
public record CacheSpec(
String cacheName,
Duration localTtl,
Duration remoteTtl,
Duration nullValueTtl,
boolean cacheNullValue,
boolean allowServeStale,
Duration staleTtl,
boolean enableLocalCache) { ... }Wrap cached payloads in a CacheEnvelope<T> to distinguish null values, support logical expiration, versioning and future extensions.
public class CacheEnvelope<T> implements Serializable {
private boolean nullValue;
private long version;
private Instant logicalExpireAt;
private T data;
public static <T> CacheEnvelope<T> of(T data, long version, Instant logicalExpireAt) { ... }
public boolean isNullValue() { return nullValue; }
public boolean isLogicallyExpired() { return logicalExpireAt != null && logicalExpireAt.isBefore(Instant.now()); }
public T getData() { return data; }
public long getVersion() { return version; }
}The core service provides get, loadAndFill and evict methods. It first checks L1, records hit/miss metrics, falls back to L2, and on miss uses a Redis SETNX lock to ensure only one loader hits the DB. After loading, it writes the envelope to Redis (with jittered TTL) and optionally to Caffeine.
public <T> T get(String businessKey, CacheSpec spec, TypeReference<CacheEnvelope<T>> type, Supplier<T> loader) {
String remoteKey = spec.buildRemoteKey(businessKey);
if (spec.enableLocalCache()) {
CacheEnvelope<?> localHit = localCache.getIfPresent(remoteKey);
if (localHit != null) { recordL1Hit(spec); return unwrap(spec, cast(localHit)); }
recordL1Miss(spec);
}
Object remoteCached = redisTemplate.opsForValue().get(remoteKey);
if (remoteCached instanceof CacheEnvelope) { recordL2Hit(spec); ... }
recordL2Miss(spec);
return loadAndFill(businessKey, spec, loader, remoteKey);
}
private <T> T loadAndFill(String businessKey, CacheSpec spec, Supplier<T> loader, String remoteKey) {
String lockKey = spec.buildLockKey(businessKey);
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, Duration.ofSeconds(3));
if (Boolean.TRUE.equals(locked)) {
try {
T loaded = loader.get();
CacheEnvelope<T> envelope = CacheEnvelope.of(loaded, currentVersion(spec, businessKey), Instant.now().plus(spec.staleTtl()));
redisTemplate.opsForValue().set(remoteKey, envelope, addJitter(spec.remoteTtl()));
if (spec.enableLocalCache()) localCache.put(remoteKey, envelope);
return loaded;
} finally { safeUnlock(lockKey, lockValue); }
}
// wait, retry, or serve stale if allowed
...
}
public void evict(String businessKey, CacheSpec spec) {
String remoteKey = spec.buildRemoteKey(businessKey);
redisTemplate.delete(remoteKey);
localCache.invalidate(remoteKey);
}Cross‑instance consistency
After a successful DB transaction, publish an event (e.g., via Spring ApplicationEventPublisher) and delete the Redis key. The event listener on each instance consumes the message and calls evict to remove the corresponding L1 entry.
public void publishProductChanged(Long productId) {
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { eventPublisher.publishEvent(new ProductChangedEvent(productId)); }
});
} else {
eventPublisher.publishEvent(new ProductChangedEvent(productId));
}
}
@EventListener
public void onProductChanged(ProductChangedEvent e) {
String key = String.valueOf(e.productId());
multiLevelCacheService.evict(key, PRODUCT_DETAIL_CACHE);
messageSender.send("product:detail", key); // MQ/Kafka broadcast
}Cache pitfalls and mitigations
Cache penetration – validate parameters, use existence filters (Bloom), cache short‑TTL null values.
Cache breakdown – single‑flight, distributed lock, logical expiration with async refresh, hot‑key pre‑warming.
Cache avalanche – apply TTL jitter, staggered pre‑warm, local fallback, rate‑limit, Redis HA.
Hot‑key & big‑key handling – keep hot keys in L1, split large objects, avoid storing massive aggregates in Caffeine.
Monitoring and capacity planning
Key metrics (exposed to Prometheus/Grafana): L1/L2 hit‑miss counts, DB load count, cache load latency, lock wait count, null‑value hits, stale‑value serves, Redis QPS, CPU, memory, hot‑key distribution, connection count, slow queries, replication lag.
Capacity estimation formulas:
L1 memory ≈ hot‑object‑count × avg‑size × safety‑factor
Redis memory ≈ total‑object‑count × avg‑size × replica‑factor × safety‑factorContainer & microservice considerations
When running in Kubernetes, bind the Caffeine maximumSize to the pod’s memory limit, anticipate cold L1 after pod restarts, and use a dynamic config center (Nacos/Apollo) to adjust TTLs and feature switches without redeploy. Avoid configuring a maximumSize that can exceed the container’s heap, which would cause GC spikes or OOM kills.
Checklist for production rollout
Separate responsibilities of L1, L2 and DB.
Invalidate caches only after DB transaction commit.
Broadcast L1 eviction across instances (MQ/Kafka).
Monitor hit‑miss, DB load, latency, Redis health.
Apply TTL jitter, hot‑key protection and fallback strategies.
Externalize tunable parameters in a config center and roll out changes gradually.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Cloud Architecture
Focuses on cloud‑native and distributed architecture engineering, sharing practical solutions and lessons learned. Covers microservice governance, Kubernetes, observability, and stability engineering to help your systems run stable, fast, and cost‑effectively.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
