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.
| Pattern | Read | Write | Best for | Trade-off |
|---|---|---|---|---|
| Cache-aside | App checks cache, misses load DB | App writes DB, evicts or updates cache | Read-heavy, tolerable staleness | Cold start misses; app owns all cache logic |
| Write-through | Always in cache | Sync to cache + DB | Consistency-critical, small datasets | Write latency doubles; writes for keys never read waste cache |
| Write-behind | Always in cache | Cache only, async flush to DB | Write-heavy, durability trade-off acceptable | Data 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 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).
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.
// 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();
}
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.
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.
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 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.
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).
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.
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.
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.
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");
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.
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.
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
);
}
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.
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.
Most engineers say "LRU" and stop. Interviewers at senior level expect you to know why Caffeine doesn't use LRU:
| Algorithm | Evicts | Weakness | Where used |
|---|---|---|---|
| LRU | Least recently accessed | Recency bias: a single scan (sequential read) flushes the entire cache with cold data | Redis default |
| LFU | Least frequently accessed | Frequency history never decays: old popular keys block new ones | Redis allkeys-lfu |
| W-TinyLFU | Low frequency + recency weighted | More complex; slightly higher CPU cost | Caffeine (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.
| Policy | What it evicts | Use when |
|---|---|---|
noeviction | Nothing — returns error on write when full | Queues, data you can't afford to lose |
allkeys-lru | Least recently used from all keys | General cache — most common choice |
volatile-lru | LRU only from keys with TTL set | Mix of permanent + cached data |
allkeys-lfu | Least frequently used from all keys | Skewed access — hot keys must stay, cold ones go |
volatile-ttl | Key with shortest remaining TTL first | You 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
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.
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.
| Caffeine (Local) | Redis (Distributed) | |
|---|---|---|
| Latency | ~100ns | ~200µs (same datacenter) |
| Consistency across pods | No — each pod independent | Yes — single shared store |
| Survives restart | No | Yes (with AOF/RDB) |
| Best for | Immutable reference data, JVM-local state | Session 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;
}
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: