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
@CacheEvictwherever 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-lfuas 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, andnoevictionis 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.