← Blogs

Redis Caching Patterns and Failure Modes

References: Redis query caching, caching patterns, and Redis key eviction.

Caching Patterns

Cache-Aside (Lazy Loading)

On a read, the application checks Redis first. A hit returns immediately. On a miss, it queries the database, stores the result in Redis, and returns it. The cache is filled only for requested data, which suits read-heavy workloads and keeps the cache smaller.

Read-Through

The application asks a cache provider for data. The provider performs a database read on a miss and manages filling the cache, so the application does not contain the miss-handling logic.

Write-Through

The application writes through the cache provider, which updates the backing store before the write is considered complete. This simplifies the application’s cache update path, although each write may take longer.

Write-Behind

The cache accepts a write quickly and persists it to the database asynchronously. This lowers write latency, but a crash before persistence can lose the recent write. The cache and database can also disagree temporarily.

Updating a Cache-Aside Entry

For a merchant record, the common pattern is to update the database and then invalidate its cache entry:

1
2
updateById(shop);
stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());

Invalidation causes the next read to load fresh data. Updating the database first makes the inconsistency window small in typical races, but it does not make the two operations atomic. A Spring database @Transactional annotation does not automatically roll back a committed database update when a separate Redis deletion fails. If this failure matters, use retry, an outbox, or another reconciliation mechanism.

Cache-aside race

Cache Penetration

Cache penetration occurs when many requests ask for keys that exist in neither Redis nor the database. Every miss reaches the database.

Cache an Empty Result

When a database lookup finds no merchant, cache an empty marker with a short TTL. Subsequent requests for the same absent ID can return without querying the database. The read path must distinguish an empty marker from a missing Redis key.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
String cachedValue = stringRedisTemplate.opsForValue().get(cacheKey);
if (cachedValue != null) {
    return cachedValue.isEmpty() ? null : JSONUtil.toBean(cachedValue, Shop.class);
}

Shop shop = getById(id);
if (shop == null) {
    stringRedisTemplate.opsForValue().set(cacheKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
    return null;
}

stringRedisTemplate.opsForValue().set(
    cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return shop;

Bloom Filter

A Bloom filter can reject IDs that are definitely absent before a database lookup. It hashes a key to several bit positions. If any bit is zero, the key was not added; if all are one, the key might exist. False positives are possible, so a positive result still requires a normal lookup. The filter must stay in sync with the authoritative set of keys.

Cache Breakdown: A Hot Key Expires

When a frequently requested key expires, many concurrent misses can hit the database at once.

Hot-key expiration

Mutex During Rebuild

Only the request that acquires a lock reads the database and rebuilds the cache. Other requests wait briefly and retry the cache. This gives fresh data after the rebuild, but waiting requests have higher latency.

Redis SET key value NX PX ... can establish a lock with an expiration. The owner should use a unique token and delete the lock only if the token still matches, typically through a Lua script. Otherwise, a slow request could release another request’s lock after its own lock expired.

Caching an empty result also handles penetration in this flow. The two concerns are separate: the lock protects a hot cache miss, while the empty marker protects repeated misses for nonexistent data.

Logical Expiration and Background Rebuild

Another approach keeps hot data in Redis after its logical expiration time. Store the value together with an expireTime. A request that finds expired data tries to acquire the rebuild lock. The winner starts a background refresh; every request can return the old value immediately while that refresh runs. This favors low latency at the cost of temporarily stale data.

1
2
3
4
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

Logical expiration

In my load test, the merchant’s name changed from A to B in the database while the cached value was still A. With 100 requests per second and an intentionally slow rebuild, early requests returned A; later requests returned B after the refresh. Only one request rebuilt the cache. The longer a refresh takes, the longer the stale-read window remains.

Cache Avalanche

If many keys expire together, a large group of requests may fall through to the database. Add jitter to TTLs so expirations are spread out. If Redis itself becomes unavailable, replication or a Redis Cluster improves availability; rate limiting and graceful degradation can protect the database while the cache recovers.