Caching with Redis
Your REST API works and is secured. Now it needs to survive real traffic without hammering Postgres for the same query a thousand times a second.
Learning objectives
- Beginner: @Cacheable on a single, read-heavy, rarely-changing lookup endpoint with a conservative TTL.
- Intermediate: Matching @CacheEvict on every write path that touches cached data, verified by an integration test that writes then re-reads and confirms fresh data.
- Advanced: A deliberate TTL + eviction strategy tuned per data type based on actual staleness tolerance (e.g. seconds for pricing data, hours for rarely-changing catalog metadata), monitored via cache hit-rate metrics.
◆ The problem
A simple in-memory HashMap cache inside your Spring Boot process works for exactly one instance. The moment you run more than one instance behind a load balancer (which Module 18's microservices architecture assumes by default), each instance has its own separate, inconsistent cache — a write on instance A doesn't invalidate stale data cached on instance B.
Redis is an external, shared, in-memory data store every instance of your application connects to — one consistent cache visible to all instances, independent of how many application processes are actually running.
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>
spring.data.redis.host=localhost spring.data.redis.port=6379 spring.cache.type=redis
@Service @EnableCaching public class BookService { @Cacheable(value = "books", key = "#id") public BookResponse findById(Long id) { // only runs on a cache MISS — Redis is checked first automatically Book book = bookRepository.findById(id).orElseThrow(() -> new BookNotFoundException(id)); return bookMapper.toResponse(book); } }
◆ Under the hood — cache-aside, made automatic
@Cacheable wraps the method in a Spring AOP proxy: on a call, it first checks Redis for a value under the computed key (#id here); on a hit, your method body never runs at all; on a miss, the method runs, and its return value is automatically written to Redis before being returned to the caller. This is the cache-aside pattern, but you never write the "check cache, then fall back to DB, then populate cache" logic yourself — the annotation generates it.
💻 Code example
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>
@Bean public RedisCacheConfiguration cacheConfiguration() { return RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)); // every cached entry expires automatically after 10 min }
A TTL bounds how long stale data can persist in the cache even if you forget to invalidate it explicitly (§17.4) — a safety net, not a substitute for deliberate invalidation on writes.
▲ Pitfall
Setting no TTL at all means a cached value lives until explicitly evicted or Redis runs out of memory and starts evicting under its own configured eviction policy (which may not be what you'd choose deliberately per-key). Always set an explicit, deliberate TTL appropriate to how fresh that specific data actually needs to be.
💻 Code example
@Bean public RedisCacheConfiguration cacheConfiguration() { return RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)); // every cached entry expires automatically after 10 min }
◆ The problem
A TTL alone means an update to a book can leave the old, cached version being served for up to the full TTL window afterward — for many use cases, this staleness window needs to be closed immediately on write, not just bounded by time.
@CacheEvict(value = "books", key = "#id") public BookResponse update(Long id, UpdateBookRequest request) { Book book = bookRepository.findById(id).orElseThrow(() -> new BookNotFoundException(id)); book.setTitle(request.title()); return bookMapper.toResponse(bookRepository.save(book)); // the cached entry for this id is evicted the moment this method completes }
The classic hard problem this exposes: "there are only two hard things in computer science: cache invalidation and naming things" is a real, not just a joking, engineering concern — every write path that can affect cached data needs a corresponding, correctly-scoped @CacheEvict, and missing even one reintroduces silent staleness.
✓ Quick recap
Why does a multi-instance deployment need Redis rather than a plain in-memory HashMap cache? Each instance's in-memory cache would be separate and inconsistent — Redis provides one shared cache all instances see the same view of. What does @Cacheable actually generate for you? The full check-cache → fall back to method body on miss → populate cache logic, via an AOP proxy. Is a TTL a substitute for explicit cache invalidation on writes? No — it's a safety net bounding staleness; @CacheEvict on write paths is still needed to avoid serving stale data within the TTL window.
💻 Code example
@CacheEvict(value = "books", key = "#id") public BookResponse update(Long id, UpdateBookRequest request) { Book book = bookRepository.findById(id).orElseThrow(() -> new BookNotFoundException(id)); book.setTitle(request.title()); return bookMapper.toResponse(bookRepository.save(book)); // the cached entry for this id is evicted the moment this method completes }
Want a visual for this concept?
Generate a diagram tailored to “Caching with Redis” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →