Spring Boot Multi-Level Cache: Caffeine + Redis Design with Consistency & 78K QPS

This article details a production-grade Spring Boot multi-level caching architecture using Caffeine L1 and Redis L2 to solve database IO bottlenecks in read-heavy scenarios, covering consistency solutions via MQ async with idempotency, defenses against cache penetration, breakdown, and avalanche, and load test results showing 78K QPS with 5.2ms P99 latency.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Spring Boot Multi-Level Cache: Caffeine + Redis Design with Consistency & 78K QPS

1. Business Pain Points: Database IO Bottlenecks in Read-Heavy Scenarios

E-commerce detail pages, user profiles, and content feeds are typical read-heavy, write-light interfaces. At 100K daily active users MySQL copes, but at 1M DAU the connection pool saturates, slow SQL queues build up, and disk IOPS hits the ceiling. P99 latency degrades from 20ms to over 2s, CPU hovers at 85%, forcing fallback to circuit breakers.

Adding read replicas or tuning indexes only relieves compute and local storage pressure; network round-trips and random disk I/O remain fundamental bottlenecks. When QPS exceeds 5,000 with 90% repeated queries on the same hot data, the database becomes the chain's weak link. Caching is inevitable, but simply adding Redis or a local ConcurrentHashMap invites cache breakdown, cross-node inconsistency, and OOM. A robust cache chain must provide fallback, graceful degradation, and clear boundaries.

2. Architecture Selection: Caffeine Local Cache + Redis Distributed Cache

Production environments typically run an L1 (in-process) + L2 (distributed) combination. L1 uses Caffeine; L2 uses Redis.

Caffeine's W-TinyLFU algorithm achieves high hit rates, its lock-free design saturates CPU caches, and read latency stays under 1ms. However, its data lives only in the current JVM, unsuitable for global sharing. Redis excels at cross-node visibility but suffers from network serialization and single-node bandwidth limits. Stacking both creates a natural funnel: L1 absorbs 70%–85% of super-hot requests, L2 handles the remainder, and the database only serves cold data or writes.

Three concrete benefits observed in production:

Traffic layering : L1 digests local hotspots, L2 carries global hot data, DB retreats to a secondary role.

Fault isolation : If the Redis cluster jitters or a network partition occurs, L1 can temporarily sustain traffic, preventing an immediate DB meltdown.

Cost control : Caffeine is strictly heap-bound (configure maximumSize) with a sensible eviction policy, which is far cheaper than blindly scaling Redis nodes. Off-heap memory is generally not recommended for Caffeine; tuning JVM GC alongside heap limits is more stable.

The read path is straightforward: check L1 → if hit return; else check L2 → if hit, populate L1 and return; else query DB, then write back to L2 and L1 per strategy.

3. Spring Cache Annotation Limitations and Manual Wrapper

@Cacheable

is convenient but falls short for production-grade multi-level caching:

Multi-level coordination impossible : it binds to a single Cache implementation, unable to express L1 miss → L2 hit → backfill L1 cascading logic.

Rigid serialization : L1 needs no serialization (raw Java objects), while L2 requires JSON/Protobuf. The annotation forces one serializer, wasting CPU.

Missing invalidation broadcast : @CacheEvict only affects the current node; other nodes' L1 caches never receive the notification, guaranteeing eventual inconsistency.

Therefore, projects typically bypass @Cacheable and wrap a MultiLevelCacheService to own the read/write lifecycle, controlling serialization protocols and node synchronization on demand.

public class MultiLevelCacheService {
    private final Cache<String, Object> l1; // Caffeine instance
    private final StringRedisTemplate redisTemplate;
    private final String cacheName;
    // assume Jackson/Protobuf serialization utilities are available

    public MultiLevelCacheService(Cache<String, Object> l1, StringRedisTemplate rt, String name) {
        this.l1 = l1;
        this.redisTemplate = rt;
        this.cacheName = name;
    }

    public <T> T get(String key, Class<T> type, Callable<T> dbLoader) {
        String ck = cacheName + ":" + key;

        // 1. Check L1 (no serialization overhead)
        Object l1Val = l1.getIfPresent(ck);
        if (l1Val != null) return type.cast(l1Val);

        // 2. Check L2
        String l2Json = redisTemplate.opsForValue().get(ck);
        if (l2Json != null) {
            T obj = JsonUtil.deserialize(l2Json, type);
            l1.put(ck, obj); // backfill L1, respect memory limit
            return obj;
        }

        // 3. L2 miss, invoke loader to query DB
        try {
            T dbVal = dbLoader.call();
            if (dbVal != null) {
                String json = JsonUtil.serialize(dbVal);
                l1.put(ck, dbVal);
                redisTemplate.opsForValue().set(ck, json, 30, TimeUnit.MINUTES);
            }
            return dbVal;
        } catch (Exception e) {
            throw new RuntimeException("Cache loader failed", e);
        }
    }
}

Paired with a business-layer Callable for DB logic, the read/write path is fully controllable. The critical point is that on evict, Redis Pub/Sub or MQ broadcast must notify all cluster nodes to clear their local L1; otherwise, multi-node environments will inevitably see stale reads.

4. Cache Consistency Evolution: Delayed Double Delete → Canal Binlog → MQ Async

Cache-DB consistency is essentially a distributed transaction problem. Production solutions have evolved through three generations:

4.1 Delayed Double Delete (Deprecated)

Flow: update DB → delete cache → sleep a few hundred ms → delete again. No longer used. The sleep duration is impossible to tune correctly; under concurrency the dirty-read window still leaks, and the main thread blocks unnecessarily — a recipe for disaster under high load.

4.2 Canal Binlog Subscription

Masquerades as a MySQL slave to consume binlog. Application code stays clean, with strong reliance on transaction logs and guaranteed eventual consistency. However, all heavy lifting shifts to the middleware team: transaction splitting (aggregating multiple DMLs in one transaction), message reordering, DDL filtering. Maintenance cost is high; small-to-medium teams usually cannot operate it.

4.3 MQ Async + Idempotent Consumption (Recommended for Production)

After the business DB transaction commits, synchronously or asynchronously emit a CacheUpdateEvent to Kafka/RabbitMQ. The consumer uses txId to build an idempotency key in Redis, preventing duplicate processing. Upon acquiring exclusive right, it refreshes L2 and publishes a Pub/Sub message to broadcast L1 invalidation across all nodes.

// Producer side: emit event after transaction commits
@EventListener(condition = "#result.success")
public void onUserUpdate(UserUpdatedEvent event) {
    rabbitTemplate.convertAndSend("cache.update.exchange", "", event);
}

// Consumer side: idempotent handling + L2 update + L1 broadcast
@RabbitListener(queues = "cache.update.queue")
public void handle(CacheEvent msg) {
    String idemKey = "cache:idem:" + msg.getTxId() + ":" + msg.getRowId();
    Boolean isFirst = stringRedisTemplate.opsForValue().setIfAbsent(idemKey, "1", 1, TimeUnit.HOURS);
    if (Boolean.FALSE.equals(isFirst)) return;

    if ("DELETE".equals(msg.getOp())) {
        redisTemplate.delete(msg.getCacheKey()); // clear L2
        redisTemplate.convertAndSend("cache.l1.clear", msg.getCacheKey()); // notify L1 clear
    }
}

This scheme achieves eventual consistency; combined with a dead-letter queue for retrying failed events, it covers 99.9% of failure scenarios. For true strong-consistency requirements (balances, inventory), avoid caching altogether and use DB transactions or dedicated consistency protocols.

5. Defense Strategies for Three Classic Cache Problems with Code

Multi-level caching amplifies concurrency risks; production must harden against all three.

5.1 Cache Penetration (Querying Non-Existent Data)

Use a Bloom filter to reject obviously absent keys; remaining requests hit the DB. On a miss, cache a null value with a short TTL (e.g., 5 minutes) to prevent attackers from flooding with fabricated keys. Wrap results in Optional to avoid null-pointer confusion.

5.2 Cache Breakdown (Hot Key Expiration Under Concurrent Load)

When a hot key expires, hundreds of threads can punch through to the DB. Distributed locking is standard, but avoid recursive retries (stack overflow, latency spikes). Production uses tryLock: the thread that acquires the lock loads from DB and backfills; threads that fail to acquire the lock wait a few tens of milliseconds then fall back to a stale value or fast-fail.

public Object getWithLock(String key, Callable<Object> loader) {
    String ck = cacheName + ":" + key;
    Object val = l1.getIfPresent(ck);
    if (val != null) return val;

    RLock lock = redisson.getLock("lock:cache:" + ck);
    boolean locked = false;
    try {
        locked = lock.tryLock(2, 5, TimeUnit.SECONDS);
        if (locked) {
            // double-check to avoid duplicate loading while waiting
            val = l1.getIfPresent(ck);
            if (val == null) {
                val = loader.call();
                if (val != null) {
                    l1.put(ck, val);
                    // sync to L2...
                }
            }
        } else {
            // failed to acquire lock, degrade or return stale cache if available
            return fallbackRead(key);
        }
    } catch (Exception e) { /* log */ }
    finally {
        if (locked) lock.unlock();
    }
    return val;
}

5.3 Cache Avalanche (Mass Key Expiration Simultaneously)

Never hard-code TTL; add random jitter. Example: base 30 minutes ±10%–20%. Pair with Resilience4j circuit breaker — when the DB cannot cope, immediately switch to fallback logic. Multi-level caching inherently resists avalanches: L1 expiration still has L2 as a shield; if L2 collapses, the circuit breaker intercepts the surge before it reaches the DB.

// Dynamic TTL calculation on write
int baseTtl = 1800; // 30 minutes
int jitter = ThreadLocalRandom.current().nextInt(300);
redisTemplate.expire(key, baseTtl + jitter, TimeUnit.SECONDS);

// Combined with circuit breaker
@CircuitBreaker(name = "db-load", fallbackMethod = "fallbackRead")
public Object loadFromDb(String key) {
    return dbMapper.selectByKey(key);
}

6. Monitoring Metrics Design and Load Test Validation

Running caches without observability is flying blind. We use Micrometer + Prometheus to track core metrics: L1 and L2 hit rates (Gauge), actual DB query QPS (Counter), full-chain P99 latency (Timer), and L1 memory eviction count.

If L1 hit rate drops below 60%, capacity is likely insufficient or hotspot distribution shifted — adjust maximumSize or pre-warm. Sustained L2 hit rate under 75% signals overly aggressive expiration or severe data skew. A sudden spike in DB query QPS exceeding 3× the historical baseline is a leading indicator of an impending avalanche; trigger contingency plans early.

Load test with JMeter: 1,000 concurrent threads, mixed read/write (10:1) for 30 minutes on a 5M-row dataset. Direct DB: P99 ~140ms, QPS ~2,100, DB CPU 88%. Single Redis: latency 18ms, QPS 24K. Caffeine + Redis multi-level: P99 5.2ms, QPS 78K, DB CPU 3.5%. Monitoring showed L1 absorbed 76.4% of requests (zero network serialization overhead), L2 handled 18.1%, per-node memory ~1.8GB, GC pause time reduced by 60%. Hard numbers prove the architecture's real throughput gains.

7. Summary

Multi-level caching ultimately balances consistency, performance, and complexity. In core internet pathways, strong consistency often sacrifices performance; eventual consistency with idempotent retries and dead-letter fallbacks handles the vast majority of cases. For true financial or inventory strong-consistency needs, stick to DB transactions or dedicated protocols — don't force-fit caching.

As complexity rises, observability must keep pace. Metrics, traces, and logs must be connected; degradation switches must be pre-wired. When Redis fails or L1 OOMs, the system needs an escape route. Caching is a lever that exploits business data characteristics. Understand the read/write ratio and expiration patterns before sizing L1 and setting TTLs. Don't follow trends blindly; stability in production and rapid incident diagnosis are the real measures of success.

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.

Redisspring-bootLoad testingCache consistencyCaffeineDistributed lockingMulti-level cachingMicrometer
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

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.