What’s a cache stampede?
A Cache stampede occurs when multiple requests flood the database after cache invalidation/expiration. A mutex lock ensures only one call fetches data from the database at a time and other requests wait and get the cached result, preventing database overload.
Spring Boot’s @Cacheable
works out of the box, but you have to override it to support mutex locks to prevent cache stampede.
To override it, we need to extend CacheInterceptor
and implement custom locking logic.
Customize your cache manager to use mutex
package com.myapp.config;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.interceptor.CacheInterceptor;
import org.springframework.cache.interceptor.CacheOperationInvocationContext;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class MutexCacheInterceptor extends CacheInterceptor {
private final RedisTemplate<String, Object> redisTemplate;
public MutexCacheInterceptor(CacheManager cacheManager, RedisTemplate<String, Object> redisTemplate) {
super();
setCacheManager(cacheManager);
this.redisTemplate = redisTemplate;
}
@Override
protected Object invokeOperation(CacheOperationInvocationContext<?> context) {
String cacheName = context.getOperation().getCacheNames().iterator().next();
String key = context.getKeyGenerator().generate(context.getTarget(), context.getMethod(), context.getArgs()).toString();
Cache cache = getCacheManager().getCache(cacheName);
if (cache == null) {
return super.invokeOperation(context);
}
Cache.ValueWrapper valueWrapper = cache.get(key);
if (valueWrapper != null) {
return valueWrapper.get();
}
String lockKey = "lock:" + key;
Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(lockAcquired)) {
try {
Object result = super.invokeOperation(context);
cache.put(key, result);
return result;
} finally {
redisTemplate.delete(lockKey);
}
} else {
while (cache.get(key) == null) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return cache.get(key).get();
}
}
}
Spring config
package com.myapp.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.CacheInterceptor;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheInterceptor cacheInterceptor(CacheManager cacheManager, RedisTemplate<String, Object> redisTemplate) {
return new MutexCacheInterceptor(cacheManager, redisTemplate);
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // Set cache expiration time
.disableCachingNullValues();
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(cacheConfig)
.build();
}
}
Cache invalidation
package com.myapp.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class CacheService {
@CacheEvict(value = "users", key = "#userId")
public void invalidateCache(String userId) {
log.info("Cache invalidated for userId: " + userId);
}
}