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: 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
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.