Pular para conteúdo

b. Cache-Aside

Cache-aside (também chamado de lazy loading) é o padrão de cache mais comum: a aplicação conversa tanto com o cache quanto com o banco, e o cache só contém o que alguém pediu recentemente.

Padrão Quem carrega o cache Escritas Usado aqui?
Cache-aside a aplicação, em um miss vão para o banco; a aplicação remove/atualiza a chave sim
Read-through o próprio cache (um loader plugado nele) — não
Write-through — a aplicação escreve no cache, que escreve no banco de forma síncrona não
Write-behind — o cache confirma e escreve no banco depois (risco de perda) não

1. Redis no Compose

Adicione um serviço redis e dê às réplicas do catalog as configurações do cache (o arquivo inteiro, para referência):

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:
Configuração Por quê
--maxmemory 256mb --maxmemory-policy allkeys-lru um cache precisa ser limitado; quando cheio, o Redis remove as chaves usadas menos recentemente em vez de falhar as escritas
--save "" --appendonly no sem persistência: um cache sempre pode ser reconstruído a partir do banco (e um reinício é uma partida a frio — meça!)
depends_on: redis: condition: service_healthy sobe as réplicas depois que o Redis responde ao PING (mas elas também sobrevivem sem ele, veja abaixo)
variáveis CACHE_* ligam/desligam o cache, mudam TTLs e a proteção contra stampede sem reconstruir

2. Dependências e configuração

Adicione o starter do Redis ao services/catalog/pom.xml (ele traz o Lettuce, um cliente Redis não bloqueante, e o 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)

Os timeouts de 200 ms importam: o timeout padrão de comando do Lettuce é de 60 segundos. Se o Redis travar, toda requisição travaria junto — um cache que deveria deixar as coisas mais rápidas derrubaria o serviço inteiro.

3. O componente de cache

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();
        }
    }
}

Decisões de design, uma a uma:

  • Guarde o JSON da resposta, não objetos Java. Um hit devolve a string como está: sem banco, sem mapeamento de objetos, sem serialização. Ela também é legível com redis-cli GET.
  • Jackson 3: o Spring Boot 4 vem com o Jackson 3 (pacote tools.jackson). O bean de JSON é um JsonMapper, e as suas exceções são unchecked, então toJson não precisa de try/catch.
  • TTL com jitter (+0–10%): chaves escritas no mesmo segundo não expiram todas no mesmo segundo.
  • Falhas do Redis viram misses (catch (DataAccessException e)): o cache é uma otimização, nunca uma dependência obrigatória. Com o Redis fora do ar, o catalog fica tão lento quanto antes do lab 5, mas funciona.
  • Um contador por resultado (catalog.cache.requests{result=HIT|MISS|BYPASS}) em cada réplica, visível em /actuator/metrics/catalog.cache.requests — e no Grafana no lab 8.
  • O ramo de proteção contra stampede é explicado no passo 2; ele vem desligado por padrão para que você veja o problema primeiro.

4. O controller

Os endpoints GET agora devolvem ResponseEntity<String> — o JSON vem do cache ou é produzido em um miss — mais o header X-Cache. As escritas removem a chave do evento depois do commit no banco:

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());
    }
}

Por que remover em vez de atualizar a chave?

Duas atualizações concorrentes podem escrever no cache na ordem inversa dos commits no banco, deixando a versão mais antiga em cache até o TTL. Apagar a chave é idempotente: a próxima leitura carrega o que o banco tiver. Mesmo a remoção tem uma condição de corrida (um leitor carrega a linha antiga logo antes do commit da atualização e a escreve depois da remoção), e é por isso que toda chave também tem um TTL: o TTL é a rede de segurança de todo esquema de invalidação.

As páginas de listagem não são removidas: uma mudança em um evento pode afetar milhares de chaves de listagem (toda cidade/página que o contém). Em vez disso, elas expiram depois de CACHE_TTL_LIST (10 s) — uma escolha deliberada de defasagem limitada para a listagem em troca de simplicidade.

5. Confira

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

A segunda requisição foi atendida por outra réplica (se as duas caíram na mesma, rode de novo: o gateway alterna as réplicas) e ainda assim foi um hit: o cache é compartilhado (distribuído), não local a cada JVM. Com um cache local (Caffeine, um HashMap), cada uma das três réplicas aqueceria separadamente e poderia guardar uma versão diferente do mesmo evento.

Agora quebre de propósito:

# 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

Na entrevista

Ao acrescentar um cache, diga quatro coisas: o que fica em cache (e a chave), a estratégia de TTL e invalidação (e a defasagem que você aceita), o hit ratio esperado (e por quê — popularidade concentrada) e o que acontece quando o cache falha (volta para o banco, protegido contra um stampede). Os entrevistadores investigam as duas últimas.

Próximo: 2. Stampede.