Preventing Cache Penetration with Mutex Locks and Logical Expiration in Redis (Java)
The article explains cache penetration, describes how to prevent it using a Redis‑based mutex lock and a logical expiration strategy, provides complete Java code examples for both approaches, compares their trade‑offs, and shows performance testing results under high concurrency.
1. What Is Cache Penetration
Cache penetration occurs when a frequently accessed key suddenly expires, causing a massive number of requests to miss the cache and hit the database, potentially overwhelming it; such keys are often called hot keys.
2. Solving Cache Penetration with a Mutex Lock
When a hot key expires, only one thread should be allowed to rebuild the cache. This can be achieved with Redis' SETNX command, which creates a lock key if it does not exist.
The following Java methods implement lock acquisition and release:
/**
* Try to acquire lock
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* Release lock
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}Using the lock, the cache‑miss handling method queryWithMutex ensures that only the thread that obtains the lock queries the database and rebuilds the cache.
/**
* Mutex lock to prevent cache penetration
* @param id
* @return
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Try to get cache
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
if (shopJson != null) {
return null; // empty cache value
}
// 4. Rebuild cache with lock
String lockKey = "lock:shop" + id;
Shop shopById = null;
try {
boolean isLock = tryLock(lockKey);
if (!isLock) {
Thread.sleep(50);
return queryWithMutex(id);
}
shopById = getById(id);
if (shopById == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopById), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
return shopById;
}3. Solving Cache Penetration with Logical Expiration
Logical expiration stores an expiration timestamp together with the cached data instead of relying on Redis TTL. When the logical time is expired, the cache is rebuilt asynchronously while still returning the stale data.
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data; // generic payload
}The method saveShopRedis writes the shop object together with a future expiration time.
/**
* Add logical expiration time
* @param id
* @param expireTime seconds
*/
public void saveShopRedis(Long id, Long expireTime) {
Shop shop = getById(id);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}The query method checks the logical expiration and, if expired, attempts to acquire a lock and rebuild the cache in a separate thread.
/**
* Logical expiration to prevent cache penetration
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) throws InterruptedException {
String key = CACHE_SHOP_KEY + id;
Thread.sleep(200);
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(shopJson)) {
return null;
}
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
return shop; // not expired
}
// expired: rebuild cache
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
return shop;
}4. Interface Testing
Using APIfox and JMeter to simulate concurrent requests (up to 1550 threads) shows that both implementations keep average response time short and avoid frequent database hits, indicating good performance and acceptable QPS under load.
5. Comparison
The mutex‑lock approach is simpler, requires only two lock‑related methods, consumes no extra memory, and maintains strong data consistency, but threads may block and risk deadlock. The logical‑expiration approach is more complex, introduces an extra data wrapper and background thread for cache rebuilding, consumes additional memory, sacrifices strict consistency for higher availability and better performance under contention.
Selected Java Interview Questions
A professional Java tech channel sharing common knowledge to help developers fill gaps. Follow us!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.