Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 052 / 100

Caching with @Cacheable and Redis in Java & Spring Boot

Cache expensive reads with one annotation.

Expona Admin6 min read

The Cost of Repeated Database Queries

In production systems, database queries become a bottleneck fast. A single API endpoint that fetches product details, inventory counts, and pricing rules might execute the same queries dozens of times per second across your fleet. Caching with @Cacheable and Redis in Java & Spring Boot solves this elegantly: one annotation replaces repetitive database hits with in-memory lookups that respond in milliseconds instead of tens of milliseconds. We've seen this pattern cut database load by 60–80% on e-commerce platforms without touching query logic.

Understanding @Cacheable on Read Methods

The @Cacheable annotation is your primary tool for transparent caching. When you decorate a method with it, Spring creates a proxy that checks the cache before executing the method body. If the value exists, it returns immediately. If not, it runs your method and stores the result.

The key decision is always the cache key. A poorly chosen key defeats caching; you'll hit your cache but miss on every variation. Consider a user profile fetch:

java
@Service
public class UserService {
    @Cacheable(value = "users", key = "#id")
    public UserProfile getUserProfile(Long id) {
        // Expensive database query
        return userRepository.findById(id).orElseThrow();
    }
}

The key = "#id" parameter uses SpEL (Spring Expression Language) to generate a unique key from the method parameter. When you call getUserProfile(123L), Spring caches the result under the key users::123 (the value prefix plus the key). Every subsequent call with the same ID within the TTL retrieves from cache.

For composite keys, use a combination of parameters:

java
@Cacheable(value = "products", key = "#categoryId + ':' + #productId")
public Product getProduct(Long categoryId, Long productId) {
    return productRepository.findByCategoryAndId(categoryId, productId);
}

When method signatures are more complex, build keys from multiple attributes:

java
@Cacheable(value = "orders", key = "#userId + ':' + #orderId")
public OrderDetail getOrderDetail(Long userId, Long orderId) {
    return orderRepository.findByUserIdAndId(userId, orderId);
}

The golden rule: every unique combination of inputs that produces different outputs should have a unique key. If you cache product details by ID alone, all users get the same cached data—that's correct. If you accidentally include the user ID in the key for data that should be shared, you're duplicating cache entries and wasting memory.

@CacheEvict on Writes

Caching introduces a consistency problem: if data changes, the cache becomes stale. The @CacheEvict annotation solves this by removing entries when you write.

On a simple update, evict the specific key:

java
@CacheEvict(value = "users", key = "#id")
public void updateUserProfile(Long id, UserProfile updates) {
    UserProfile existing = userRepository.findById(id).orElseThrow();
    existing.setName(updates.getName());
    existing.setEmail(updates.getEmail());
    userRepository.save(existing);
}

When you update user 123, Spring removes users::123 from the cache. The next read triggers a fresh database query and repopulates the cache with current data.

For operations that invalidate multiple entries, use allEntries = true:

java
@CacheEvict(value = "products", allEntries = true)
public void rebuildProductCatalog() {
    // Expensive rebuild operation
    productRepository.deleteAll();
    productRepository.saveAll(loadFromExternalSource());
}

This clears the entire products cache. Use it sparingly—clearing all entries means every subsequent read misses the cache.

For deletion operations, evict the same key you cache on reads:

java
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
    userRepository.deleteById(id);
}

Always Set a TTL (Time-To-Live)

Without a TTL, stale data lives forever. A user's permissions might be cached for hours after they're revoked. An inventory count might be wrong by thousands of units if your cache never expires. Setting a TTL forces periodic refreshes and bounds how stale your data can become.

In Spring Boot with Redis, configure TTL in application.yml:

java
@Configuration
@EnableCaching
public class CacheConfig {

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

        return RedisCacheManager.create(connectionFactory);
    }
}

This sets a global default TTL of 5 minutes. For different cache names with different TTLs:

java
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
    Map cacheConfigs = new HashMap<>();
    
    // Fast-moving data: short TTL
    cacheConfigs.put("users", RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(5)));
    
    // Stable reference data: longer TTL
    cacheConfigs.put("categories", RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofHours(2)));

    return RedisCacheManager.create(
        RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory)
            .withInitialCacheConfigurations(cacheConfigs)
            .build()
    );
}

Choose TTL based on tolerance for staleness. Inventory counts might tolerate 30 seconds. User preferences might tolerate 5 minutes. Reference data that changes quarterly might use 24 hours. Document your choices in code comments—it helps the next person understand why that TTL exists.

Never Cache Per-User Data in a Shared Key

The most dangerous caching mistake is storing user-specific data under a key that doesn't include the user ID. We've seen production incidents where one user's private data was served to another.

Wrong approach:

java
@Cacheable(value = "dashboard", key = "'summary'")
public DashboardSummary getDashboardSummary(Long userId) {
    // DANGEROUS: All users share the same cache key!
    return dashboardRepository.buildSummaryFor(userId);
}

If Alice calls this method first, her dashboard summary is cached under dashboard::summary. When Bob calls it, he gets Alice's data.

Correct approach:

java
@Cacheable(value = "dashboard", key = "#userId + ':summary'")
public DashboardSummary getDashboardSummary(Long userId) {
    return dashboardRepository.buildSummaryFor(userId);
}

Now each user has their own cache entry: dashboard::1:summary, dashboard::2:summary, etc.

The same principle applies to transactional data. An order's details are user-specific; include the user ID in the key. A product's catalog information is shared; don't include user ID.

Common Mistakes

  • Forgetting to enable caching. Without @EnableCaching on a @Configuration class, annotations are ignored silently. Your code runs without errors, but nothing gets cached.
  • Using mutable objects as cache values. If you cache a List or Map and the caller modifies it, the cached copy is polluted. Either return immutable collections or deep-copy on retrieval.
  • Caching null or error responses. A failed database query shouldn't be cached. Configure unless to exclude null results: @Cacheable(value = "users", key = "#id", unless = "#result == null").
  • Inconsistent eviction logic. If you cache on reads but forget to evict on deletes, stale records linger forever. Audit every write operation and add corresponding @CacheEvict annotations.
  • Monitoring blindness. Cache hit/miss rates are critical metrics. Without visibility, you can't tell if caching actually helps. Use Redis INFO commands or Spring Boot Actuator metrics to track cache performance.

Key Takeaways

  • Use @Cacheable on read methods with carefully designed keys that uniquely identify cached data.
  • Pair every cache with @CacheEvict on corresponding write and delete operations.
  • Always configure a TTL appropriate to your data's staleness tolerance.
  • Never combine per-user data under a shared cache key; include the user ID in the key.
  • Monitor cache hit rates and memory usage in production; caching only helps if you measure it.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles