Implement cache-aside, write-through, write-behind, and read-through patterns with eviction policies.
Published April 14, 2025
Caching stores frequently accessed data in fast storage (memory) to reduce latency and database load. Choosing the right caching strategy determines correctness and consistency.
Application manages the cache explicitly.
public User getUser(String userId) {
// 1. Check cache
User cached = redis.get("user:" + userId);
if (cached != null) return cached; // cache hit
// 2. Cache miss: load from DB
User user = userRepository.findById(userId);
// 3. Populate cache
redis.setex("user:" + userId, 3600, user); // TTL 1 hour
return user;
}
public void updateUser(String userId, User user) {
userRepository.save(user);
redis.del("user:" + userId); // invalidate cache
// Or: redis.set("user:" + userId, user); // update cache
}
Pros: Only caches data that's actually read; works great for read-heavy workloads. Cons: First request always has a cache miss (cold start). Stale data possible if invalidation is missed.
public void updateUser(User user) {
userRepository.save(user); // write to DB
redis.set("user:" + user.getId(), user); // write to cache
}
Pros: Cache is always consistent with DB. Cons: Every write pays the DB penalty. Unused cache entries waste space.
Client → Cache (immediate) → Background worker → DB (async)
Pros: Fastest writes; batch DB updates. Cons: Risk of data loss if cache node dies before the async write completes.
Client → Cache → DB (on miss)
↓ auto-populates on miss
Pros: Transparent to the application. Cons: Cache layer must know how to talk to the DB.
| Policy | Description | Use Case |
|---|---|---|
| LRU | Remove Least Recently Used | General purpose |
| LFU | Remove Least Frequently Used | Frequency-based access |
| FIFO | Remove oldest entry | Simple queues |
| TTL | Expire after fixed time | Data with known staleness |
| No eviction | Return error when full | Critical data |
Redis default: no eviction (return error). Common config: maxmemory-policy allkeys-lru.
When many requests hit the same expired cache key simultaneously, all miss and bombard the database.
// Solution 1: Mutex lock (only one request rebuilds the cache)
public User getUserWithLock(String userId) {
User cached = redis.get("user:" + userId);
if (cached != null) return cached;
String lockKey = "lock:user:" + userId;
if (redis.setnx(lockKey, "1") == 1) { // acquired lock
redis.expire(lockKey, 30);
try {
User user = userRepository.findById(userId);
redis.setex("user:" + userId, 3600, user);
return user;
} finally {
redis.del(lockKey);
}
}
// Solution 2: Return stale value while rebuilding
return redis.get("user:stale:" + userId);
}
@Service
public class UserService {
@Cacheable(value = "users", key = "#userId")
public User getUser(String userId) {
return userRepository.findById(userId).orElseThrow();
}
@CacheEvict(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
@CachePut(value = "users", key = "#user.id") // update cache without evicting
public User updateUserAndCache(User user) {
return userRepository.save(user);
}
}