Spring Cache

Spring Cache Abstraction

Spring’s caching abstraction is declarative — annotate methods, and a proxy (same AOP mechanism as @Transactional/@PreAuthorize) intercepts calls to check the cache before invoking the method, caching the result afterward. The underlying cache provider (Redis, Caffeine, EhCache, simple ConcurrentHashMap) is fully pluggable behind this abstraction — business code never references the provider directly.

@Configuration
@EnableCaching
public class CacheConfig {
}

@Service
@RequiredArgsConstructor
public class ProductService {

    @Cacheable(value = "products", key = "#sku")
    public Product getProduct(String sku) {
        return productRepository.findBySku(sku)
            .orElseThrow(() -> new ProductNotFoundException(sku));
        // on first call: cache miss → method executes → result cached under key 'sku'
        // on subsequent calls with same sku: method body SKIPPED entirely, cached value returned
    }

    @CachePut(value = "products", key = "#product.sku")
    public Product updateProduct(Product product) {
        return productRepository.save(product);
        // ALWAYS executes the method, then updates the cache with the result — use for writes
    }

    @CacheEvict(value = "products", key = "#sku")
    public void deleteProduct(String sku) {
        productRepository.deleteBySku(sku);
        // removes the entry — without this, deleted DB rows would remain visible via stale cache
    }

    @Caching(evict = {
        @CacheEvict(value = "products", key = "#sku"),
        @CacheEvict(value = "productSummaries", allEntries = true)
    })
    public void retire(String sku) {
        // combine multiple cache operations across different cache names in one annotation
    }
}
Annotation Behavior
@Cacheable Check cache first; skip method body on hit; cache result on miss
@CachePut Always run method; update cache with the result (use for write paths that must also refresh the cache)
@CacheEvict Remove entry/entries; use after deletes or whenever cached data must be invalidated
@Caching Compose multiple cache operations on one method
@CacheConfig Class-level default cacheNames, reduces repetition across methods in the same class

Conditional caching — common in real systems where blanket caching isn’t appropriate for every input:

@Cacheable(value = "products", key = "#sku", condition = "#sku != null", unless = "#result.price == 0")
public Product getProduct(String sku) { ... }

condition evaluates before the method runs (skip caching logic entirely for some inputs); unless evaluates after (cache logic runs, but the specific result is excluded from being stored) — a subtle but interview-relevant distinction.

Architect-level pitfall, same root cause as @Transactional: @Cacheable is proxy-based AOP — self-invocation (this.getProduct(sku) called from within the same class) bypasses the proxy and silently skips caching entirely. Same fix as always: call through an injected reference to a separate bean, not this.


Redis Integration

Redis is the dominant production cache provider for Spring applications — distributed (shared across all instances, unlike per-instance in-memory caches), supports TTL natively, and doubles as a building block for rate limiting, session storage, and pub/sub beyond just caching.

spring:
  data:
    redis:
      host: redis-cluster
      port: 6379
      password: ${REDIS_PASSWORD}
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8
  cache:
    type: redis
    redis:
      time-to-live: 600000   # 10 minutes default TTL
      cache-null-values: false
@Bean
public RedisCacheConfiguration cacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(10))
        .disableCachingNullValues()
        .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
            new GenericJackson2JsonRedisSerializer()))
        .computePrefixWith(cacheName -> "orders::" + cacheName + "::");
}

// Per-cache-name TTL overrides — different data has different staleness tolerance
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory, RedisCacheConfiguration defaultConfig) {
    Map<String, RedisCacheConfiguration> configs = Map.of(
        "products", defaultConfig.entryTtl(Duration.ofMinutes(30)),
        "inventory", defaultConfig.entryTtl(Duration.ofSeconds(30)) // fast-changing data, short TTL
    );
    return RedisCacheManager.builder(factory)
        .cacheDefaults(defaultConfig)
        .withInitialCacheConfigurations(configs)
        .build();
}

Lettuce vs Jedis: Spring Boot defaults to Lettuce (async, thread-safe, single connection multiplexed via Netty) over Jedis (blocking, requires connection pooling for thread safety). Lettuce is the right default for virtually all modern Spring Boot apps — Jedis is mostly seen in legacy codebases or where blocking-style simplicity was deliberately chosen, and there’s rarely a reason to choose it for new work.

Direct RedisTemplate use beyond declarative caching — for cases the @Cacheable abstraction doesn’t fit (rate limiting counters, distributed locks, pub/sub, sorted sets for leaderboards):

@Service
@RequiredArgsConstructor
public class RateLimiterService {
    private final StringRedisTemplate redisTemplate;

    public boolean allowRequest(String clientId, int limit, Duration window) {
        String key = "ratelimit:" + clientId;
        Long count = redisTemplate.opsForValue().increment(key);
        if (count != null && count == 1) {
            redisTemplate.expire(key, window); // set TTL only on the first increment
        }
        return count != null && count <= limit;
    }
}

Architect-level point — serialization choice matters for cross-version compatibility: GenericJackson2JsonRedisSerializer (JSON) is generally preferred over the default JDK serialization for production Redis caches — JDK serialization is brittle across class version changes (deploying a new app version with a modified DTO can make previously cached entries unreadable, causing deserialization exceptions on read) and isn’t human-inspectable for debugging via redis-cli. JSON serialization trades a small performance/size cost for operational resilience and debuggability — a worthwhile trade in almost every production system.


Cache Eviction Strategies

Two distinct eviction concerns get conflated under “eviction” — worth separating clearly in an architect-level answer:

1. Application-driven invalidation (you explicitly remove/update entries on a known data change):

@CacheEvict(value = "products", key = "#product.sku")
@Transactional
public void updateProduct(Product product) {
    productRepository.save(product);
    // cache entry removed in the SAME logical operation as the DB write
}

This is the most reliable consistency strategy when feasible — the cache is invalidated at the exact moment the underlying data changes, rather than waiting for a TTL to expire and serving stale data in the interim.

2. TTL-based expiry (passive, time-driven, Redis-native):

spring:
  cache:
    redis:
      time-to-live: 600000

Necessary as a safety net for data changed by paths the cache layer doesn’t see (direct DB writes from another service, batch jobs, manual data fixes) — TTL bounds the maximum staleness window even when explicit invalidation is missed or bypassed.

3. Memory-pressure eviction policies (Redis server-level, not Spring-level) — what happens when Redis runs out of allocated memory:

Policy Behavior
noeviction Reject new writes once memory limit is hit — never silently drop data
allkeys-lru Evict least-recently-used key across the whole keyspace, regardless of TTL — most common default for pure-cache use
volatile-lru LRU eviction, but only among keys that have a TTL set (keys without expiry are never evicted)
allkeys-lfu / volatile-lfu Least-frequently-used variants — better than LRU for workloads with a stable “hot set” that shouldn’t get evicted just because it wasn’t the most recent access
volatile-ttl Evict keys with the shortest remaining TTL first
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru

Architect-level decision framework — choosing the right combination:

  • Use explicit @CacheEvict wherever the write path is known and within your control — it’s the strongest consistency guarantee and should be the default for any data your own service owns and mutates.
  • Always set a TTL regardless, even on explicitly-evicted caches — as a defense-in-depth safety net against missed invalidation paths (a bug, a direct DB write bypassing the service layer, a batch import) rather than relying purely on explicit eviction being airtight.
  • Choose TTL duration based on staleness tolerance per data type, not a single blanket value — reference data (product catalog) tolerates 30+ minutes; rapidly-changing operational data (live inventory counts) needs seconds-scale TTLs or no caching at all if correctness-criticality outweighs the read-load savings.
  • allkeys-lru/allkeys-lfu as the Redis-level safety net for memory pressure is a separate, independent concern from your application’s TTL/explicit-eviction strategy — it only kicks in if Redis is genuinely out of allocated memory, and noeviction is the wrong choice for a pure-cache use case (it would start rejecting cache writes rather than gracefully dropping old entries, turning a cache-capacity problem into a write-failure problem).

Common pitfall worth flagging in review: caching write-heavy or low-reuse data (every key requested once, never again) wastes memory and adds serialization overhead with zero hit-rate benefit — caching should be reserved for genuinely read-heavy, reasonably-stable data; reflexively wrapping every repository method in @Cacheable without checking actual read/write ratios is a common over-engineering pattern in codebases that adopted caching as a default rather than a deliberate, measured optimization.