Caching

Spring's cache abstraction adds declarative caching with four annotations—no manual get/put in business code. Under the hood it's CacheInterceptor around advice (same proxy model as @Transactional). This chapter covers annotations, providers from Caffeine to Redis, and low-level Redis patterns beyond caching.

mid senior Spring Boot 3.x

Spring cache abstraction

Vendor-neutral caching API: swap ConcurrentHashMap, Caffeine, or Redis without changing service method signatures. Enable with @EnableCaching and a CacheManager bean.

Minimal setup
@Configuration
@EnableCaching
class CacheConfig {}

@Service
class ProductService {
  @Cacheable("products")
  public ProductDto getBySku(String sku) {
    return productClient.fetchFromDb(sku);  // only on cache miss
  }
}
sequenceDiagram
  participant Caller as Caller
  participant Proxy as Cache proxy
  participant Cache as Cache store
  participant Target as Service method
  Caller->>Proxy: getBySku ABC
  Proxy->>Cache: get key ABC
  alt cache hit
    Cache-->>Proxy: ProductDto
    Proxy-->>Caller: return cached
  else cache miss
    Proxy->>Target: proceed
    Target-->>Proxy: ProductDto
    Proxy->>Cache: put key ABC
    Proxy-->>Caller: return fresh
  end
AnnotationBehavior
@CacheableCache result; skip method on hit
@CachePutAlways run method; update cache with result
@CacheEvictRemove entries — on update/delete
@CachingCombine multiple cache ops on one method
🔬 Under the Hood

CacheInterceptor evaluates SpEL for keys/conditions, delegates to CacheManager.getCache(name). Cache names in annotations map to named cache regions—not individual keys. Keys are computed per invocation inside that region.

@Cacheable

The workhorse annotation—cache read-through on method results.

Key, condition, unless
@Cacheable(
    value = "orders",
    key = "#id",
    condition = "#id > 0",
    unless = "#result == null || #result.status == 'DRAFT'"
)
public OrderDto getOrder(long id) {
  return orderRepository.findDto(id);
}

// Composite key
@Cacheable(value = "rates", key = "#fromCurrency + '-' + #toCurrency")
public BigDecimal getExchangeRate(String fromCurrency, String toCurrency) { /* ... */ }

// Default key: all method params as SimpleKey — getOrder(42) -> SimpleKey[42]
AttributePurpose
value / cacheNamesCache region name(s)
keySpEL expression — default uses all parameters
conditionSpEL — if false, caching skipped entirely (method still runs)
unlessSpEL — if true after method, result not cached
cacheManagerBean name when multiple cache managers exist
⚠️ Pitfall

Caching mutable objects — caller can mutate cached instance and corrupt cache for everyone. Cache immutable DTOs/records. Self-invocation bypasses cache — see AOP chapter.

@CachePut & @CacheEvict

Writes and invalidation—keep cache coherent when data changes.

Update and evict patterns
@CachePut(value = "products", key = "#result.sku()")
public ProductDto updateProduct(UpdateProductCommand cmd) {
  return productRepository.save(cmd);
}

@CacheEvict(value = "products", key = "#sku")
public void deleteProduct(String sku) {
  productRepository.deleteBySku(sku);
}

@CacheEvict(value = "productCatalog", allEntries = true)
public void refreshCatalog() {
  catalogService.rebuildIndex();
}

@CacheEvict(value = "orders", key = "#id", beforeInvocation = true)
public void cancelOrder(long id) {
  orderRepository.cancel(id);  // evict even if cancel throws
}
Attribute@CacheEvict
allEntries = true Clear entire cache region — use sparingly; expensive on Redis
beforeInvocation = true Evict before method runs — ensures stale data not served if method fails mid-way
Default (after) Evict only if method completes successfully
💡 Pro Tip

On update: prefer @CachePut to refresh the entry vs @CacheEvict + next read repopulates—reduces thundering herd on hot keys.

@Caching, custom keys & cache resolvers

Advanced configuration when one method touches multiple cache regions or needs non-SpEL key logic.

@Caching — evict and put together
@Caching(
    evict = {
        @CacheEvict(value = "orders", key = "#id"),
        @CacheEvict(value = "orderLists", allEntries = true)
    },
    put = @CachePut(value = "orders", key = "#result.id()")
)
public OrderDto reassignOrder(long id, String newCustomerId) {
  return orderService.reassign(id, newCustomerId);
}

Custom KeyGenerator

KeyGenerator implementation
@Component("tenantAwareKeyGenerator")
class TenantKeyGenerator implements KeyGenerator {
  @Override
  public Object generate(Object target, Method method, Object... params) {
    String tenant = TenantContext.getCurrentTenant();
    return tenant + ":" + method.getName() + ":" + Arrays.deepHashCode(params);
  }
}

@Cacheable(value = "reports", keyGenerator = "tenantAwareKeyGenerator")
public ReportDto getReport(ReportQuery query) { /* ... */ }

Multiple CacheManager beans

Route to different managers
@Bean
@Primary
CacheManager caffeineCacheManager() { /* local Caffeine */ }

@Bean
CacheManager redisCacheManager(RedisConnectionFactory factory) { /* distributed */ }

@Cacheable(value = "sessions", cacheManager = "redisCacheManager")
public SessionDto getSession(String id) { /* ... */ }

@Cacheable(value = "config", cacheManager = "caffeineCacheManager")
public AppConfig loadConfig() { /* ... */ }

Alternatively implement CacheResolver for dynamic cache name resolution based on tenant or environment.

Cache providers

CacheManager is the plug-in point—Spring ships adapters for in-process and distributed stores.

ProviderScopeTTL / evictionUse when
ConcurrentMapCacheManager Single JVM None — unbounded until restart Dev/test only — Boot default if no provider on classpath
Caffeine Single JVM Size, time, reference-based eviction Production in-process — fast, stats, configurable
Redis Distributed TTL per key, cluster-wide Multi-instance apps, shared session-like cache
Hazelcast Distributed in-memory grid Near-cache, partition tolerance Large clustered caches, compute near data
ConcurrentMap — dev only
@Bean
CacheManager simpleCacheManager() {
  return new ConcurrentMapCacheManager("products", "orders");
  // No TTL — memory leak risk; not visible across pods
}
⚠️ Pitfall

Default in-memory cache in multi-pod deployment = cache inconsistency — each instance has different data. Use Redis or stick to local cache only for derived/read-only data with short TTL and acceptance of staleness.

Caffeine — in-process production cache

High-performance JVM cache with size bounds, TTL, and hit rate statistics—default choice for single-instance or local L1 layer.

CaffeineCacheManager
@Bean
CacheManager caffeineCacheManager() {
  CaffeineCacheManager manager = new CaffeineCacheManager("products", "rates");
  manager.setCaffeine(Caffeine.newBuilder()
      .maximumSize(10_000)
      .expireAfterWrite(Duration.ofMinutes(10))
      .expireAfterAccess(Duration.ofMinutes(5))
      .recordStats());
  return manager;
}
Boot auto-config — application.yml
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=5000,expireAfterWrite=10m,recordStats
    cache-names: products,rates,config
PolicyBehavior
maximumSizeLRU-ish eviction when full (W-TinyLFU algorithm)
expireAfterWriteFixed TTL from insert/replace
expireAfterAccessTTL resets on read — good for hot keys
recordStatsExpose hit rate via cache.getNativeCache().stats() — wire to Micrometer
📦 Real World

L1 (Caffeine) + L2 (Redis) pattern: check local first, then Redis, then DB—reduces cross-AZ Redis latency. Spring doesn't ship this out of the box; implement custom Cache or use libraries like cache2k/multi-level patterns.

Redis via Spring Data Redis

Shared cache across all application instances—essential for horizontal scale and consistent invalidation.

RedisCacheManager configuration
@Bean
RedisCacheManager redisCacheManager(RedisConnectionFactory factory) {
  RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
      .entryTtl(Duration.ofMinutes(30))
      .disableCachingNullValues()
      .serializeValuesWith(RedisSerializationContext.SerializationPair
          .fromSerializer(new GenericJackson2JsonRedisSerializer()));

  Map<String, RedisCacheConfiguration> perCache = Map.of(
      "products", defaults.entryTtl(Duration.ofHours(1)),
      "sessions", defaults.entryTtl(Duration.ofMinutes(15))
  );

  return RedisCacheManager.builder(factory)
      .cacheDefaults(defaults)
      .withInitialCacheConfigurations(perCache)
      .transactionAware()
      .build();
}
Boot properties
spring:
  data:
    redis:
      host: redis.internal
      port: 6379
  cache:
    type: redis
    redis:
      time-to-live: 30m
      cache-null-values: false
      key-prefix: "acme:"

Hazelcast — near-cache pattern

Embedded or client-server grid with near-cache (local replica of hot entries) for read-heavy workloads. Integrate via HazelcastCacheManager — less common in cloud-native stacks than Redis, but strong for on-prem clusters.

Redis integration beyond caching

Spring Data Redis provides low-level templates for structures, pub/sub, and atomic scripts—separate from the cache abstraction but same connection factory.

RedisTemplate vs StringRedisTemplate

TemplateKeys & valuesUse for
RedisTemplate<K,V> Generic serializers — often String keys, JSON/object values Hashes, lists, custom objects, cache-like manual ops
StringRedisTemplate String/String only — UTF-8 Counters, simple flags, rate limiting keys, Lua with string args
RedisTemplate setup
@Bean
RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
  RedisTemplate<String, Object> template = new RedisTemplate<>();
  template.setConnectionFactory(factory);
  template.setKeySerializer(new StringRedisSerializer());
  template.setHashKeySerializer(new StringRedisSerializer());
  template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
  template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
  template.afterPropertiesSet();
  return template;
}

Serialization — JDK vs JSON

SerializerProsCons
JdkSerializationRedisSerializer Works with any Serializable Opaque binary, Java-only, fragile on class changes, security risk
GenericJackson2JsonRedisSerializer Human-readable, cross-language, version-tolerant with care Requires default typing config carefully — avoid unsafe polymorphic types
⚠️ Pitfall

JDK serialization in Redis: upgrading DTO classes breaks cached bytes; Redis CLI shows gibberish. Always prefer JSON + explicit DTO types. Never deserialize untrusted JDK blobs.

@RedisHash — Redis repositories

Entity stored as Redis hash
@RedisHash("session")
public class UserSession {
  @Id
  private String sessionId;
  private String userId;
  @TimeToLive
  private Long expirationSeconds = 3600L;
}

public interface SessionRepository extends CrudRepository<UserSession, String> {}

Spring Data Redis generates repository implementation—similar ergonomics to JPA for simple key-value domain objects.

Lettuce vs Jedis

ClientModelNotes
Lettuce (Boot default) Async, thread-safe, single shared connection + multiplexing Supports reactive ReactiveRedisTemplate
Jedis Blocking, connection pool per thread traditionally Explicit opt-in; familiar in older codebases

Pub/Sub & Lua scripts

Patterns for event broadcast and atomic multi-key operations—complement declarative caching.

Pub/Sub — RedisMessageListenerContainer

Subscribe to channel
@Configuration
class RedisPubSubConfig {
  @Bean
  RedisMessageListenerContainer container(RedisConnectionFactory factory,
      MessageListenerAdapter listenerAdapter) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(factory);
    container.addMessageListener(listenerAdapter, new ChannelTopic("cache-invalidation"));
    return container;
  }

  @Bean
  MessageListenerAdapter listenerAdapter(CacheInvalidationListener listener) {
    return new MessageListenerAdapter(listener, "onMessage");
  }
}

@Component
class CacheInvalidationListener {
  private final CacheManager cacheManager;

  public void onMessage(String message) {
    cacheManager.getCache("products").clear();  // react to remote invalidation
  }
}

Lua scripts — atomic operations

Execute logic server-side atomically—rate limits, compare-and-set, inventory decrement without race conditions.

Atomic decrement with floor at zero
@Service
class InventoryService {
  private final StringRedisTemplate redis;
  private final DefaultRedisScript<Long> decrementScript;

  InventoryService(StringRedisTemplate redis) {
    this.redis = redis;
    this.decrementScript = new DefaultRedisScript<>("""
        local current = tonumber(redis.call('GET', KEYS[1]) or '0')
        local qty = tonumber(ARGV[1])
        if current < qty then return -1 end
        return redis.call('DECRBY', KEYS[1], qty)
        """, Long.class);
  }

  boolean reserve(String sku, int qty) {
    Long result = redis.execute(decrementScript, List.of("stock:" + sku), String.valueOf(qty));
    return result != null && result >= 0;
  }
}
🎯 Interview Tip

When to use Spring Cache vs raw Redis: annotations for read-heavy method caching; RedisTemplate for counters, locks, leaderboards, pub/sub. Mention stampede protection: sync=true on @Cacheable or single-flight pattern for hot keys.

💡 Pro Tip

Expose cache metrics: Caffeine recordStats() → Micrometer; Redis cache hit/miss via custom wrapper or tracing cache gets in dev. Alert on hit rate drop after deploys.