b. Cache-Aside
Cache-aside (also called lazy loading) is the most common caching pattern: the application talks to both the cache and the database, and the cache only ever contains what somebody asked for recently.
| Pattern | Who loads the cache | Writes | Used here? |
| Cache-aside | the application, on a miss | go to the database; the app evicts/updates the key | yes |
| Read-through | the cache itself (a loader plugged into it) | β | no |
| Write-through | β | app writes to the cache, which writes to the database synchronously | no |
| Write-behind | β | cache acknowledges, writes to the database later (risk of loss) | no |
1. Redis in Compose
Add a redis service and give the catalog replicas the cache settings (the whole file, for reference):
| docker-compose.yaml |
|---|
| name: boxoffice
# settings shared by the three catalog replicas (a YAML anchor)
x-catalog: &catalog
build: ./services/catalog
image: boxoffice-catalog
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
REDIS_HOST: redis
REDIS_PORT: 6379
CACHE_ENABLED: ${CACHE_ENABLED:-true}
CACHE_TTL_EVENT: ${CACHE_TTL_EVENT:-60s}
CACHE_TTL_LIST: ${CACHE_TTL_LIST:-10s}
CACHE_STAMPEDE_PROTECTION: ${CACHE_STAMPEDE_PROTECTION:-false}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 40s
retries: 3
services:
db:
image: postgres:17
environment:
POSTGRES_DB: ${DB_NAME:-boxoffice}
POSTGRES_USER: ${DB_USER:-boxoffice}
POSTGRES_PASSWORD: ${DB_PASSWORD:-boxoffice}
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:8-alpine
# a pure cache: bounded memory, evict least-recently-used keys, no persistence
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru",
"--save", "", "--appendonly", "no"]
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
# three identical replicas; no "ports:" β only the edge is reachable from outside
catalog-1:
<<: *catalog
hostname: catalog-1
catalog-2:
<<: *catalog
hostname: catalog-2
catalog-3:
<<: *catalog
hostname: catalog-3
auth:
build: ./services/auth
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
JWT_TTL: ${JWT_TTL:-PT1H}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@boxoffice.dev}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-boxoffice-admin}
depends_on:
db:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 40s
retries: 3
gateway:
build: ./services/gateway
environment:
AUTH_URL: http://auth:8081
AUTH_JWKS_URI: http://auth:8081/auth/.well-known/jwks.json
JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
ports:
- "8080:8080" # published for experiments; clients use the edge (port 80)
depends_on:
- auth
- catalog-1
- catalog-2
- catalog-3
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 40s
retries: 3
edge:
image: nginx:1.30-alpine
ports:
- "80:80"
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- gateway
volumes:
db-data:
|
| Setting | Why |
--maxmemory 256mb --maxmemory-policy allkeys-lru | a cache must be bounded; when full, Redis evicts the least recently used keys instead of failing writes |
--save "" --appendonly no | no persistence: a cache can always be rebuilt from the database (and a restart is a cold start β measure it!) |
depends_on: redis: condition: service_healthy | start the replicas after Redis answers PING (but they also survive without it, see below) |
CACHE_* variables | turn the cache on/off, change TTLs and stampede protection without rebuilding |
2. Dependencies and configuration
Add the Redis starter to services/catalog/pom.xml (it brings Lettuce, a non-blocking Redis client, and StringRedisTemplate):
| services/catalog/pom.xml |
|---|
| <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>boxoffice</groupId>
<artifactId>catalog</artifactId>
<version>1.0.0</version>
<name>catalog</name>
<description>BoxOffice catalog: events and venues</description>
<properties>
<java.version>21</java.version>
<springdoc.version>3.1.1</springdoc.version>
</properties>
<dependencies>
<!-- REST API (Spring MVC on Tomcat) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Bean Validation (@NotBlank, @Positive, ...) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- health, metrics -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- schema migrations (Boot 4: the starter brings the auto-configuration) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Redis client (Lettuce) + StringRedisTemplate: the cache of lab 5 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- OpenAPI document + Swagger UI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
</dependencies>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
|
| services/catalog/src/main/resources/application.yaml |
|---|
| server:
port: 8082
tomcat:
mbeanregistry:
enabled: true # exposes tomcat.threads.* metrics (Little's Law in lab 2)
spring:
application:
name: catalog
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:boxoffice}
username: ${DB_USER:boxoffice}
password: ${DB_PASSWORD:boxoffice}
hikari:
maximum-pool-size: ${DB_POOL_SIZE:10}
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
timeout: 200ms # a slow cache must not make the service slow
connect-timeout: 200ms
flyway:
schemas: catalog # Flyway creates the schema and owns it
jpa:
open-in-view: false
hibernate:
ddl-auto: validate # Flyway changes the schema; Hibernate only checks it
mvc:
problemdetails:
enabled: true # errors as RFC 9457 application/problem+json
catalog:
cache:
enabled: ${CACHE_ENABLED:true} # false: always go to the database
event-ttl: ${CACHE_TTL_EVENT:60s} # one event (invalidated on update)
list-ttl: ${CACHE_TTL_LIST:10s} # listing pages (expire only)
stampede-protection: ${CACHE_STAMPEDE_PROTECTION:false}
management:
endpoints:
web:
exposure:
include: health, info, metrics
endpoint:
health:
probes:
enabled: true # /actuator/health/liveness and /readiness
springdoc:
api-docs:
path: /catalog/openapi # OpenAPI document under the service prefix (Swagger UI: /swagger-ui.html)
|
The 200 ms timeouts matter: Lettuce's default command timeout is 60 seconds. If Redis hangs, every request would hang with it β a cache that is supposed to make things faster would take the whole service down.
3. The cache component
| services/catalog/src/main/java/boxoffice/catalog/EventCache.java |
|---|
| package boxoffice.catalog;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import tools.jackson.databind.json.JsonMapper;
/**
* Cache-aside in Redis. The application asks the cache first; on a miss it loads from
* the database and writes the JSON into the cache with a TTL. Values are stored as the
* exact JSON returned to clients, so a hit costs no database access and no serialization.
*/
@Component
public class EventCache {
private static final Logger log = LoggerFactory.getLogger(EventCache.class);
/** The JSON body and what happened: HIT, MISS or BYPASS (cache disabled). */
public record Result(String json, String outcome) { }
private final StringRedisTemplate redis;
private final JsonMapper mapper; // Jackson 3 (Spring Boot 4)
private final MeterRegistry metrics;
private final boolean enabled;
private final boolean stampedeProtection;
private final Duration eventTtl;
private final Duration listTtl;
public EventCache(StringRedisTemplate redis, JsonMapper mapper, MeterRegistry metrics,
@Value("${catalog.cache.enabled}") boolean enabled,
@Value("${catalog.cache.stampede-protection}") boolean stampedeProtection,
@Value("${catalog.cache.event-ttl}") Duration eventTtl,
@Value("${catalog.cache.list-ttl}") Duration listTtl) {
this.redis = redis;
this.mapper = mapper;
this.metrics = metrics;
this.enabled = enabled;
this.stampedeProtection = stampedeProtection;
this.eventTtl = eventTtl;
this.listTtl = listTtl;
}
public Duration eventTtl() { return eventTtl; }
public Duration listTtl() { return listTtl; }
public Result get(String key, Duration ttl, Supplier<?> loader) {
if (!enabled) {
return count(new Result(toJson(loader.get()), "BYPASS"));
}
var cached = read(key);
if (cached != null) {
return count(new Result(cached, "HIT"));
}
if (stampedeProtection) {
var lockKey = key + ":lock";
var token = UUID.randomUUID().toString();
if (tryLock(lockKey, token)) {
try {
cached = read(key); // someone may have filled it meanwhile
if (cached != null) {
return count(new Result(cached, "HIT"));
}
return count(load(key, ttl, loader));
} finally {
unlock(lockKey, token);
}
}
// another request is already loading this key: wait for it instead of
// sending one more identical query to the database
for (int i = 0; i < 20; i++) {
sleep(Duration.ofMillis(25));
cached = read(key);
if (cached != null) {
return count(new Result(cached, "HIT"));
}
}
}
return count(load(key, ttl, loader));
}
public void evict(String key) {
try {
redis.delete(key);
} catch (DataAccessException e) {
log.warn("cache evict failed for {}: {}", key, e.getMessage());
}
}
// ---- internals --------------------------------------------------------
private Result load(String key, Duration ttl, Supplier<?> loader) {
var json = toJson(loader.get());
try {
redis.opsForValue().set(key, json, withJitter(ttl));
} catch (DataAccessException e) {
log.warn("cache write failed for {}: {}", key, e.getMessage());
}
return new Result(json, "MISS");
}
private String read(String key) {
try {
return redis.opsForValue().get(key);
} catch (DataAccessException e) {
// Redis down or slow: the cache is an optimization, not a dependency
log.warn("cache read failed for {}: {}", key, e.getMessage());
return null;
}
}
private boolean tryLock(String lockKey, String token) {
try {
// SET lockKey token NX PX 2000: only one request in the whole cluster gets it
return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(lockKey, token, Duration.ofSeconds(2)));
} catch (DataAccessException e) {
return true; // no Redis, no lock: just load
}
}
private void unlock(String lockKey, String token) {
try {
// delete only our own lock (it may have expired and been taken by someone else);
// get + delete is not atomic: production code uses a Lua script for this
if (token.equals(redis.opsForValue().get(lockKey))) {
redis.delete(lockKey);
}
} catch (DataAccessException e) {
log.debug("unlock failed: {}", e.getMessage());
}
}
/** TTL plus up to 10% random extra, so that keys written together do not expire together. */
private static Duration withJitter(Duration ttl) {
long extra = (long) (ttl.toMillis() * 0.10 * ThreadLocalRandom.current().nextDouble());
return ttl.plusMillis(extra);
}
private Result count(Result result) {
metrics.counter("catalog.cache.requests", "result", result.outcome()).increment();
return result;
}
private String toJson(Object value) {
return mapper.writeValueAsString(value); // Jackson 3 exceptions are unchecked
}
private static void sleep(Duration d) {
try {
Thread.sleep(d);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
|
Design decisions, one by one:
- Store the response JSON, not Java objects. A hit returns the string as-is: no database, no object mapping, no serialization. It is also readable with
redis-cli GET. - Jackson 3: Spring Boot 4 ships Jackson 3 (package
tools.jackson). The JSON bean is a JsonMapper, and its exceptions are unchecked, so toJson needs no try/catch. - TTL with jitter (
+0β10%): keys written in the same second do not all expire in the same second. - Redis failures become misses (
catch (DataAccessException e)): the cache is an optimization, never a hard dependency. With Redis down, catalog is as slow as before lab 5, but it works. - A counter per outcome (
catalog.cache.requests{result=HIT|MISS|BYPASS}) on every replica, visible at /actuator/metrics/catalog.cache.requests β and on Grafana in lab 8. - The stampede protection branch is explained in step 2; it is off by default so that you can see the problem first.
4. The controller
The GET endpoints now return ResponseEntity<String> β the JSON comes from the cache or is produced on a miss β plus the X-Cache header. Writes evict the event's key after the database commit:
| services/catalog/src/main/java/boxoffice/catalog/EventController.java |
|---|
| package boxoffice.catalog;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/catalog/events")
public class EventController {
private final CatalogService service;
private final EventCache cache;
public EventController(CatalogService service, EventCache cache) {
this.service = service;
this.cache = cache;
}
@GetMapping
public ResponseEntity<String> list(
@RequestParam(required = false) String city,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(50) int size) {
var key = "catalog:events:city=" + (city == null ? "" : city) + ":page=" + page + ":size=" + size;
return json(cache.get(key, cache.listTtl(), () -> service.upcomingEvents(city, page, size)));
}
@GetMapping("/{id}")
public ResponseEntity<String> get(@PathVariable Long id) {
return json(cache.get(eventKey(id), cache.eventTtl(), () -> service.findEvent(id)));
}
@PostMapping
public ResponseEntity<EventOut> create(@Valid @RequestBody EventIn in) {
var out = service.createEvent(in);
return ResponseEntity.created(URI.create("/catalog/events/" + out.id())).body(out);
}
@PutMapping("/{id}")
public EventOut update(@PathVariable Long id, @Valid @RequestBody EventIn in) {
var out = service.updateEvent(id, in);
cache.evict(eventKey(id)); // the next read loads the new version
return out;
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
service.deleteEvent(id);
cache.evict(eventKey(id));
}
private static String eventKey(Long id) {
return "catalog:event:" + id;
}
private static ResponseEntity<String> json(EventCache.Result result) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.header("X-Cache", result.outcome())
.body(result.json());
}
}
|
Why evict instead of updating the key?
Two concurrent updates can write the cache in the opposite order of the database commits, leaving the older version cached until the TTL. Deleting the key is idempotent: the next read loads whatever the database has. Even eviction has a race (a reader loads the old row just before the update commits and writes it after the eviction), which is why every key also has a TTL: TTL is the safety net of every invalidation scheme.
Listing pages are not evicted: a change to one event can affect thousands of listing keys (every city/page that contains it). They expire after CACHE_TTL_LIST (10 s) instead β a deliberate choice of bounded staleness for the listing in exchange for simplicity.
5. Check it
docker compose up --build -d
curl -s -o /dev/null -D - localhost/catalog/events/42 | grep -iE "x-cache|x-instance"
curl -s -o /dev/null -D - localhost/catalog/events/42 | grep -iE "x-cache|x-instance"
docker compose exec redis redis-cli GET catalog:event:42
docker compose exec redis redis-cli TTL catalog:event:42
curl -s -o /dev/null -D - localhost/catalog/events/42 | grep -iE "x-cache|x-instance"X-Instance: catalog-2
X-Cache: MISScurl -s -o /dev/null -D - localhost/catalog/events/42 | grep -iE "x-cache|x-instance"X-Instance: catalog-3
X-Cache: HITdocker compose exec redis redis-cli TTL catalog:event:42(integer) 62
The second request was served by another replica (if both landed on the same one, run it again: the gateway alternates the replicas) and was still a hit: the cache is shared (distributed), not local to each JVM. With a local cache (Caffeine, a HashMap), each of the three replicas would warm up separately and could hold a different version of the same event.
Now break it on purpose:
# invalidation: update event 42 as admin, then read it
ADMIN=$(curl -s -X POST localhost/auth/login -H 'Content-Type: application/json' \
-d '{"email":"admin@boxoffice.dev","password":"boxoffice-admin"}' | jq -r .accessToken)
curl -s -X PUT localhost/catalog/events/42 -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' \
-d '{"title":"Festival #42 (new lineup)","category":"FESTIVAL","venueId":45,"startsAt":"2027-03-03T14:00:00-03:00","priceFrom":175}' > /dev/null
curl -s -D - localhost/catalog/events/42 | grep -iE "x-cache|title"
# resilience: no Redis, still working (slower, all MISS)
docker compose stop redis
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" -D - localhost/catalog/events/42 | grep -iE "x-cache|^200"
docker compose start redis
In the interview
When you add a cache, say four things: what is cached (and the key), the TTL and invalidation strategy (and the staleness you accept), the expected hit ratio (and why β popularity skew), and what happens when the cache fails (fall back to the database, protected against a stampede). Interviewers probe the last two.
Next: 2. Stampede.