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.

Cloud Architecture
Cloud Architecture
Cloud Architecture
Spring Multi-Level Cache: Production Design & Management with Caffeine + Redis

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 value

Back‑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 eviction

Delayed 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‑factor

Container & 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

CacheRedisSpringCaffeineMulti-Level Cache
Cloud Architecture
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.