← Home
Caching

Top 8 Senior Interview Questions

Cache Strategies · Spring @Cacheable · Redis · Stampede Prevention · Invalidation · Consistency

Questions

  1. Cache-aside, write-through, write-behind — when to choose each
  2. Spring @Cacheable — internals and pitfalls
  3. Cache stampede (thundering herd) — causes and prevention
  4. Cache invalidation strategies
  5. Redis data structures beyond simple key-value
  6. Cache and DB consistency under concurrent writes
  7. Redis eviction policies and memory management
  8. Local (Caffeine) vs distributed (Redis) cache — trade-offs and two-level caching
Q1 · Cache Strategies
Walk me through cache-aside, write-through, and write-behind. When do you choose each?

Direct answer

There are three main write strategies. Cache-aside (lazy loading) is the most common — the application checks the cache first, and on a miss loads from DB and populates the cache itself. Write-through writes every change to both cache and DB synchronously, keeping them always in sync. Write-behind (write-back) writes to cache immediately and flushes to DB asynchronously, trading consistency for write speed.

How each works and when to use it

PatternReadWriteBest forTrade-off
Cache-asideApp checks cache, misses load DBApp writes DB, evicts or updates cacheRead-heavy, tolerable stalenessCold start misses; app owns all cache logic
Write-throughAlways in cacheSync to cache + DBConsistency-critical, small datasetsWrite latency doubles; writes for keys never read waste cache
Write-behindAlways in cacheCache only, async flush to DBWrite-heavy, durability trade-off acceptableData loss on crash; complex failure handling

Cache-aside with cache-aside read + evict-on-write is my default — it's simple, well-understood, and works with any caching layer. I add write-through only when I need guaranteed read consistency with no staleness window, which is rare in practice.

The production gotcha

The cold start problem with cache-aside: after a deploy or a full cache flush, every key misses simultaneously. If you have 10,000 users active and all their session data just expired at once, every request goes to DB in the same second — often enough to take down an under-sized DB.

Mitigations: warm-up scripts that pre-populate critical keys after deploy; staggered TTLs (add random jitter to expiry times so they don't all expire together); or probabilistic early expiry (XFetch algorithm — before TTL expires, randomly refresh based on remaining time).

Interview drill-down — expect these follow-ups:
L1 → "With cache-aside, what happens right after a deploy when the cache is cold?"
All keys are missing simultaneously — every request goes to DB. Called the "cold start" or thundering herd on restart. Fix: deploy a cache-warming step that pre-populates top N keys (most popular products, active sessions) before traffic is routed to the new instance. Or use a blue/green deploy where the old instances stay up long enough to keep the cache warm.
L2 → "Write-through ensures consistency — so why isn't it the default?"
Two problems. First: write latency doubles — every write must hit both cache and DB synchronously before acknowledging. Second: write amplification — you cache data for every write, including keys that may never be read. Cache fills up with cold data that could have been warmed lazily. Write-through makes sense for small, fully-read datasets like config tables. Rare in practice for general APIs.
L3 → "Can you combine strategies? For example, write-through for some data and cache-aside for others?"
Yes, and it's common. Critical session data (auth tokens) → write-through so every request sees the current token. Product catalog → cache-aside with 30-minute TTL — staleness is fine. Configuration → write-through or manual refresh. Spring's CompositeCacheManager with separate cache names lets you configure different policies per cache.
Trap answer to avoid: "Write-through is better because it's always consistent." — Interviewers expect you to weigh write latency, write amplification, and data access patterns. Always-consistent is not always worth the trade-off.
Q2 · Spring Cache
How does Spring's @Cacheable work under the hood, and what are its pitfalls?

Direct answer

Spring @Cacheable is proxy-based AOP — the same mechanism as @Transactional. When a @Cacheable method is called through the Spring proxy, the proxy intercepts the call, computes a cache key using SpEL, checks the CacheManager, and either returns the cached value or calls the real method and stores the result. This means it has the same self-invocation limitation: calling this.method() bypasses the proxy entirely.

Key annotations and configuration

// Cache the result; key is the userId parameter
@Cacheable(value = "users", key = "#userId", unless = "#result == null")
public User findUser(Long userId) { ... }

// Evict on write — always evict, never update
@CacheEvict(value = "users", key = "#user.id")
public void updateUser(User user) { ... }

// Update cache with new value after write
@CachePut(value = "users", key = "#result.id")
public User saveUser(User user) { ... }

Configure RedisCacheManager with per-cache TTLs in production:

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory cf) {
    RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(30))
        .disableCachingNullValues();

    return RedisCacheManager.builder(cf)
        .cacheDefaults(defaults)
        .withCacheConfiguration("users",
            defaults.entryTtl(Duration.ofMinutes(5)))    // shorter TTL for users
        .withCacheConfiguration("config",
            defaults.entryTtl(Duration.ofHours(24)))   // longer TTL for static config
        .build();
}

The pitfalls

1. Self-invocation. getFullProfile() calling this.findUser(id) — the proxy is bypassed. The cache never fires. Fix: extract the cached method into a separate bean.

2. Null caching. By default, Spring caches null return values. A method that returns null for a missing user will cache that null — subsequent calls return null even if the user now exists. Add unless="#result == null".

3. @CacheEvict on @Transactional methods. If the eviction runs inside the transaction and the transaction later rolls back, the cache entry is gone but the DB change is also rolled back — now the cache is empty and consistent, but if the eviction ran before the rollback, subsequent reads repopulate the cache with stale pre-rollback data. Prefer @TransactionalEventListener(AFTER_COMMIT) for eviction.

Interview drill-down — expect these follow-ups:
L1 → "You said @CacheEvict inside @Transactional is dangerous — walk me through the exact failure scenario."
Transaction begins → updateUser() runs → @CacheEvict fires, deletes cache key → transaction rolls back (e.g. DB constraint violation). Now the DB has the old value, but the cache key is gone. Next read repopulates cache from DB — which gets the old value. Appears correct but you've just caused unnecessary cache churn and a brief inconsistency window for concurrent readers who hit between the eviction and the repopulation.
L2 → "Can @Cacheable and @Transactional interact unexpectedly?"
Yes. If a @Cacheable method is called inside a @Transactional method, the cached value bypasses the DB read — but inside the transaction you might need to see the current transactional state. A cache hit serves the pre-transaction snapshot. Fix: if you need within-transaction freshness, use @CachePut to invalidate caches and re-read, or exclude that method from caching when called in a transactional context.
L3 → "How do you test @Cacheable without a running Redis?"
Use ConcurrentMapCacheManager in tests (in-memory, no infrastructure). Spring Boot's @SpringBootTest auto-configures a test cache manager if you annotate the test with @EnableCaching and provide a TestCacheManager bean. For verifying cache hits/misses: spy on the repository, assert that repository.findById() is called exactly once across two service calls — second call should hit cache.
Trap answer to avoid: "@Cacheable is just a simple cache — I set it and forget it." — This signals you haven't operated it in production. Interviewers want to hear about self-invocation, null caching, the @Transactional interaction, and monitoring cache hit rates.
Q3 · Stampede Prevention
What is a cache stampede and how do you prevent it?

Direct answer

A cache stampede (thundering herd) happens when a popular cache entry expires and many concurrent requests all miss at the same instant. All of them query the database simultaneously, generating a spike that can exceed DB capacity. The more popular the key, the worse the spike.

Prevention strategies

1. Distributed lock (mutex). First thread to miss acquires a lock, fetches from DB, writes cache. All other threads wait, then get the now-cached value. Double-check after acquiring the lock — another thread may have already populated it.

public Product getProduct(String id) {
    String key = "product:" + id;
    Product cached = redis.opsForValue().get(key);
    if (cached != null) return cached;  // fast path

    RLock lock = redisson.getLock("lock:" + key);
    try {
        lock.tryLock(2, 10, TimeUnit.SECONDS);
        cached = redis.opsForValue().get(key);          // double-check after lock
        if (cached != null) return cached;

        Product p = repo.findById(id).orElseThrow();
        redis.opsForValue().set(key, p, Duration.ofMinutes(5));
        return p;
    } finally { lock.unlock(); }
}

2. Probabilistic early expiry (XFetch). Before the TTL expires, randomly decide to refresh early. The probability increases as expiry approaches. No locks, no waiting — a random request triggers the refresh slightly early, preventing the cliff-edge expiry.

3. Stale-while-revalidate. Serve the stale cached value immediately, trigger an async refresh in the background. Users always get a fast response; the cache stays fresh with a slight lag. Best for read-heavy, low-staleness-tolerance data.

The production gotcha

The mutex pattern with a lock timeout of 2s means that if your DB query takes 3s (under load, it might), the lock expires, a second thread acquires it and also hits the DB, and now you have two concurrent DB queries instead of one. The lock lease must be longer than your worst-case DB response time. When in doubt, stale-while-revalidate is safer because it never holds a lock at all.

Interview drill-down — expect these follow-ups:
L1 → "The distributed lock approach — what happens if Redis goes down while the lock is held?"
The lock lease expires via TTL even if Redis is down momentarily (Redisson sets a lease time). If Redis is fully unavailable: the lock() call fails, the stampede falls through to the DB — same as having no lock. You need a fallback: catch the RedisConnectionFailureException, fall through to DB directly. Redis going down should degrade to higher DB load, not cause a service failure.
L2 → "You mentioned XFetch / probabilistic early expiry. How does the probability formula work?"
P(refresh) = exp(-β × remaining_ttl / delta) where delta is how long the last recompute took and β is a tunable coefficient (typically 1.0). The closer to expiry and the faster the computation, the lower the probability of refreshing early. This means expensive operations are refreshed early more often (their delta is larger), naturally preventing stampedes on costly keys without locking.
L3 → "Stale-while-revalidate — how do you implement it thread-safely in Java without a library?"
Store a CacheEntry with the value and an expiresAt timestamp. On read: if within TTL, return value. If past a "soft" expiry (say, 80% of TTL) but within "hard" expiry, return stale value AND submit a CompletableFuture to an async executor to refresh. Use an AtomicBoolean to ensure only one thread triggers the refresh (CAS from false to true). The hard expiry (100% TTL) forces a synchronous refresh if the async one hasn't completed yet.
Trap answer to avoid: "Just set a very short TTL to avoid stampedes." — Shorter TTLs cause more frequent expiries, which increases the frequency of stampede windows. You've made the problem worse, not better. The correct fix is probabilistic early expiry or distributed locking.
Q4 · Invalidation
Cache invalidation is one of the hardest problems in CS. How do you approach it?

Direct answer

There's no universally correct answer — the right strategy depends on your consistency requirements and system topology. My hierarchy of approaches: TTL-based first (simplest, eventually consistent), then event-driven eviction on write (stronger consistency), then CDC-based invalidation (strongest, highest complexity).

Strategies in practice

TTL-based. Set an expiry that matches your staleness tolerance. Choose TTL based on how often data changes and how stale is acceptable. The simplest approach — no eviction logic needed. Works well for reference data that changes infrequently.

@CacheEvict on write methods. Works when all writes go through your service. Evict the cache entry on every write — next read repopulates from DB. The key is to evict after commit, not during the transaction:

// WRONG — eviction can run before commit, then rollback leaves cache empty
@Transactional
@CacheEvict(value = "orders", key = "#id")
public void updateOrder(Long id, String status) { ... }

// CORRECT — evict only after successful commit
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderUpdated(OrderUpdatedEvent e) {
    cacheManager.getCache("orders").evict(e.getOrderId());
}

CDC-based invalidation. Debezium reads the DB write-ahead log and publishes change events. A consumer listens and evicts the affected cache keys. This works across services — Service B can invalidate Service A's cache when it changes shared data. It's the strongest consistency approach but adds significant infrastructure.

The production gotcha — the write race

Even with "evict on write", there's a subtle race: Thread A reads (cache miss, starts loading from DB). Thread B writes new value to DB + evicts cache. Thread A finishes its DB read and writes the old value back into cache. Cache now has stale data until its TTL expires.

The delayed double-delete pattern handles this: after writing to DB, delete the cache key immediately, then delete it again ~500ms later. The second delete catches the case where a concurrent read just repopulated with stale data between your write and first eviction.

Interview drill-down — expect these follow-ups:
L1 → "What's wrong with the @TransactionalEventListener approach if the event listener fails?"
If the listener throws after AFTER_COMMIT, the transaction is already done — you can't roll it back. The DB has the new value but the cache eviction failed, so the cache has the old value. Fix: make the eviction idempotent and add retry logic in the listener. Or accept a short consistency window and rely on TTL to expire the stale entry. Cache eviction failure should be a soft failure, not a fatal error.
L2 → "Delayed double-delete — doesn't the second delete just always miss if the first delete worked?"
That's fine — deleting a non-existent key is a no-op in Redis. The second delete is the defensive one: it catches the race where Thread A's stale DB read happened between the write and the first delete. The cost is one extra Redis DELETE every write — negligible. It's not an optimisation; it's a safety net for the race window.
L3 → "What is CDC-based cache invalidation and when is it worth the complexity?"
Change Data Capture (Debezium) reads the Postgres WAL (write-ahead log) and publishes change events to Kafka. A consumer listens and invalidates the corresponding cache keys. Worth it when: multiple services cache the same data and can't coordinate via application events; or when writes happen through tools/scripts that bypass your service layer. The WAL never lies — it's the authoritative source of all DB changes. Downside: adds Kafka dependency, Debezium connector management, and ~100–500ms invalidation lag.
Trap answer to avoid: "Just use @CacheEvict and you're done." — This misses the AFTER_COMMIT timing issue, the write race condition, and cross-service invalidation. Interviewers at senior level expect you to know about these failure modes.
Q5 · Redis
Beyond simple key-value, how do you use Redis data structures in a production Spring Boot service?

Direct answer

Redis has five core data structures beyond Strings, each suited for specific patterns: Hashes for partial object updates, Sorted Sets for leaderboards and rate limiting, Lists for queues and feeds, Bitmaps for boolean tracking at scale, HyperLogLog for approximate cardinality. Choosing the right structure often reduces both memory usage and operation complexity.

Practical use cases

Hash — partial field updates. Store object fields separately. Update one field without re-serializing and re-writing the whole object:

// Update just the lastLogin field without touching the rest of the user object
redisTemplate.opsForHash().put("user:" + id, "lastLogin", Instant.now().toString());
redisTemplate.opsForHash().put("user:" + id, "loginCount", count);

Sorted Set — sliding window rate limiter. Score = timestamp. Add the current request, remove entries outside the window, count remaining:

public boolean isAllowed(String userId) {
    long now = System.currentTimeMillis();
    long windowStart = now - 60_000;  // 1-minute window
    String key = "ratelimit:" + userId;

    // Use a Lua script for atomicity: ZREMRANGEBYSCORE + ZADD + ZCARD + EXPIRE
    redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);
    redisTemplate.opsForZSet().add(key, UUID.randomUUID().toString(), now);
    Long count = redisTemplate.opsForZSet().size(key);
    redisTemplate.expire(key, 70, TimeUnit.SECONDS);
    return count != null && count <= 100;  // 100 req/min
}

Bitmap — daily active users. Each bit represents one user. One bit per user per day — tracking 100M users requires only 12MB:

// Mark user as active today
redisTemplate.opsForValue().setBit("dau:" + today, userId, true);
// Count active users today
redisTemplate.execute((RedisCallback<Long>) conn -> conn.bitCount(("dau:" + today).getBytes()));

HyperLogLog — approximate unique count. ~0.81% error, 12KB max memory regardless of cardinality:

redisTemplate.opsForHyperLogLog().add("unique-visitors", sessionId);
Long approxCount = redisTemplate.opsForHyperLogLog().size("unique-visitors");

The production gotcha — hot keys

A large Hash or Sorted Set stored under a single Redis key is a hot key problem: one key maps to one slot, which lands on one node in a Redis Cluster. All traffic for that key hits one node. If that key is very popular, you've created a bottleneck. Mitigation: shard by suffix: leaderboard:{gameId}:{shardId} where shardId = userId % 100. Merge shards at read time.

Interview drill-down — expect these follow-ups:
L1 → "The sorted set rate limiter — those 4 operations aren't atomic. What's the race?"
Between ZREMRANGEBYSCORE and ZCARD, another thread can add entries. Two threads can both see count=99 and both add, ending up with count=101 (over limit). Fix: use a Lua script — Redis executes Lua atomically. The script does ZREMRANGEBYSCORE + ZADD + ZCARD + EXPIRE in a single atomic block. Or use Redisson's RRateLimiter which handles this internally.
L2 → "You mentioned hot keys. What are Redis Cluster's limitations with hot keys specifically?"
Redis Cluster uses consistent hashing to assign keys to slots (16384 total), and each slot is owned by one master node. A hot key always goes to one node — you can't horizontally scale reads of that key without using replicas. Redis 7.0+ allows read-from-replica for hot keys (WAIT command). Alternatively: shard the key yourself (e.g. leaderboard:0, leaderboard:1..leaderboard:99) and merge at application layer.
L3 → "Is a Bloom filter in Redis a good fit for cache penetration prevention?"
Good fit when: the set of valid IDs is large but bounded (e.g. all user IDs), the false-positive rate is acceptable (1% is typical), and you can rebuild the filter on startup from the DB. Redis has a native Bloom filter via the RedisBloom module (BF.ADD, BF.EXISTS). Before a cache miss hits the DB, check the Bloom filter — if it says definitely-not-exists, return 404 immediately without touching DB. The 1% false positive rate means 1% of truly valid IDs wrongly get a miss — acceptable.
Trap answer to avoid: "I use Redis Lists as a queue." — Redis Lists work for queues but have no consumer groups, no acknowledgment, no replay. For production queuing you want Redis Streams (XADD, XREADGROUP, XACK) which support consumer groups and message acknowledgment. Or just use Kafka.
Q6 · Consistency
How do you keep a distributed cache consistent with your database under concurrent writes?

Direct answer

The fundamental tension: writing to DB and cache are two separate operations that aren't atomic. Any gap between them creates a window where the cache and DB are inconsistent. The best approach for most services is: evict on write, never update, combined with @TransactionalEventListener(AFTER_COMMIT) to ensure the eviction only happens after the DB transaction commits successfully.

The write race — why simple eviction isn't enough

Even "evict on write" has a race condition that most engineers miss:

// Timeline showing the race:
// T1: Thread A: cache miss → starts DB read
// T2: Thread B: writes new value to DB
// T3: Thread B: evicts cache key
// T4: Thread A: finishes DB read (old value!) → writes to cache
// Result: cache now has stale data, will not expire until TTL

Delayed double-delete is the practical fix:

@Transactional
public void updateOrder(Long id, String status) {
    orderRepository.updateStatus(id, status);
    // eviction scheduled AFTER commit via event
    eventPublisher.publishEvent(new OrderUpdatedEvent(id));
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderUpdated(OrderUpdatedEvent e) {
    String key = "order:" + e.getId();
    redis.delete(key);                         // first eviction
    scheduler.schedule(
        () -> redis.delete(key),               // second eviction ~500ms later
        500, TimeUnit.MILLISECONDS
    );
}

The production gotcha

Firing eviction inside a @Transactional method (not via @TransactionalEventListener) means the cache eviction can succeed but the transaction rolls back. Now the cache key is gone, the DB still has the old value, and the next read repopulates the cache with that old value — correctly, but you've created unnecessary invalidation churn and a brief inconsistency window.

The deeper problem: cross-service cache invalidation. If Service A caches data owned by Service B, and Service B writes through its own DB, Service A's cache never learns about it. Design principle: keep caches within service boundaries. If Service A needs real-time data from Service B, either call Service B directly (no caching) or have Service B publish events that Service A subscribes to for invalidation.

Interview drill-down — expect these follow-ups:
L1 → "You write to DB first, then evict. What if the eviction fails — say Redis is down?"
DB has the new value, cache has the old value. Subsequent reads serve stale until TTL expires. For most use cases this is acceptable — the TTL is your safety net. For critical consistency: add Redis eviction to an outbox table, retry with a background job (transactional outbox pattern applied to cache eviction). But this is usually over-engineering; short TTLs are simpler and sufficient.
L2 → "What about 'read your own writes' — a user writes a value and immediately reads back stale cached data?"
Common in UIs: user updates their profile, page refreshes, still sees old data. Two fixes: (1) Cache bypass for the writing user for a short window — store a per-user "cache-dirty" flag in the session, skip cache reads if flag is set, clear flag after TTL. (2) @CachePut instead of @CacheEvict — update the cache synchronously with the new value on write, so the next read gets the fresh value immediately.
L3 → "Cross-service cache consistency — Service A caches data owned by Service B. How?"
The correct design: don't. Caches should stay within service boundaries. If Service A needs data from Service B, it should call Service B's API. If the round-trip cost is too high, publish domain events from Service B → consume in Service A → evict Service A's local cache on change. This preserves ownership boundaries and eventual consistency. Alternatively, accept the staleness and set an appropriate TTL based on how often Service B's data changes.
Trap answer to avoid: "Write to cache and DB together in the same transaction." — DB transactions and Redis don't share transaction boundaries. If the DB commits but the Redis write fails, you have inconsistency. If you write Redis first and DB rolls back, Redis has data the DB doesn't. These aren't atomically consistent without a distributed transaction protocol, which is almost never worth it for a cache.
Q7 · Redis Memory
How do you size a Redis cache and choose an eviction policy?

Direct answer

Redis eviction policy controls what happens when Redis hits its maxmemory limit. The right policy depends on your data characteristics. For a general-purpose cache where all keys have TTLs, allkeys-lru is the safe default. If you have a mix of permanent keys and cached keys, use volatile-lru to protect the permanent ones.

LRU vs LFU vs W-TinyLFU — the algorithm matters

Most engineers say "LRU" and stop. Interviewers at senior level expect you to know why Caffeine doesn't use LRU:

AlgorithmEvictsWeaknessWhere used
LRULeast recently accessedRecency bias: a single scan (sequential read) flushes the entire cache with cold dataRedis default
LFULeast frequently accessedFrequency history never decays: old popular keys block new onesRedis allkeys-lfu
W-TinyLFULow frequency + recency weightedMore complex; slightly higher CPU costCaffeine (Java), Guava

W-TinyLFU (Window TinyLFU) is what Caffeine uses. It splits the cache into a small "window" area (1%) and a "protected" area (80%) + "probationary" area (20%). New entries enter the window; if they prove popular, they graduate to the protected area. Frequency counts use a Count-Min Sketch (a probabilistic data structure) and decay periodically so historical frequency doesn't trap stale hot keys forever.

// Caffeine uses W-TinyLFU — you don't configure the algorithm, just the cache:
Cache<String, Product> cache = Caffeine.newBuilder()
    .maximumSize(10_000)          // triggers W-TinyLFU eviction
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .recordStats()                // enable hit rate monitoring
    .build();

// Check hit rate in production:
CacheStats stats = cache.stats();
log.info("hit rate: {}%", stats.hitRate() * 100);

Why does W-TinyLFU beat LRU in practice? Benchmark: for a Zipf-distributed access pattern (a few keys very hot, most keys cold — typical for product catalogs, social feeds), W-TinyLFU achieves 30–50% higher hit rates than LRU at the same cache size. LRU's weakness: if you have a batch job that touches 10,000 "cold" records, it flushes your 10,000 "hot" records from the cache — a full cache pollution incident. W-TinyLFU's window area absorbs the scan while protecting the hot keys in the protected area.

The policies and when to use each

PolicyWhat it evictsUse when
noevictionNothing — returns error on write when fullQueues, data you can't afford to lose
allkeys-lruLeast recently used from all keysGeneral cache — most common choice
volatile-lruLRU only from keys with TTL setMix of permanent + cached data
allkeys-lfuLeast frequently used from all keysSkewed access — hot keys must stay, cold ones go
volatile-ttlKey with shortest remaining TTL firstYou want deterministic expiry order

LRU vs LFU: LRU keeps recently-accessed entries, LFU keeps frequently-accessed entries. For a product catalog where some products are always popular (hot) and others rarely accessed (cold), LFU is better — LRU would evict cold products only to have them re-requested and re-cached repeatedly.

Sizing formula: maxmemory = avgObjectSize × expectedKeyCount × 1.5 (1.5x for Redis overhead and fragmentation). Set maxmemory to 80% of available RAM — leave headroom for Redis's own working memory.

# application.yml
spring:
  data:
    redis:
      host: redis-host
      port: 6379
  cache:
    redis:
      time-to-live: 30m
      key-prefix: "myapp:"
      use-key-prefix: true

The production gotcha

Redis LRU is approximate, not exact. Redis doesn't maintain a full sorted list of all keys by access time — that would be expensive. Instead it samples N random keys (default maxmemory-samples=5) and evicts the least recently used among them. With 5 samples you get roughly 74% accuracy of true LRU. Setting maxmemory-samples=10 gets you to ~95% accuracy with minimal extra CPU. Worth the trade-off on large caches.

Memory fragmentation: INFO memory → check mem_fragmentation_ratio. Above 1.5 means Redis is using 50% more OS memory than it's actually storing. Causes: many small writes followed by deletes, or Redis allocating large blocks for small keys. Fix: MEMORY PURGE (Redis 4+) defragments in place. Or restart Redis after a BGSAVE — the new process starts with clean allocation.

Interview drill-down — expect these follow-ups:
L1 → "What happens when Redis hits maxmemory with noeviction policy and your app writes a new key?"
Redis returns an OOM error: "ERR OOM command not allowed when used memory > 'maxmemory'". For caches this is usually wrong — you want eviction, not errors. noeviction is for use cases where losing data is worse than rejecting writes: message queues, pub/sub, data structures you can't rebuild. If you're using Redis as a cache, always use allkeys-lru or allkeys-lfu.
L2 → "Redis LRU is approximate — what's the practical impact? And when would you switch to allkeys-lfu?"
With default maxmemory-samples=5, Redis samples 5 random keys and evicts the least recently used among them. There's a ~26% chance a more-recently-used key is evicted instead of the true LRU key. In practice for a cache this is fine — you don't need perfect LRU. Increase maxmemory-samples=10 for ~95% accuracy. For allkeys-lfu: switch when your access pattern is heavily skewed (Zipf-like) and you have batch jobs or scans that pollute the LRU cache with cold data — LFU's frequency counter protects hot keys against scan pollution. Caffeine's W-TinyLFU does this automatically; Redis allkeys-lfu is the closest equivalent.
L3 → "Memory fragmentation ratio above 1.5 — walk me through diagnosing and fixing it."
INFO memory → look at mem_fragmentation_ratio = used_memory_rss / used_memory. Above 1.5 means OS allocated 50% more than Redis is actually using. Common causes: many small keys with varying sizes cause the allocator to fragment; many key deletions leave holes. Fix in order: (1) CONFIG SET activedefrag yes — online defragmentation in Redis 4+. (2) MEMORY PURGE — forces defrag on demand. (3) If fragmentation is extreme (>2.0), BGSAVE + restart — the new process starts with a clean allocator. Monitor fragmentation weekly — chronic fragmentation means your key distribution has a structural problem.
Trap answer to avoid: "I'd use volatile-lru by default." — This is a common wrong answer. volatile-lru only evicts keys with TTL set. If any key doesn't have a TTL, it's protected from eviction — cache fills up and writes fail. allkeys-lru is the correct default for a cache where all keys should be evictable. Second trap: "I'd use LRU everywhere." — LRU is great for most workloads, but if you're using Caffeine, it already uses W-TinyLFU which is strictly better than LRU for skewed access patterns. Know the difference: Redis = LRU/LFU (configurable), Caffeine = W-TinyLFU (built-in).
Q8 · Architecture
When do you use Caffeine (local) vs Redis (distributed)? How do you implement two-level caching?

Direct answer

Caffeine is nanosecond-latency, zero network overhead, but is isolated to one JVM — no consistency across pods. Redis is microsecond-latency, shared across all instances, but adds network overhead and serialization cost. The choice hinges on whether your service is horizontally scaled and whether the data needs to be consistent across instances.

When to use each

Caffeine (Local)Redis (Distributed)
Latency~100ns~200µs (same datacenter)
Consistency across podsNo — each pod independentYes — single shared store
Survives restartNoYes (with AOF/RDB)
Best forImmutable reference data, JVM-local stateSession data, shared counters, rate limiting

Two-level caching (L1 + L2): L1 = Caffeine (nanoseconds), L2 = Redis (microseconds). L1 miss checks L2. L2 miss queries DB. Spring doesn't have native two-level support, but you can implement it with a CompositeCacheManager:

@Bean
public CacheManager cacheManager(
        RedisCacheManager redis,
        CaffeineCacheManager caffeine) {
    // Checks Caffeine first, falls through to Redis on miss
    return new CompositeCacheManager(caffeine, redis);
}

@Bean
public CaffeineCacheManager caffeineCacheManager() {
    CaffeineCacheManager mgr = new CaffeineCacheManager();
    mgr.setCaffeine(Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(30, TimeUnit.SECONDS));  // short L1 TTL
    return mgr;
}

The production gotcha — Caffeine in Kubernetes

This is the trap that catches teams every time they scale out. A single-instance service running with Caffeine works perfectly. The team deploys to Kubernetes with 20 pods. Suddenly, a write on Pod 1 doesn't invalidate the Caffeine caches on Pods 2–20. Users who hit different pods see different data.

Fixes:

  • Short L1 TTL (10–30 seconds): accept brief staleness. Simplest.
  • Redis pub/sub invalidation: when a key is evicted from Redis, publish an invalidation message. All pods subscribe and evict their local Caffeine entry. More complex but strongly consistent.
  • Drop Caffeine, use Redis only: adds ~200µs per cache hit but eliminates the problem. Often the right trade-off.
Interview drill-down — expect these follow-ups:
L1 → "CompositeCacheManager — what happens when the same key exists in both Caffeine and Redis with different values?"
CompositeCacheManager checks managers in order — Caffeine first. If found in Caffeine, it returns that value without checking Redis. So if Caffeine has a stale value (within its own TTL) and Redis has a fresh value (recently updated), the caller sees the stale Caffeine value. This is the core trade-off of L1 caches: keep L1 TTL short (10–30s) to bound the staleness window. For data that must be immediately consistent across pods, don't use L1.
L2 → "Redis pub/sub invalidation for Caffeine — what are the failure modes?"
If a pod is down when the invalidation message is published, it misses the message — pub/sub is fire-and-forget, no persistence. When the pod restarts, its Caffeine cache has no entries (it was just restarted) so this is fine. The problematic case: a pod has a network partition, stays up, misses invalidation messages, serves stale data for as long as the partition lasts. Mitigation: pair pub/sub invalidation with a short Caffeine TTL as a backstop.
L3 → "When would you remove the Redis layer entirely and use only Caffeine?"
Single-instance service (no horizontal scaling), or data that's purely local to each instance (e.g., per-JVM compiled templates, per-pod connection state). Also during load testing: Redis round-trips are often the bottleneck; removing Redis and measuring throughput tells you your theoretical ceiling. In practice, any stateless service that scales horizontally almost always needs Redis for session data, rate limiting, and coordination — Caffeine alone isn't sufficient.
Trap answer to avoid: "Use Caffeine in production to avoid network latency." — Without an invalidation strategy, pods serving stale data out of Caffeine is a correctness bug, not a performance win. Always pair Caffeine with either very short TTLs or Redis pub/sub invalidation in a multi-pod deployment.