Skip to content

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: MISS
curl -s -o /dev/null -D - localhost/catalog/events/42 | grep -iE "x-cache|x-instance"X-Instance: catalog-3
X-Cache: HIT
docker 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.