Redis Cache When Necessary (and When NOT)
The 3-Line Engineering Rule
Cache only data with high read-to-write ratios and tolerable eventual consistency; never use Redis as a band-aid for missing database indexes.
Over-caching dynamic data introduces stale reads, race conditions during cache invalidation, cache stampedes (thundering herd), and memory leaks.
Use the Cache-Aside pattern with mandatory TTL + random jitter. Never cache volatile data updated on every user interaction without strict invalidation guarantees.
Architectural Blueprint & Sequence
Hover over nodes to inspect state transitionsApplication Service
GET /products/492
Redis Cache
Cache Key: prod:492
Cache HIT (0.8ms)
Return JSON payload directly
Cache MISS
Read from DB replica
PostgreSQL DB
SELECT * FROM products WHERE id=492
Populate Cache
SET prod:492 EX (3600s + Jitter)
Key Engineering Takeaways
- Fix slow SQL queries with indexes first before slapping Redis on top.
- Always set a TTL; immortal cache keys will eventually trigger out-of-memory (OOM) evictions.
- Add random TTL jitter (`TTL = 3600 + rand(-300, 300)`) to avoid cache avalanche.
- Protect against cache stampedes with mutex locking or early probabilistic expiration (XFetch).
The Problem & Production Reality
Redis is an extraordinary in-memory data store, but introducing a cache doubles system complexity: now you have two sources of truth that can get out of sync. Many teams add Redis to patch slow database queries instead of writing proper indexes, leading to ghost data and synchronization bugs.
Anti-Pattern: Indefinite Caching of Frequently Mutated Entities
Caching user profile balances or order statuses without TTLs or invalidation creates persistent consistency bugs where users see outdated bank balances.
// ❌ ANTI-PATTERN: No TTL, manual sync bugs
public UserAccount getAccount(String id) {
UserAccount cached = redis.get("account:" + id);
if (cached != null) return cached;
UserAccount dbAcc = db.findAccount(id);
redis.set("account:" + id, dbAcc); // ❌ NO TTL! If an async worker updates DB, cache is permanently stale
return dbAcc;
}Production Pattern: Cache-Aside with TTL Jitter & Invalidation
Always set an expiration time augmented with a random jitter to prevent all cached records from expiring simultaneously.
// ✅ PRODUCTION PATTERN: Cache-Aside with Jittered TTL
@Service
public class ProductCatalogService {
private final StringRedisTemplate redis;
private final ProductRepository productRepo;
private static final int BASE_TTL_SECONDS = 3600;
public ProductDto getProduct(String productId) {
String cacheKey = "product:" + productId;
String cachedJson = redis.opsForValue().get(cacheKey);
if (cachedJson != null) {
return JsonUtils.fromJson(cachedJson, ProductDto.class);
}
// Cache miss: query primary DB
Product product = productRepo.findById(productId)
.orElseThrow(() -> new NotFoundException("Product not found: " + productId));
ProductDto dto = ProductMapper.toDto(product);
// Add 0-300 seconds of random jitter to prevent cache avalanche
int jitter = ThreadLocalRandom.current().nextInt(0, 300);
redis.opsForValue().set(cacheKey, JsonUtils.toJson(dto), Duration.ofSeconds(BASE_TTL_SECONDS + jitter));
return dto;
}
@Transactional
public void updateProduct(String productId, UpdateProductRequest req) {
productRepo.update(productId, req);
// Evict cache immediately on write; do not attempt complex dual writes
redis.delete("product:" + productId);
}
}Trade-off Analysis & Staff Recommendations
| Aspect | Pros / Strengths | Cons / Hazards | Staff Recommendation |
|---|---|---|---|
| Evict on Write vs Update on Write | Evicting on write is simple and immune to concurrent write reordering. | The very next read will incur a cache miss. | Always evict (delete) the cache key on update; let the subsequent read lazily repopulate. |
| Memory Eviction Policy | `allkeys-lru` guarantees Redis won't crash when memory fills. | Critical non-cached keys (e.g. rate limiter tokens) could be evicted. | Use `volatile-lru` if Redis stores both cache and rate-limit counters; ensure all cache keys have TTL. |