Caching with Spring Cache & Redis

Make Your Application 10x Faster - Store Frequently Used Data in Memory

Why Caching?

Imagine a librarian who memorizes the locations of the most requested books. Instead of searching the entire library for "Harry Potter" every time, she says "Shelf 3, Row 2" from memory. That's caching!

Database queries are slow (10-100ms). Memory access is fast (0.1ms). Caching stores frequently used data in memory, so you don't hit the database repeatedly.

WITHOUT CACHING:
Request → Database → 50ms → Response
Request → Database → 50ms → Response  (same data!)
Request → Database → 50ms → Response  (same data again!)

WITH CACHING:
Request → Database → 50ms → Cache → Response
Request → Cache → 0.1ms → Response  (from memory!)
Request → Cache → 0.1ms → Response  (instant!)

Result: 500x faster for cached requests!

Reduced Latency

Serve data in milliseconds instead of hundreds of milliseconds. Users notice the difference!

Lower Database Load

Fewer queries mean your database can handle more users without expensive upgrades.

Cost Savings

Less CPU usage, fewer database connections, lower cloud bills.

Better Scalability

Handle traffic spikes easily. Cache absorbs the load.

Spring Cache: Simple Annotations

Spring Cache adds caching with just annotations - no boilerplate code needed.

Setup

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
// Enable caching in your application
@SpringBootApplication
@EnableCaching
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

Basic Caching with @Cacheable

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    // First call: hits database, stores result in cache
    // Subsequent calls: returns from cache, skips database!
    @Cacheable("users")
    public User findById(Long id) {
        System.out.println("Fetching from database...");
        return userRepository.findById(id).orElse(null);
    }

    // Cache with custom key
    @Cacheable(value = "users", key = "#email")
    public User findByEmail(String email) {
        return userRepository.findByEmail(email);
    }

    // Conditional caching
    @Cacheable(value = "users", condition = "#id > 0")
    public User findByIdIfPositive(Long id) {
        return userRepository.findById(id).orElse(null);
    }
}

Updating Cache with @CachePut

// Always executes the method AND updates the cache
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
    return userRepository.save(user);
}

Removing from Cache with @CacheEvict

// Remove specific entry from cache
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
    userRepository.deleteById(id);
}

// Clear entire cache
@CacheEvict(value = "users", allEntries = true)
public void clearUserCache() {
    // Cache cleared!
}

// Evict before method execution
@CacheEvict(value = "users", key = "#id", beforeInvocation = true)
public void deleteUserSafely(Long id) {
    userRepository.deleteById(id);
}

Multiple Cache Operations

@Caching(
    put = { @CachePut(value = "users", key = "#user.id") },
    evict = { @CacheEvict(value = "userList", allEntries = true) }
)
public User saveUser(User user) {
    return userRepository.save(user);
}

In-Memory Cache with Caffeine

Caffeine is a high-performance in-memory cache. Great for single-server applications.

<!-- pom.xml -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
# application.properties
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m

Custom Cache Configuration

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();

        cacheManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)              // Max entries
            .expireAfterWrite(10, TimeUnit.MINUTES)  // TTL
            .recordStats());                // Enable stats

        return cacheManager;
    }

    // Different settings for different caches
    @Bean
    public CacheManager customCacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();

        cacheManager.setCaches(Arrays.asList(
            buildCache("users", 100, 30),      // 100 entries, 30 min
            buildCache("products", 500, 60),   // 500 entries, 60 min
            buildCache("config", 50, 120)      // 50 entries, 2 hours
        ));

        return cacheManager;
    }

    private CaffeineCache buildCache(String name, int size, int minutes) {
        return new CaffeineCache(name, Caffeine.newBuilder()
            .maximumSize(size)
            .expireAfterWrite(minutes, TimeUnit.MINUTES)
            .build());
    }
}

Distributed Cache with Redis

Redis is a distributed cache that works across multiple servers. Essential for microservices and scaled applications.

Setup

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
# application.properties
spring.cache.type=redis
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.password=yourpassword  # if needed
spring.cache.redis.time-to-live=3600000  # 1 hour in ms

Redis Configuration

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // Default config
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(30))
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        // Custom config per cache
        Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
        cacheConfigs.put("users", defaultConfig.entryTtl(Duration.ofHours(1)));
        cacheConfigs.put("products", defaultConfig.entryTtl(Duration.ofMinutes(15)));
        cacheConfigs.put("sessions", defaultConfig.entryTtl(Duration.ofHours(24)));

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .build();
    }
}

Using RedisTemplate Directly

@Service
public class CacheService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    // Store value
    public void set(String key, Object value, long timeoutMinutes) {
        redisTemplate.opsForValue().set(key, value, timeoutMinutes, TimeUnit.MINUTES);
    }

    // Get value
    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    // Delete value
    public void delete(String key) {
        redisTemplate.delete(key);
    }

    // Check if exists
    public boolean exists(String key) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    // Set expiration
    public void expire(String key, long timeoutMinutes) {
        redisTemplate.expire(key, timeoutMinutes, TimeUnit.MINUTES);
    }

    // Store hash (like a Map)
    public void setHash(String key, String field, Object value) {
        redisTemplate.opsForHash().put(key, field, value);
    }

    public Object getHash(String key, String field) {
        return redisTemplate.opsForHash().get(key, field);
    }
}

Caching Patterns

Cache-Aside (Lazy Loading)

Most common pattern. Application manages the cache.

public User getUser(Long id) {
    // 1. Check cache
    User user = cache.get("user:" + id);

    if (user != null) {
        return user;  // Cache hit
    }

    // 2. Cache miss - load from database
    user = userRepository.findById(id).orElse(null);

    // 3. Store in cache for next time
    if (user != null) {
        cache.set("user:" + id, user, 30);  // 30 min TTL
    }

    return user;
}

// With @Cacheable, Spring does this automatically!
@Cacheable("users")
public User getUser(Long id) {
    return userRepository.findById(id).orElse(null);
}

Write-Through

Update cache immediately when data changes.

@CachePut(value = "users", key = "#user.id")
public User saveUser(User user) {
    return userRepository.save(user);  // DB and cache updated together
}

Write-Behind (Write-Back)

Update cache immediately, database later (async).

public User saveUser(User user) {
    // Update cache immediately
    cache.set("user:" + user.getId(), user, 60);

    // Queue database update for later
    asyncDatabaseService.saveAsync(user);

    return user;
}

Cache Stampede Prevention

Prevent multiple requests from hitting the database when cache expires.

@Service
public class UserService {

    private final LoadingCache<Long, User> userCache;

    public UserService(UserRepository userRepository) {
        this.userCache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(30, TimeUnit.MINUTES)
            .build(userId -> {
                // Only ONE thread loads data, others wait
                return userRepository.findById(userId).orElse(null);
            });
    }

    public User getUser(Long id) {
        return userCache.get(id);  // Automatically prevents stampede
    }
}

Real-World Examples

API Rate Limiting

@Service
public class RateLimiter {

    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;

    public boolean isAllowed(String clientId, int maxRequests, int windowSeconds) {
        String key = "rate:" + clientId;

        Long count = redisTemplate.opsForValue().increment(key);

        if (count == 1) {
            redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS);
        }

        return count <= maxRequests;
    }
}

// Usage in controller
@GetMapping("/api/data")
public ResponseEntity<?> getData(@RequestHeader("X-Client-Id") String clientId) {
    if (!rateLimiter.isAllowed(clientId, 100, 60)) {  // 100 req/min
        return ResponseEntity.status(429).body("Too many requests");
    }
    return ResponseEntity.ok(fetchData());
}

Session Storage

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
# application.properties
spring.session.store-type=redis
spring.session.redis.namespace=myapp:sessions
server.servlet.session.timeout=30m

Caching Database Queries

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Cacheable("products")
    Optional<Product> findById(Long id);

    @Cacheable(value = "productsByCategory", key = "#category")
    List<Product> findByCategory(String category);

    @CacheEvict(value = {"products", "productsByCategory"}, allEntries = true)
    @Override
    <S extends Product> S save(S product);
}

Caching Best Practices

1. Cache What Makes Sense

Good Candidates for Caching:

  • Read-heavy data (read 100x more than written)
  • Expensive computations
  • Data that doesn't change often
  • Reference data (countries, categories)

Poor Candidates:

  • Rapidly changing data
  • User-specific data that varies widely
  • Security-sensitive data
  • Data that must always be fresh

2. Set Appropriate TTLs

// Too short: cache isn't helpful
@Cacheable(value = "users", ttl = 10)  // 10 seconds - why bother?

// Too long: stale data problems
@Cacheable(value = "users", ttl = 86400000)  // 1 day - risky!

// Just right: balance freshness and performance
@Cacheable(value = "users", ttl = 1800000)  // 30 minutes

3. Handle Cache Failures Gracefully

@Cacheable(value = "users", unless = "#result == null")
public User findUser(Long id) {
    try {
        return userRepository.findById(id).orElse(null);
    } catch (Exception e) {
        logger.error("Failed to fetch user", e);
        return null;  // Don't cache errors
    }
}

4. Use Meaningful Cache Keys

// BAD: Generic keys
@Cacheable("data")

// GOOD: Descriptive keys
@Cacheable(value = "users", key = "'user:' + #id")
@Cacheable(value = "products", key = "'category:' + #category + ':page:' + #page")

5. Monitor Cache Performance

# Expose cache metrics
management.endpoints.web.exposure.include=caches,metrics
# Access at: /actuator/caches and /actuator/metrics/cache.gets

Build High-Performance Java Applications

Learn caching, Redis, and performance optimization with hands-on projects.