Caching Strategies
Choose what to cache, where to keep it and how it goes stale: cache-aside, write-through, TTLs, stampede protection and HTTP caching.
@Cacheable(cacheNames = "products", key = "#id", sync = true) // cache-aside
@CacheEvict(cacheNames = "products", key = "#id") // invalidate
ResponseEntity.ok().cacheControl(CacheControl.maxAge(Duration.ofMinutes(5))).body(dto)A cache trades freshness for speed. Before adding one, decide how stale each piece of data may be. A product description can be ten minutes old; a stock count used to accept an order cannot be stale at all. That answer picks the strategy.
Cache-aside, the default
The application looks in the cache, and on a miss loads from the database and stores the result. @Cacheable does exactly this. Pair every cached read with an invalidation on write: @CacheEvict drops the entry so the next read reloads it. Evicting is safer than updating in place, because a failed or rolled-back write cannot leave a wrong value behind.
Write-through
@CachePut always runs the method and stores its return value, so the cache is refreshed as part of the write. Use it when the write already returns the full new object and reads must see it immediately. With a shared cache, make the cache manager transaction-aware so the put or evict happens after commit; otherwise a rollback leaves the cache ahead of the database.
Expiry as the safety net
Always set a time to live, even when you evict on writes. Rows change through other services, SQL scripts and admin tools that never call your eviction code. A TTL bounds how long any mistake can survive. Pick it per cache, not globally.
Stampedes
When a popular entry expires, hundreds of requests miss at once and all hit the database. @Cacheable(sync = true) lets one thread load the value while the others on the same instance wait for it. It does not coordinate between instances, but turning 500 concurrent loads into one per replica is usually enough.
Local or shared
- Local (Caffeine, or the simple map cache): nanosecond reads, but each replica has its own copy, and an eviction on one replica leaves the others stale until their TTL runs out.
- Shared (Redis): one copy for every replica, consistent evictions, at the cost of a network hop and serialisation.
HTTP caching
The cheapest request is the one that never reaches you. Cache-Control lets browsers and CDNs reuse public responses, and an ETag lets a client revalidate and receive an empty 304 Not Modified.
Common mistakes
- Caching per-user data under a key that does not include the user.
- Caching JPA entities with lazy associations instead of plain DTO records.
- Calling a
@Cacheablemethod from inside its own class, which bypasses the proxy. - Caching everything. Measure first: a 2 ms indexed query rarely needs a cache.
Example
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Optional;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
record ProductView(long id, String name, BigDecimal price) implements Serializable {}
class CachingConfig {
}
(cacheNames = "products")
public class ProductCatalog {
private final JdbcClient jdbc;
ProductCatalog(JdbcClient jdbc) {
this.jdbc = jdbc;
}
// Cache-aside: one loader per key per instance, others wait for its result.
(key = "#id", sync = true)
public Optional<ProductView> find(long id) {
return jdbc.sql("SELECT id, name, price FROM product WHERE id = :id")
.param("id", id)
.query(ProductView.class)
.optional();
}
// Invalidate rather than update. With a transaction-aware cache manager the
// evict waits for the commit; otherwise a concurrent read can re-cache the old row.
(key = "#id")
public void changePrice(long id, BigDecimal price) {
jdbc.sql("UPDATE product SET price = :price WHERE id = :id")
.param("price", price)
.param("id", id)
.update();
}
}
When to use it
- A product page endpoint reads the same 2,000 products thousands of times a minute; cache-aside with a 10 minute TTL removes most of the queries.
- A cached exchange rate expires and 300 concurrent requests stampede the rates service; sync = true lets one request reload it.
- A public blog API sends ETags so mobile clients revalidate with 304 responses instead of downloading every article again.
More examples
Per-cache TTLs and transaction-aware Redis caching
Boot builds the <code>RedisCacheManager</code> and applies this customiser, so you keep the auto-configured connection and only describe what differs per cache. A typed JSON serialiser keeps cached values readable in <code>redis-cli</code> and avoids Java serialisation, which breaks whenever the class changes between deploys. <code>transactionAware()</code> defers cache writes until the surrounding transaction commits, so a rollback never leaves the cache ahead of the database. Because null values are disabled, a cached method must never produce null: an empty <code>Optional</code> is unwrapped to null and the put fails with <code>IllegalArgumentException</code>. Either allow null values for that cache or add <code>unless = "#result == null"</code> (which cannot be combined with <code>sync = true</code>).
import java.time.Duration;
import org.springframework.boot.cache.autoconfigure.RedisCacheManagerBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
record ProductView(long id, String name, java.math.BigDecimal price) {}
@Configuration
public class RedisCacheConfig {
@Bean
RedisCacheManagerBuilderCustomizer cacheTtls() {
RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues();
return builder -> builder
// Catalogue data changes rarely: ten minutes is fine.
.withCacheConfiguration("products", base
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(SerializationPair.fromSerializer(
new JacksonJsonRedisSerializer<>(ProductView.class))))
// Exchange rates must be fresh: thirty seconds at most.
.withCacheConfiguration("fx-rates", base.entryTtl(Duration.ofSeconds(30)))
// Puts and evicts inside a transaction wait for the commit.
.transactionAware();
}
}
ETag and Cache-Control on a read endpoint
The ETag is built from the version the client cares about, so a matching <code>If-None-Match</code> header short-circuits the response: Spring sets the 304 status and returning <code>null</code> writes no body. <code>max-age</code> lets a CDN or browser reuse the response for a minute without asking at all. Only mark responses public when they contain nothing user-specific; for per-user data use <code>cachePrivate()</code>.
import java.time.Duration;
import java.time.Instant;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
record Article(long id, String title, String body, Instant updatedAt) {}
interface ArticleReader {
Article load(long id);
}
@RestController
public class ArticleController {
private final ArticleReader articles;
ArticleController(ArticleReader articles) {
this.articles = articles;
}
@GetMapping("/api/articles/{id}")
ResponseEntity<Article> get(@PathVariable long id, WebRequest request) {
Article article = articles.load(id);
String etag = "\"" + article.id() + "-" + article.updatedAt().toEpochMilli() + "\"";
// Client already has this version: answer 304 with no body.
if (request.checkNotModified(etag)) {
return null;
}
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(1)).cachePublic())
.eTag(etag)
.body(article);
}
}
A local Caffeine cache for small, hot lookups
With <code>com.github.ben-manes.caffeine:caffeine</code> on the classpath, these three lines give every replica an in-memory cache bounded by size and age. It suits small reference data that is read on nearly every request and tolerates a few minutes of staleness. Always set <code>maximumSize</code>: an unbounded local cache is a memory leak with a delay.
spring.cache.type=caffeine
spring.cache.cache-names=countries,feature-flags
spring.cache.caffeine.spec=maximumSize=5000,expireAfterWrite=5m
Discussion