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.
Spring cache abstraction
Vendor-neutral caching API: swap ConcurrentHashMap, Caffeine, or Redis without changing service method signatures. Enable with @EnableCaching and a CacheManager bean.
@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
| Annotation | Behavior |
|---|---|
| @Cacheable | Cache result; skip method on hit |
| @CachePut | Always run method; update cache with result |
| @CacheEvict | Remove entries — on update/delete |
| @Caching | Combine multiple cache ops on one method |
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.
@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]
| Attribute | Purpose |
|---|---|
| value / cacheNames | Cache region name(s) |
| key | SpEL expression — default uses all parameters |
| condition | SpEL — if false, caching skipped entirely (method still runs) |
| unless | SpEL — if true after method, result not cached |
| cacheManager | Bean name when multiple cache managers exist |
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.
@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 |
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 = {
@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
@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
@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.
| Provider | Scope | TTL / eviction | Use 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 |
@Bean
CacheManager simpleCacheManager() {
return new ConcurrentMapCacheManager("products", "orders");
// No TTL — memory leak risk; not visible across pods
}
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.
@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;
}
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=5000,expireAfterWrite=10m,recordStats
cache-names: products,rates,config
| Policy | Behavior |
|---|---|
| maximumSize | LRU-ish eviction when full (W-TinyLFU algorithm) |
| expireAfterWrite | Fixed TTL from insert/replace |
| expireAfterAccess | TTL resets on read — good for hot keys |
| recordStats | Expose hit rate via cache.getNativeCache().stats() — wire to Micrometer |
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.
@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();
}
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
| Template | Keys & values | Use 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 |
@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
| Serializer | Pros | Cons |
|---|---|---|
| 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 |
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
@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
| Client | Model | Notes |
|---|---|---|
| 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
@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.
@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;
}
}
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.
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.