Backend EngineeringApril 10, 20257 min read

Redis Caching with Spring Boot: Real-World Patterns

Beyond @Cacheable — TTL strategies, cache-aside vs write-through, cache warming, and how to handle cache invalidation without shooting yourself in the foot.

RedisSpring BootJavaCachingPerformance

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.

Basic Setup

CacheConfig.java
java
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}

@Cacheable, @CachePut, @CacheEvict

UserService.java
java
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.

Cache Stampede Prevention

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.

Distributed Lock with Redis

RedisLockService.java
java
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}

Cache-Aside vs Write-Through

  • Cache-Aside (Lazy Loading): app reads cache → miss → reads DB → writes cache. Simple but cache is stale between miss and write.
  • Write-Through: app writes to cache AND DB on every write. Cache is always fresh but doubles write latency.
  • Write-Behind (Write-Back): app writes to cache only, cache asynchronously flushes to DB. Fast writes but risk of data loss.
  • For most read-heavy apps, Cache-Aside with short TTL and explicit @CacheEvict on writes is the right balance.

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