advanced~2h

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 →

Practice quiz

Next Step

Continue to Introduction to Microservices← Back to all Spring Boot chapters