In enterprise platforms such as HRIS, ERP, and asset tracking systems, database read replicas often become the primary bottleneck during shift transitions and concurrent report generation. Introducing a distributed cache like Redis is the industry standard solution, but naive implementations frequently fall prey to cache stampedes, stale data discrepancies, and memory exhaustion.
In this guide, we walk through production-grade caching patterns that ensure high availability and sub-millisecond retrieval.
1. The Cache-Aside Pattern with Probabilistic Early Expiration
The standard Cache-Aside pattern operates on a simple premise:
- Check Redis for the key.
- If present (Cache Hit), return cached value.
- If absent (Cache Miss), query database, store result in Redis with a TTL, and return.
The Problem: Cache Stampede (Dog-piling)
When a high-traffic key expires, hundreds of simultaneous requests experience a cache miss at the exact same millisecond. All of these concurrent threads then hammer the underlying database simultaneously with the exact same query, causing sudden CPU spikes or database deadlocks.
The Solution: Distributed Locks or XFetch Algorithm
In Java Spring Boot, you can combine Redisson distributed locks or implement a randomized jitter on TTLs:public DepartmentSummaryDto getDepartmentSummary(String deptId) {
String cacheKey = "dept:summary:" + deptId;
// 1. Try fetching from Redis
String cachedJson = redisTemplate.opsForValue().get(cacheKey);
if (cachedJson != null) {
return deserialize(cachedJson, DepartmentSummaryDto.class);
}
// 2. Lock to prevent cache stampede
RLock lock = redissonClient.getLock("lock:" + cacheKey);
try {
if (lock.tryLock(2, 10, TimeUnit.SECONDS)) {
// Double-check cache inside lock
cachedJson = redisTemplate.opsForValue().get(cacheKey);
if (cachedJson != null) {
return deserialize(cachedJson, DepartmentSummaryDto.class);
}
// 3. Query Database
DepartmentSummaryDto data = databaseRepository.findSummaryByDept(deptId);
// 4. Add randomized jitter (e.g., 3600s + 0-300s random)
long ttlSeconds = 3600 + ThreadLocalRandom.current().nextLong(300);
redisTemplate.opsForValue().set(cacheKey, serialize(data), ttlSeconds, TimeUnit.SECONDS);
return data;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
// Fallback if lock timed out
return databaseRepository.findSummaryByDept(deptId);
}
2. Choosing the Right Redis Eviction Policy
Redis operates strictly in memory. When your database cache grows beyond the allocated maxmemory threshold, Redis will reject new writes unless a proper eviction policy is configured in redis.conf.
| Eviction Policy | Best Use Case | Operational Risk |
|---|---|---|
| allkeys-lru | Standard web application caches | Minimal; automatically purges least recently accessed keys |
| volatile-lru | Hybrid setups storing both cache and persistent session state | High risk if some cache keys lack a TTL |
| allkeys-lfu | Read-heavy datasets where access frequency matters more than recency | Retains frequent hits over transient bursts |
| noeviction | Pure message brokers and queues | Out-of-memory errors on new writes |
maxmemory-policy allkeys-lru is the safest and most predictable choice.
3. Cache Invalidation Strategies
Invalidating cache is famously one of computer science's hardest challenges. In enterprise systems, prefer Event-Driven Invalidation using Spring Application Events or Redis Pub/Sub:- When an asset or employee profile is updated via a
PUT /api/v1/employees/{id}command, immediately publish anEmployeeUpdatedEvent. - The listener evicts the specific key:
redisTemplate.delete("emp:" + id). - Avoid deleting entire wildcards (
keys emp:*) in production because the RedisKEYScommand is blocking and can freeze the entire Redis server single-threaded event loop. UseSCANinstead if key pattern eviction is mandatory.