Beyond @Cacheable — TTL strategies, cache-aside vs write-through, cache warming, and how to handle cache invalidation without shooting yourself in the foot.
Redis caching in Spring Boot is trivially easy to set up and surprisingly hard to get right in production. Here's what I've learned building caching layers that actually improve reliability instead of hiding bugs.
1@Configuration
2@EnableCaching
3public class CacheConfig {
4
5 @Bean
6 public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
7 RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
8 .entryTtl(Duration.ofMinutes(30))
9 .serializeValuesWith(
10 RedisSerializationContext.SerializationPair.fromSerializer(
11 new GenericJackson2JsonRedisSerializer()
12 )
13 );
14
15 return RedisCacheManager.builder(factory)
16 .cacheDefaults(config)
17 .withCacheConfiguration("users",
18 config.entryTtl(Duration.ofHours(1)))
19 .withCacheConfiguration("sessions",
20 config.entryTtl(Duration.ofMinutes(15)))
21 .build();
22 }
23}1@Service
2public class UserService {
3
4 @Cacheable(value = "users", key = "#userId")
5 public User findById(Long userId) {
6 return userRepository.findById(userId).orElseThrow();
7 }
8
9 @CachePut(value = "users", key = "#user.id")
10 public User update(User user) {
11 return userRepository.save(user);
12 }
13
14 @CacheEvict(value = "users", key = "#userId")
15 public void delete(Long userId) {
16 userRepository.deleteById(userId);
17 }
18
19 // Evict all entries in a cache
20 @CacheEvict(value = "users", allEntries = true)
21 public void clearAll() {}
22}Never cache mutable data without a solid eviction strategy. The classic mistake: cache a user's permissions, user gets role changed, cache still says old role for the next 30 minutes. Use @CacheEvict on every write path.
When a popular cache key expires, hundreds of requests simultaneously hit the DB — this is a cache stampede. Fix it with a probabilistic early expiry: randomly refresh the cache before it actually expires, so the stampede never happens.
1@Service
2@RequiredArgsConstructor
3public class RedisLockService {
4
5 private final StringRedisTemplate redis;
6
7 public boolean acquireLock(String key, String value, Duration ttl) {
8 // SET key value NX EX seconds — atomic acquire
9 Boolean acquired = redis.opsForValue()
10 .setIfAbsent(key, value, ttl);
11 return Boolean.TRUE.equals(acquired);
12 }
13
14 public void releaseLock(String key, String expectedValue) {
15 // Lua script ensures we only delete OUR lock, not someone else's
16 String script = """
17 if redis.call('get', KEYS[1]) == ARGV[1] then
18 return redis.call('del', KEYS[1])
19 end
20 return 0
21 """;
22 redis.execute(new DefaultRedisScript<>(script, Long.class),
23 List.of(key), expectedValue);
24 }
25}Redis SCAN instead of KEYS for cache invalidation patterns. KEYS blocks the Redis server while scanning. SCAN is incremental and non-blocking — critical in production where Redis is shared across services.
More in Backend Engineering