Q: How does Spring's @Cacheable work? Caveats around proxies, keys, and invalidation.

Answer:

Spring Cache abstraction = annotations (@Cacheable, @CacheEvict, @CachePut) backed by a pluggable provider (Caffeine, Redis, Ehcache, Hazelcast).

Enable

@EnableCaching
@Configuration class CacheConfig { }

Default provider: ConcurrentMapCacheManager (heap, no eviction). Real apps use Caffeine or Redis.

Annotations

AnnotationEffect
@CacheableLookup cache; on miss, run method, store result
@CachePutAlways run method, store result (refresh)
@CacheEvictRemove entry (or all)
@CachingCompose multiple

Examples

@Cacheable(value = "products", key = "#id")
public Product findById(Long id) { return repo.findById(id).orElseThrow(); }

@CachePut(value = "products", key = "#p.id")
public Product update(Product p) { return repo.save(p); }

@CacheEvict(value = "products", key = "#id")
public void delete(Long id) { repo.deleteById(id); }

@CacheEvict(value = "products", allEntries = true)
public void clearAll() { }

Key Generation

  • Default: SimpleKeyGenerator — combines all params.
  • SpEL via key:
    @Cacheable(value="users", key="#email.toLowerCase()")
    @Cacheable(value="orders", key="#root.methodName + '-' + #status + '-' + #page")
    
  • Custom: implement KeyGenerator.

condition and unless

@Cacheable(value="products", key="#id", condition="#id > 0", unless="#result == null")
public Product find(long id) { ... }
  • condition evaluated before call → skip caching when false.
  • unless evaluated after call → skip storing when true.

Caffeine Setup

<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>caffeine</artifactId>
</dependency>
spring:
  cache:
    type: caffeine
    cache-names: products, users
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m,recordStats

Programmatic per-cache config:

@Bean
CacheManager cacheManager() {
    CaffeineCacheManager m = new CaffeineCacheManager("products", "users");
    m.setCaffeine(Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .recordStats());
    return m;
}

Redis Setup

spring:
  cache:
    type: redis
    redis:
      time-to-live: 600000   # ms
      cache-null-values: false
@Bean
RedisCacheManager cacheManager(RedisConnectionFactory cf) {
    return RedisCacheManager.builder(cf)
        .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())))
        .withCacheConfiguration("products", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1)))
        .build();
}

Self-Invocation Trap (BIG One)

Spring caching is proxy-based. Internal calls bypass the proxy.

@Service
class ProductService {
    @Cacheable("products")
    public Product find(Long id) { ... }

    public Product findAndCount(Long id) {
        return find(id);   // ❌ same-class call → no proxy → no cache!
    }
}

Fixes:

  • Move method to a different bean.
  • Self-inject:
    @Lazy @Autowired ProductService self;
    public Product findAndCount(Long id) { return self.find(id); }
    
  • Use AopContext.currentProxy() (requires @EnableCaching(exposeProxy = true)).

Other Pitfalls

1. Caching null Default behavior: null cached. Often you don't want that:

@Cacheable(value="users", key="#id", unless="#result == null")

Or for Redis, set cache-null-values: false.

2. Mutable returned objects Caller mutates → next cache hit returns mutated. Use immutable types or defensive copies.

3. Cache stampede / thundering herd Many concurrent misses → all hit DB. Caffeine handles this by default (AsyncCache or sync loading). Redis caches don't — implement single-flight or use Caffeine in front of Redis.

4. Stale data TTL too long → stale; too short → cache useless. Combine TTL with explicit @CacheEvict on writes.

5. Multi-arg key surprises

@Cacheable("orders")
List<Order> list(int page, int size, Sort sort) { ... }
// SimpleKey(page, size, sort) — sort.toString() may not be deterministic across instances

Define an explicit key SpEL.

@CacheEvict(beforeInvocation = true)

Default: evict after method returns. If method throws, cache remains. Set beforeInvocation = true to evict regardless.

@Caching (Compose)

@Caching(evict = {
    @CacheEvict(value="orders", key="#id"),
    @CacheEvict(value="orderSummaries", allEntries=true)
})
public void delete(Long id) { ... }

When NOT to Cache

  • Highly write-heavy data — cache invalidation cost dwarfs read savings.
  • Per-user data with low repeat rate.
  • Anything sensitive without thinking through eviction on auth/role changes.