Backend

Integrating Redis Cache into Spring Applications

Introduction

Caching is one of the most effective ways to speed up applications. Instead of hitting the database for every request, you can store frequently accessed data in a fast in-memory store. Redis, combined with Spring Boot, makes caching simple, powerful, and highly scalable. In this comprehensive guide, you’ll learn why Redis is the industry standard for caching, how Spring’s caching abstraction works, and how to implement production-ready caching patterns including cache-aside, TTL management, and distributed caching strategies.

Why Use Redis for Caching?

Redis is an in-memory data store known for its speed, flexibility, and low latency. Because it keeps data in memory instead of on disk, it can return results in sub-millisecond times—orders of magnitude faster than traditional databases.

Key Advantages

  • Extremely fast – Sub-millisecond response times for most operations
  • Rich data structures – Supports strings, hashes, sets, sorted sets, lists, and streams
  • Distributed architecture – Built-in clustering and replication for high availability
  • Versatile use cases – Ideal for caching, sessions, rate limiting, leaderboards, and pub/sub
  • Spring integration – First-class support through Spring Data Redis

Project Setup

Start by adding the required dependencies to your Spring Boot project:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <!-- Connection pool for better performance -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>
</dependencies>

Configure Redis Connection

Add comprehensive Redis settings to application.yml:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: ${REDIS_PASSWORD:}
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 8
          max-idle: 8
          min-idle: 2
          max-wait: -1ms
  cache:
    type: redis
    redis:
      time-to-live: 3600000  # 1 hour default TTL
      cache-null-values: false
      use-key-prefix: true
      key-prefix: "myapp:"

logging:
  level:
    org.springframework.cache: DEBUG

Start Redis locally with Docker:

docker run -d --name redis -p 6379:6379 redis:7-alpine

Redis Cache Configuration

Create a comprehensive cache configuration class that defines different TTLs for different cache types:

@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // Default cache configuration
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofHours(1))
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer()))
            .disableCachingNullValues();

        // Custom configurations for specific caches
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
        
        // Short-lived cache for frequently changing data
        cacheConfigurations.put("products", defaultConfig.entryTtl(Duration.ofMinutes(15)));
        
        // Longer cache for stable data
        cacheConfigurations.put("categories", defaultConfig.entryTtl(Duration.ofHours(24)));
        
        // User sessions - medium TTL
        cacheConfigurations.put("users", defaultConfig.entryTtl(Duration.ofMinutes(30)));
        
        // API responses - short TTL
        cacheConfigurations.put("apiResponses", defaultConfig.entryTtl(Duration.ofMinutes(5)));

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

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        
        // Use JSON serialization for values
        Jackson2JsonRedisSerializer<Object> serializer = 
            new Jackson2JsonRedisSerializer<>(Object.class);
        
        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.activateDefaultTyping(
            mapper.getPolymorphicTypeValidator(),
            ObjectMapper.DefaultTyping.NON_FINAL);
        serializer.setObjectMapper(mapper);

        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);
        template.afterPropertiesSet();
        
        return template;
    }
}

Domain Model

Create a Product entity that we’ll cache:

@Entity
@Table(name = "products")
public class Product implements Serializable {
    
    @Serial
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String name;
    
    @Column(length = 1000)
    private String description;
    
    @Column(nullable = false)
    private BigDecimal price;
    
    @Column(name = "category_id")
    private Long categoryId;
    
    @Column(name = "stock_quantity")
    private Integer stockQuantity;
    
    @Column(name = "created_at")
    private LocalDateTime createdAt;
    
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
    
    // Constructors, getters, setters
    public Product() {}
    
    public Product(String name, String description, BigDecimal price) {
        this.name = name;
        this.description = description;
        this.price = price;
        this.createdAt = LocalDateTime.now();
    }
    
    @PreUpdate
    public void preUpdate() {
        this.updatedAt = LocalDateTime.now();
    }
    
    // Getters and setters...
}

Caching with Annotations

Spring’s caching abstraction provides powerful annotations. Here’s a complete service demonstrating all caching patterns:

@Service
@Slf4j
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // Basic caching - cache result by ID
    @Cacheable(value = "products", key = "#id")
    public Product getProductById(Long id) {
        log.info("Fetching product {} from database", id);
        return productRepository.findById(id)
            .orElseThrow(() -> new ProductNotFoundException("Product not found: " + id));
    }

    // Conditional caching - only cache if price > 100
    @Cacheable(value = "products", key = "#id", condition = "#result != null && #result.price > 100")
    public Product getExpensiveProduct(Long id) {
        log.info("Fetching expensive product {} from database", id);
        return productRepository.findById(id).orElse(null);
    }

    // Cache with custom key using SpEL
    @Cacheable(value = "products", key = "'category:' + #categoryId + ':page:' + #page")
    public List<Product> getProductsByCategory(Long categoryId, int page, int size) {
        log.info("Fetching products for category {} page {}", categoryId, page);
        Pageable pageable = PageRequest.of(page, size);
        return productRepository.findByCategoryId(categoryId, pageable).getContent();
    }

    // Update cache when product is modified
    @CachePut(value = "products", key = "#result.id")
    @CacheEvict(value = "products", key = "'category:' + #product.categoryId + ':page:*'", allEntries = false)
    public Product updateProduct(Long id, Product product) {
        log.info("Updating product {}", id);
        Product existing = productRepository.findById(id)
            .orElseThrow(() -> new ProductNotFoundException("Product not found: " + id));
        
        existing.setName(product.getName());
        existing.setDescription(product.getDescription());
        existing.setPrice(product.getPrice());
        existing.setStockQuantity(product.getStockQuantity());
        
        return productRepository.save(existing);
    }

    // Create and cache new product
    @CachePut(value = "products", key = "#result.id")
    public Product createProduct(Product product) {
        log.info("Creating new product: {}", product.getName());
        return productRepository.save(product);
    }

    // Evict single cache entry
    @CacheEvict(value = "products", key = "#id")
    public void deleteProduct(Long id) {
        log.info("Deleting product {}", id);
        productRepository.deleteById(id);
    }

    // Evict all entries in a cache
    @CacheEvict(value = "products", allEntries = true)
    public void clearProductCache() {
        log.info("Clearing all product cache entries");
    }

    // Multiple cache operations with @Caching
    @Caching(
        put = { @CachePut(value = "products", key = "#result.id") },
        evict = { 
            @CacheEvict(value = "products", key = "'featured'"),
            @CacheEvict(value = "products", key = "'category:' + #product.categoryId + ':page:0'")
        }
    )
    public Product saveAndInvalidateRelated(Product product) {
        return productRepository.save(product);
    }
}

Using RedisTemplate Directly

For advanced use cases, use RedisTemplate for direct Redis operations:

@Service
@Slf4j
public class RedisCacheService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final StringRedisTemplate stringRedisTemplate;

    public RedisCacheService(RedisTemplate<String, Object> redisTemplate,
                            StringRedisTemplate stringRedisTemplate) {
        this.redisTemplate = redisTemplate;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    // Store object with TTL
    public void cacheObject(String key, Object value, Duration ttl) {
        redisTemplate.opsForValue().set(key, value, ttl);
        log.debug("Cached object with key: {} for {}", key, ttl);
    }

    // Retrieve cached object
    @SuppressWarnings("unchecked")
    public <T> T getCachedObject(String key, Class<T> type) {
        Object value = redisTemplate.opsForValue().get(key);
        return value != null ? (T) value : null;
    }

    // Cache with conditional set (only if not exists)
    public boolean cacheIfAbsent(String key, Object value, Duration ttl) {
        Boolean result = redisTemplate.opsForValue().setIfAbsent(key, value, ttl);
        return Boolean.TRUE.equals(result);
    }

    // Increment counter (useful for rate limiting)
    public Long incrementCounter(String key) {
        return stringRedisTemplate.opsForValue().increment(key);
    }

    // Decrement counter
    public Long decrementCounter(String key) {
        return stringRedisTemplate.opsForValue().decrement(key);
    }

    // Hash operations for structured data
    public void cacheUserSession(String sessionId, Map<String, String> sessionData) {
        String key = "session:" + sessionId;
        redisTemplate.opsForHash().putAll(key, sessionData);
        redisTemplate.expire(key, Duration.ofHours(2));
    }

    public Map<Object, Object> getUserSession(String sessionId) {
        return redisTemplate.opsForHash().entries("session:" + sessionId);
    }

    // List operations for queues
    public void addToQueue(String queueName, Object item) {
        redisTemplate.opsForList().rightPush(queueName, item);
    }

    public Object popFromQueue(String queueName) {
        return redisTemplate.opsForList().leftPop(queueName);
    }

    // Set operations for unique collections
    public void addToSet(String setName, Object... values) {
        redisTemplate.opsForSet().add(setName, values);
    }

    public Set<Object> getSetMembers(String setName) {
        return redisTemplate.opsForSet().members(setName);
    }

    // Sorted set for leaderboards
    public void addToLeaderboard(String leaderboard, String member, double score) {
        redisTemplate.opsForZSet().add(leaderboard, member, score);
    }

    public Set<Object> getTopScores(String leaderboard, int count) {
        return redisTemplate.opsForZSet().reverseRange(leaderboard, 0, count - 1);
    }

    // Delete by pattern
    public void deleteByPattern(String pattern) {
        Set<String> keys = redisTemplate.keys(pattern);
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
            log.info("Deleted {} keys matching pattern: {}", keys.size(), pattern);
        }
    }

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

    // Get TTL of a key
    public Long getTTL(String key) {
        return redisTemplate.getExpire(key);
    }
}

Rate Limiting with Redis

Implement a sliding window rate limiter using Redis:

@Service
@Slf4j
public class RateLimiterService {

    private final StringRedisTemplate redisTemplate;

    public RateLimiterService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public boolean isAllowed(String clientId, int maxRequests, Duration window) {
        String key = "ratelimit:" + clientId;
        long now = System.currentTimeMillis();
        long windowStart = now - window.toMillis();

        // Use Redis transaction for atomic operations
        return Boolean.TRUE.equals(redisTemplate.execute(new SessionCallback<Boolean>() {
            @Override
            @SuppressWarnings("unchecked")
            public Boolean execute(RedisOperations operations) throws DataAccessException {
                operations.multi();
                
                // Remove old entries outside the window
                operations.opsForZSet().removeRangeByScore(key, 0, windowStart);
                
                // Count current requests in window
                operations.opsForZSet().count(key, windowStart, now);
                
                // Add current request
                operations.opsForZSet().add(key, String.valueOf(now), now);
                
                // Set expiry on the key
                operations.expire(key, window);
                
                List<Object> results = operations.exec();
                Long count = (Long) results.get(1);
                
                return count < maxRequests;
            }
        }));
    }

    public RateLimitInfo getRateLimitInfo(String clientId, int maxRequests, Duration window) {
        String key = "ratelimit:" + clientId;
        long now = System.currentTimeMillis();
        long windowStart = now - window.toMillis();

        Long count = redisTemplate.opsForZSet().count(key, windowStart, now);
        int remaining = Math.max(0, maxRequests - (count != null ? count.intValue() : 0));
        
        return new RateLimitInfo(maxRequests, remaining, window.toSeconds());
    }
}

public record RateLimitInfo(int limit, int remaining, long resetInSeconds) {}

Cache Warming

Pre-populate caches on application startup for frequently accessed data:

@Component
@Slf4j
public class CacheWarmer implements ApplicationListener<ApplicationReadyEvent> {

    private final ProductService productService;
    private final ProductRepository productRepository;

    public CacheWarmer(ProductService productService, ProductRepository productRepository) {
        this.productService = productService;
        this.productRepository = productRepository;
    }

    @Override
    @Async
    public void onApplicationEvent(ApplicationReadyEvent event) {
        log.info("Starting cache warming...");
        
        try {
            // Warm up popular products
            List<Long> popularProductIds = productRepository.findPopularProductIds(100);
            for (Long id : popularProductIds) {
                try {
                    productService.getProductById(id);
                } catch (Exception e) {
                    log.warn("Failed to warm cache for product {}: {}", id, e.getMessage());
                }
            }
            
            log.info("Cache warming completed. Warmed {} products", popularProductIds.size());
        } catch (Exception e) {
            log.error("Cache warming failed", e);
        }
    }
}

Cache Statistics and Monitoring

Create an endpoint to monitor cache health:

@RestController
@RequestMapping("/api/cache")
public class CacheController {

    private final CacheManager cacheManager;
    private final RedisTemplate<String, Object> redisTemplate;

    public CacheController(CacheManager cacheManager, 
                          RedisTemplate<String, Object> redisTemplate) {
        this.cacheManager = cacheManager;
        this.redisTemplate = redisTemplate;
    }

    @GetMapping("/stats")
    public Map<String, Object> getCacheStats() {
        Map<String, Object> stats = new HashMap<>();
        
        // Get all cache names
        stats.put("cacheNames", cacheManager.getCacheNames());
        
        // Get Redis info
        RedisConnection connection = redisTemplate.getConnectionFactory().getConnection();
        Properties info = connection.serverCommands().info("memory");
        
        stats.put("usedMemory", info.getProperty("used_memory_human"));
        stats.put("maxMemory", info.getProperty("maxmemory_human"));
        stats.put("connectedClients", connection.serverCommands().info("clients")
            .getProperty("connected_clients"));
        
        connection.close();
        
        return stats;
    }

    @DeleteMapping("/{cacheName}")
    public ResponseEntity<Void> clearCache(@PathVariable String cacheName) {
        Cache cache = cacheManager.getCache(cacheName);
        if (cache != null) {
            cache.clear();
            return ResponseEntity.ok().build();
        }
        return ResponseEntity.notFound().build();
    }

    @DeleteMapping
    public ResponseEntity<Void> clearAllCaches() {
        cacheManager.getCacheNames().forEach(name -> {
            Cache cache = cacheManager.getCache(name);
            if (cache != null) {
                cache.clear();
            }
        });
        return ResponseEntity.ok().build();
    }
}

Common Mistakes to Avoid

Watch out for these common pitfalls when implementing Redis caching:

1. Forgetting Serializable Interface

// Wrong - will fail serialization
public class User {
    private String name;
}

// Correct - implement Serializable
public class User implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;
    private String name;
}

2. Caching Mutable Objects Without Defensive Copies

// Wrong - cached object can be modified
@Cacheable("users")
public User getUser(Long id) {
    return userRepository.findById(id).orElse(null);
}
// Caller can modify: user.setName("hacked");

// Better - return immutable or use DTOs
@Cacheable("users")
public UserDTO getUser(Long id) {
    User user = userRepository.findById(id).orElse(null);
    return user != null ? UserDTO.from(user) : null;
}

3. Not Setting TTL Leading to Stale Data

// Wrong - data cached forever
@Cacheable("config")
public Config getConfig() { ... }

// Correct - configure TTL in RedisCacheConfiguration
cacheConfigurations.put("config", defaultConfig.entryTtl(Duration.ofMinutes(30)));

4. Calling Cached Methods Internally (Self-Invocation)

@Service
public class ProductService {
    
    @Cacheable("products")
    public Product getProduct(Long id) { ... }
    
    // Wrong - cache annotation ignored due to self-invocation
    public List<Product> getProducts(List<Long> ids) {
        return ids.stream()
            .map(this::getProduct)  // Cache NOT used!
            .toList();
    }
}

// Correct - inject self or use separate service
@Service
public class ProductService {
    @Autowired
    private ProductService self;  // Proxy injection
    
    public List<Product> getProducts(List<Long> ids) {
        return ids.stream()
            .map(self::getProduct)  // Cache works!
            .toList();
    }
}

5. Using Default Key Generation with Complex Parameters

// Wrong - Pageable doesn't have stable hashCode
@Cacheable("products")
public List<Product> getProducts(Pageable pageable) { ... }

// Correct - explicit key with relevant fields
@Cacheable(value = "products", key = "'page:' + #pageable.pageNumber + ':size:' + #pageable.pageSize")
public List<Product> getProducts(Pageable pageable) { ... }

Best Practices

  • Set appropriate TTLs – Different data types need different expiration times
  • Use meaningful cache names and keys – Makes debugging and monitoring easier
  • Implement cache warming – Pre-populate caches for critical data
  • Monitor cache hit rates – Low hit rates indicate ineffective caching
  • Handle cache failures gracefully – Your app should work without cache
  • Use connection pooling – Essential for production performance
  • Configure maxmemory-policy – Use volatile-lru or allkeys-lru for eviction
  • Separate Redis for cache vs. persistence – Different reliability requirements

Final Thoughts

Integrating Redis with Spring Boot is one of the most impactful performance optimizations you can make. With Spring’s caching abstraction, you get declarative caching with minimal code changes. For advanced use cases, RedisTemplate provides full access to Redis data structures for sessions, rate limiting, queues, and leaderboards. Start with annotation-based caching for simple use cases, then leverage RedisTemplate for complex scenarios. Always set appropriate TTLs, monitor cache effectiveness, and remember that caching is not a silver bullet—use it where it makes sense.

To learn more about building high-performance Spring applications, read Building Reactive APIs with Spring WebFlux and Testing Spring Boot Apps Using Testcontainers. For official documentation, visit the Spring Data Redis Guide and the Redis Documentation.

Leave a Comment