Sessão 1Paralelismo & Caching · 3h

Fazer mais coisas ao mesmo tempo e não repetir trabalho

Hoje vais perceber porque é que uma aplicação fica lenta, como pôr o computador a trabalhar em várias tarefas em simultâneo e como guardar resultados já calculados para responder num piscar de olhos. Tudo explicado com analogias do dia a dia e aplicado ao Spring Boot.

  • Explicar o que é uma thread e porque existe paralelismo
  • Usar CompletableFuture e @Async no Spring
  • Configurar pools de threads e virtual threads
  • Evitar race conditions em beans Spring
  • Aplicar cache com @Cacheable, Caffeine e Redis
  • Reconhecer os riscos de uma cache mal feita

Porque é que as aplicações ficam lentas?

Quando abres uma loja online e clicas num produto, o teu pedido viaja até um servidor (um computador noutro sítio), que vai buscar dados a uma base de dados, talvez pergunte o preço a outro serviço, calcula os portes e só depois te responde. Cada um destes passos demora tempo.

A maior parte desse tempo não é gasto a calcular. É gasto à espera: à espera da base de dados, da rede, de outro serviço. O processador está quase parado, de braços cruzados.

O restaurante com um só empregado

Imagina um restaurante com um único empregado. Ele leva o pedido da mesa 1 à cozinha e fica ali, parado, até o prato estar pronto. Só depois atende a mesa 2. As mesas esperam imenso, mas o empregado passou quase todo o tempo sem fazer nada. Há duas formas de melhorar: contratar mais empregados (paralelismo) ou ter pratos já preparados para os pedidos mais comuns (cache). É exatamente isso que vais aprender hoje.

O que vais ver nesta sessão

0:00–0:40Módulo 1.1 · Fundamentos de concorrência em Java
0:40–1:30Módulo 1.2 · Paralelismo no Spring (+ laboratório)
1:30–1:40Intervalo ☕
1:40–2:40Módulo 1.3 · Estratégias de caching (+ laboratório)
2:40–3:00Módulo 1.4 · Problemas e boas práticas

Três palavras que vais ouvir muito

LatênciaQuanto tempo demoraO tempo entre fazeres um pedido e receberes a resposta. Mede-se em milissegundos (ms). 1000 ms = 1 segundo.
ThroughputQuanto se faz por segundoQuantos pedidos o servidor consegue responder por segundo. Um restaurante que serve 100 refeições por hora tem mais throughput do que um que serve 20.
ConcorrênciaVárias coisas em cursoVárias tarefas a decorrer no mesmo período de tempo, mesmo que se vão alternando.

Threads: os trabalhadores do teu programa

Um programa Java corre dentro de um processo (a JVM). Dentro desse processo, quem executa realmente as instruções são as threads (em português, «fios de execução»). Cada thread segue o código linha a linha, de forma independente das outras.

Quando corres um main, já existe uma thread chamada main. Se criares mais threads, o teu programa passa a fazer várias coisas ao mesmo tempo.

Threads são empregados

O processo é o restaurante; as threads são os empregados. Todos partilham a mesma cozinha (a memória). Isto é ótimo, porque todos veem os mesmos dados, mas também é perigoso: dois empregados podem tentar mexer na mesma panela ao mesmo tempo.

Concorrência vs. paralelismo

ConceitoO que éAnalogia
ConcorrênciaVárias tarefas em curso, que se vão alternando no mesmo processador.Um cozinheiro que mexe a sopa, vira o bife e volta à sopa.
ParalelismoVárias tarefas a correr literalmente ao mesmo tempo, em processadores (núcleos) diferentes.Três cozinheiros, cada um no seu fogão.

Criar uma thread à mão

O () -> { … } é uma lambda: um bloco de código que se passa como se fosse um valor. Aqui diz à thread «quando arrancares, faz isto». O start() cria a thread; o join() espera que ela acabe.

A ordem não é garantida

Corre este programa várias vezes e a ordem das mensagens pode mudar. Quem decide que thread corre em cada momento é o sistema operativo, não tu. Esta imprevisibilidade é a principal fonte de dificuldade na programação concorrente.

Vê a diferença: um a um ou todos de uma vez

Imagina que o teu serviço tem de pedir dados a três sistemas: o catálogo, o stock e os preços. Muda os tempos e vê o que acontece quando os pedidos são feitos em sequência ou em paralelo.

A regra de ouro

Em sequência, o tempo total é a soma dos tempos. Em paralelo, é o tempo da tarefa mais lenta. Só podes paralelizar tarefas que não dependem umas das outras.

Platform threads vs. virtual threads

Até ao Java 21, cada thread Java correspondia a uma thread do sistema operativo, a que chamamos platform thread. São «caras»: cada uma reserva cerca de 1 MB de memória e o sistema operativo tem de gerir a troca entre elas (context switching). Um servidor normal aguenta alguns milhares, não muito mais.

O Java 21 trouxe as virtual threads (Projeto Loom): threads leves geridas pela própria JVM. Quando uma virtual thread fica à espera (de uma base de dados, por exemplo), a JVM «tira-a» da thread real e põe lá outra a trabalhar. Podes ter milhões delas.

~1 MBPlatform threadMemória reservada por cada uma. Criar 10 000 é pesado e lento.
~1 KBVirtual threadComeça minúscula e cresce conforme precisa. Criar 1 000 000 é viável.
I/OOnde brilhamTarefas que passam o tempo à espera: HTTP, base de dados, ficheiros.
CPUOnde não ajudamCálculos pesados. Aí o limite é o número de núcleos do processador.
Empregados vs. talões de pedido

Uma platform thread é um empregado contratado a tempo inteiro: caro. Uma virtual thread é um talão de pedido: o empregado pega num talão, leva-o à cozinha, pousa-o enquanto o prato é feito e pega logo noutro talão. Poucos empregados conseguem gerir milhares de talões.

Em paralelo, esperas pela mais lenta (500 ms). Em sequência somavas tudo: 1000 ms. O paralelismo poupou metade do tempo.

Executores e CompletableFuture

Criar threads à mão com new Thread() é como contratar um empregado novo para cada cliente e despedi-lo no fim. Na prática usamos um pool de threads: um grupo fixo de threads que vão pegando em tarefas de uma fila.

ExecutorService: a agência de trabalho

FerramentaPara que serve
Executors.newFixedThreadPool(n)Pool com n threads fixas. Bom ponto de partida.
Executors.newVirtualThreadPerTaskExecutor()Uma virtual thread nova por tarefa (Java 21+). Ideal para I/O.
ForkJoinPoolDivide um problema grande em pedaços e junta os resultados. É o que está por trás dos parallel streams.

CompletableFuture: promessas que se encadeiam

Um Future simples obriga-te a ficar parado no get(). O CompletableFuture permite dizer «quando isto acabar, faz aquilo», sem bloquear. É como encomendar online: recebes um número de encomenda e só és avisado quando chega.

MétodoEm linguagem simples
supplyAsync(() -> …)Começa uma tarefa noutra thread e promete um resultado.
thenApply(r -> …)Quando o resultado chegar, transforma-o.
thenCompose(r -> …)Quando chegar, começa outra tarefa assíncrona que depende dele.
thenCombine(outro, (a,b) -> …)Junta dois resultados independentes.
allOf(f1, f2, f3)Espera que todas acabem.
exceptionally(e -> …)Se der erro, devolve um valor alternativo.
orTimeout(2, SECONDS)Desiste se demorar demasiado.

Experimenta: muda o tempo de cada serviço e simula uma falha no serviço de preços.

Parallel streams: com cuidado

lista.parallelStream().map(…) divide o trabalho por vários núcleos automaticamente. Funciona bem para cálculos pesados em listas grandes. Mas usa um pool partilhado por toda a JVM (o ForkJoinPool.commonPool()): se puseres lá chamadas à base de dados ou HTTP, bloqueias esse pool para toda a aplicação. Em código Spring de servidor, raramente é a ferramenta certa.

Quando as threads se atropelam

As threads partilham memória. Se duas mexem na mesma variável ao mesmo tempo, os resultados podem ficar errados. Estes problemas são traiçoeiros porque nem sempre acontecem: o programa funciona 99 vezes e falha à centésima.

Race condition: a corrida pela mesma variável

A instrução contador++ parece uma só, mas o computador faz três passos: ler o valor, somar 1, escrever. Se duas threads leem o mesmo valor antes de qualquer uma escrever, uma das somas perde-se.

Agora em grande escala: várias threads, cada uma a somar muitas vezes. Compara as três versões.

SoluçãoComo funcionaQuando usar
synchronizedPõe um «cadeado»: só uma thread entra no bloco de cada vez.Proteger várias operações que têm de acontecer juntas.
AtomicInteger, AtomicLongO processador faz ler-somar-escrever num só passo indivisível.Contadores e valores simples.
ConcurrentHashMapUm mapa preparado para muitas threads.Sempre que um mapa é partilhado.
ImutabilidadeObjetos que nunca mudam (ex.: record) não podem ser corrompidos.Sempre que possível. É a solução mais simples.

Deadlock e starvation

Deadlock 🔒🔒A thread 1 tem o cadeado A e espera pelo B. A thread 2 tem o B e espera pelo A. Ficam presas para sempre. Como dois carros num cruzamento estreito, cada um à espera que o outro recue.
Starvation 🍽️Uma thread nunca chega a ter vez porque outras passam sempre à frente. Como o cliente que nunca é atendido porque chegam sempre clientes VIP.
Livelock 🔄Duas threads cedem a vez uma à outra eternamente, como duas pessoas num corredor que se desviam sempre para o mesmo lado.
Como evitar deadlocks

Adquire os cadeados sempre pela mesma ordem em todo o código, mantém os blocos synchronized pequenos e prefere ferramentas de alto nível (ConcurrentHashMap, CompletableFuture) em vez de cadeados manuais.

O perigo escondido no Spring: beans singleton

Por defeito, o Spring cria uma única instância de cada @Service, @Component ou @RestController (âmbito singleton). Essa instância é usada por todos os pedidos ao mesmo tempo, cada um na sua thread. Logo, um campo dentro de um serviço é partilhado por todos os utilizadores!

Se a Ana e o Bruno adicionarem itens ao mesmo tempo, o item da Ana pode acabar no carrinho do Bruno.

Variáveis locais vivem na pilha de cada thread e nunca são partilhadas. Regra prática: serviços Spring não devem ter campos que mudam; só dependências injetadas (repositórios, outros serviços) e constantes.

O controller é singleton: todos os pedidos partilham o mesmo campo. pedidosHoje++ não é atómico, por isso perdem-se somas. Usa AtomicInteger ou, melhor, uma métrica do Micrometer (sessão 2).

Execução assíncrona com @Async

Às vezes não precisas da resposta de uma tarefa para responder ao utilizador. Exemplo: depois de uma encomenda, enviar o email de confirmação. O cliente não deve ficar à espera do servidor de email. Com @Async, o Spring corre esse método noutra thread e o pedido continua.

Passo 1: ligar a funcionalidade

Passo 2: marcar os métodos

Repara nos nomes entre parênteses retos: o pedido HTTP foi tratado pela thread http-nio-8080-exec-3 do Tomcat; o email foi enviado pela thread task-1, do executor do Spring. O cliente recebeu a resposta 2 segundos antes de o email sair.

Como funciona por dentro: o proxy

O Spring não altera a tua classe. Cria um proxy: um objeto «intermediário» que se põe à frente do teu serviço. Quando chamas o método, quem recebe a chamada é o proxy, que a entrega a outra thread.

Controllerchama enviarEmail()
→
Proxy do Springintercepta e submete a tarefa ao executor
→outra thread
NotificacaoServiceo teu código corre na thread task-1
A armadilha da auto-invocação

Se um método da mesma classe chamar o método @Async com this.enviarEmail(), a chamada não passa pelo proxy. Resultado: corre na mesma thread, de forma síncrona, sem qualquer aviso. O mesmo acontece com @Transactional e @Cacheable. Solução: põe o método assíncrono noutro bean e injeta-o.

E se o método assíncrono falhar?

Num método @Async que devolve void, a exceção não volta a quem chamou (já seguiu caminho). Por defeito só aparece no log. Para a tratares, regista um AsyncUncaughtExceptionHandler. Nos métodos que devolvem CompletableFuture, a exceção fica dentro da promessa e tratas com exceptionally.

As anotações do Spring funcionam através de proxies. Uma chamada interna (this.metodo()) vai diretamente ao objeto real e o @Async é ignorado em silêncio.

Configurar o pool: quantos trabalhadores e quanta fila?

O executor que corre os métodos @Async é um ThreadPoolTaskExecutor. Tem quatro parâmetros que tens mesmo de perceber, porque decidem como a tua aplicação se comporta sob pressão.

ParâmetroSignificadoNo restaurante
corePoolSizeThreads sempre disponíveis.Empregados do quadro.
queueCapacityQuantas tarefas podem esperar na fila.Lugares na sala de espera.
maxPoolSizeMáximo de threads, criadas só quando a fila enche.Empregados temporários chamados em dias de enchente.
Política de rejeiçãoO que fazer quando tudo está cheio.O que dizer ao cliente quando não cabe mais ninguém.
Uma surpresa que apanha toda a gente

As threads extra (acima do corePoolSize) só são criadas quando a fila está cheia. Se a fila for enorme (ou ilimitada), nunca passas do core. Experimenta no simulador abaixo.

A configuração no Spring Boot

É a forma mais simples: o Spring Boot cria o executor por ti com estes valores.

Útil quando queres pools separados por tipo de trabalho. Assim, se o servidor de email ficar lento, não bloqueia a geração de faturas. Chama-se a isto bulkhead (como os compartimentos estanques de um navio).

Políticas de rejeição

PolíticaO que fazNa prática
AbortPolicy (defeito)Lança TaskRejectedException.Falha rápido e às claras.
CallerRunsPolicyQuem submeteu corre a tarefa ele próprio.Abranda naturalmente quem está a produzir trabalho a mais (backpressure).
DiscardPolicyDeita fora a tarefa em silêncio.Perigoso: perdes trabalho sem saber.
DiscardOldestPolicyDescarta a tarefa mais antiga da fila.Só para dados em que só o mais recente interessa.

Não perder o contexto: TaskDecorator

Algumas informações vivem «agarradas» à thread: o utilizador autenticado (SecurityContext), o identificador do pedido nos logs (MDC), o trace. Quando o trabalho salta para outra thread, essa informação fica para trás. Um TaskDecorator copia-a.

Atalho para segurança e tracing

Para o utilizador autenticado existe o DelegatingSecurityContextAsyncTaskExecutor. Para o tracing (sessão 2), o Spring Boot 3 tem ContextPropagatingTaskDecorator, que propaga tudo o que o Micrometer conhece.

Virtual threads, agendamento e WebFlux

Ligar virtual threads no Spring Boot

Com Java 21 e Spring Boot 3.2 ou superior, basta uma linha:

Com isto, o Tomcat trata cada pedido HTTP numa virtual thread, e os @Async e @Scheduled também passam a usá-las. Não precisas de mudar mais nada no código.

Simula um serviço que recebe muitos pedidos, cada um à espera 1 segundo de outro sistema. Compara um pool de 200 platform threads (o defeito do Tomcat) com virtual threads.

Pinning: quando a virtual thread fica «presa»

No Java 21 a 23, se uma virtual thread fizer uma espera dentro de um bloco synchronized, fica pinned (presa) à thread real, e perdes a vantagem. Isto foi corrigido no Java 24. Em Java 21, prefere ReentrantLock em código com esperas e confirma com -Djdk.tracePinnedThreads=full. Outro cuidado: com milhares de pedidos em simultâneo, o limite passa a ser a base de dados. As virtual threads não criam ligações à base de dados do nada (vês isto na sessão 2).

Tarefas agendadas com @Scheduled

Por defeito, todas as tarefas agendadas partilham uma única thread. Se uma demorar, as outras atrasam. Aumenta com spring.task.scheduling.pool.size=4 ou ativa as virtual threads.

Paralelismo em processamento em lote (Spring Batch)

Multi-threaded stepUm passo que lê, processa e escreve blocos (chunks) em várias threads. Simples, mas o leitor tem de ser thread-safe.
PartitioningDivide os dados em partes (ex.: clientes A–M e N–Z) e cada parte é processada por um trabalhador independente.
Parallel stepsPassos diferentes e independentes correm ao mesmo tempo (ex.: importar clientes e importar produtos).

Spring MVC com virtual threads ou Spring WebFlux?

O Spring WebFlux é a alternativa reativa: em vez de uma thread por pedido, poucas threads tratam tudo com eventos, e o código usa os tipos Mono e Flux.

Spring MVC + virtual threadsSpring WebFlux
Estilo de códigoNormal, linha a linha, fácil de ler e depurar.Encadeamento de operadores; curva de aprendizagem maior.
BibliotecasFunciona com JPA, JDBC e tudo o que já existe.Exige drivers reativos (R2DBC, WebClient…).
Streaming e backpressureLimitado.Excelente: fluxos contínuos, SSE, WebSockets.
RecomendaçãoA escolha por defeito para APIs novas em 2026.Quando precisas de streaming ou já tens uma base reativa.

Laboratório 1: paralelizar chamadas a serviços externos

Objetivo: a página de detalhe de uma encomenda chama três serviços, um a seguir ao outro. Vais torná-la mais rápida com CompletableFuture e depois comparar com virtual threads.

Ponto de partida (lento)

Repara que clientes e fidelizacao precisam do clienteId, que vem da encomenda. A transportadora só precisa do id. Há dependências, por isso não se pode paralelizar tudo às cegas.

Versão otimizada

Desafios laboratório

  1. Mede os tempos com um StopWatch do Spring e regista-os no log.
  2. Troca o executor por Executors.newVirtualThreadPerTaskExecutor(). Os tempos mudam com poucos pedidos? E com 500 pedidos em simultâneo?
  3. Simula a transportadora em baixo (lança exceção). Garante que a página continua a responder com exceptionally.

Cache: não fazer duas vezes o mesmo trabalho

Uma cache é um local de armazenamento rápido onde guardas o resultado de uma operação lenta, para o reutilizar da próxima vez. Na segunda vez, em vez de ires à base de dados (lento), vais à memória (muito rápido).

O post-it no monitor

Ligas para as informações para saber o número do canalizador. Demora 3 minutos. Escreves o número num post-it e colas no monitor. Da próxima vez, demoras 2 segundos. O post-it é a cache. Mas atenção: se o canalizador mudar de número, o post-it está desatualizado. Este é o grande desafio das caches.

~0,0001 msMemória (RAM)Cache local, dentro da tua aplicação.
~0,5 msRede localCache distribuída como o Redis.
5–50 msBase de dadosUma consulta típica.
100–2000 msServiço externoUma API de terceiros pela internet.

Vocabulário essencial

Hit
O valor estava na cache. Resposta rápida. 🎯
Miss
Não estava. Tem de se ir à fonte (lenta) e depois guardar na cache.
Hit ratio
Percentagem de hits. Uma boa cache tem tipicamente mais de 80%.
Evicção
Remover entradas da cache, porque está cheia ou porque o valor mudou.
TTL
Time To Live: tempo máximo que uma entrada pode viver na cache.

Padrões de utilização

PadrãoComo funcionaVantagem / risco
Cache-aside ⭐A aplicação pergunta à cache; se não houver, vai à base de dados e guarda na cache.Simples e o mais usado. É o que o @Cacheable faz.
Read-throughA aplicação só fala com a cache; a cache vai à base de dados sozinha.Código mais limpo; precisa de uma cache que saiba carregar dados (ex.: LoadingCache do Caffeine).
Write-throughAo gravar, escreve na cache e na base de dados ao mesmo tempo.Cache sempre atualizada; escritas mais lentas.
Write-behindEscreve na cache e a base de dados é atualizada mais tarde, em lote.Escritas rapidíssimas; risco de perder dados se a cache cair.

Quando a cache enche: políticas de evicção

LRULeast Recently Used. Sai quem não é usado há mais tempo. Como arrumar o roupeiro e tirar a roupa que não vestes há mais tempo.
LFULeast Frequently Used. Sai quem foi usado menos vezes no total.
TTLCada entrada tem prazo de validade, como um iogurte. Passado o prazo, sai.
W-TinyLFUO algoritmo do Caffeine: combina frequência e recência. Excelente hit ratio na prática.

Experimenta uma cache por dentro

Clica nos produtos para os pedir. O primeiro pedido de cada um é um miss (800 ms); os seguintes são hits (1 ms), até a entrada sair da cache. Muda a capacidade e a política.

Cache local ou distribuída?

Local (Caffeine)Distribuída (Redis)
Onde viveNa memória de cada instância da aplicação.Num servidor à parte, partilhado por todas as instâncias.
VelocidadeNanossegundos.Sub-milissegundo (há uma ida à rede).
ConsistênciaCada instância tem a sua cópia: podem divergir.Todas veem o mesmo valor.
Sobrevive a reinícios?Não.Sim.
Ideal paraDados pequenos, muito lidos, que mudam pouco (configurações, catálogos).Sessões, dados partilhados, aplicações com várias instâncias.

Cache no Spring em quatro anotações

O Spring tem uma abstração de cache: tu marcas os métodos com anotações e ele trata do resto. O melhor é que o código não depende da tecnologia: podes trocar Caffeine por Redis sem mudar uma linha do serviço.

Passo 1: dependência e ativação

Passo 2: as anotações

AnotaçãoO que faz
@Cacheable("produtos")Antes de correr o método, procura na cache. Se existir, devolve logo e o método nem corre. Se não, corre e guarda o resultado.
@CachePut("produtos")Corre sempre o método e atualiza a cache com o resultado. Para gravações.
@CacheEvict("produtos")Remove da cache. Com allEntries = true limpa tudo.
@Caching(…)Junta várias das anteriores no mesmo método.

Testa a API. Repara no tempo de resposta e nas mensagens do servidor por baixo.

Controlar as chaves e as condições

A chave identifica a entrada na cache. Por defeito usa os parâmetros do método. Com key, condition e unless escreves regras em SpEL (Spring Expression Language), uma mini-linguagem que começa com #.

AtributoAvaliadoSignificado
conditionAntes de correr o métodoSe for falso, ignora a cache por completo.
unlessDepois de correr o métodoSe for verdadeiro, não guarda o resultado. Pode usar #result.
Os objetos em cache devem ser imutáveis

Numa cache local, o objeto devolvido é o mesmo que está guardado. Se alguém fizer produto.setPreco(0) no objeto que recebeu, alterou a cache para todos! Usa record ou DTOs imutáveis.

@CachePut corre sempre o método e guarda o resultado. @Cacheable não correria o método se o produto já estivesse em cache. @CacheEvict também seria válido, mas obrigava a ir à base de dados no próximo pedido.

Escolher a tecnologia: Caffeine, Redis ou as duas

Sem configuração, o Spring usa um simples ConcurrentHashMap, que nunca expira e nunca liberta memória. Serve para aprender, nunca para produção. Escolhe um fornecedor a sério:

maximumSize limita o número de entradas; expireAfterWrite é o TTL; recordStats ativa as estatísticas de hits e misses (para veres no Actuator).

Para configurar cada cache de forma diferente:

O Redis guarda bytes, por isso os objetos têm de ser serializados (convertidos em texto ou binário). O defeito usa a serialização Java, que é frágil e ilegível. Prefere JSON:

Em aplicações com várias instâncias e muito tráfego, combinam-se as duas: uma cache local pequena e rapidíssima à frente de uma cache Redis partilhada.

L1 · Caffeineem cada instância · TTL curto (30 s) · nanossegundos
↓ miss
L2 · Redispartilhada · TTL longo (10 min) · ~0,5 ms
↓ miss
Base de dadosa fonte da verdade · 5–50 ms

O Spring não traz isto pronto. Implementa-se com um CacheManager próprio ou usando bibliotecas como a JetCache. O problema a resolver é a invalidação: quando um produto muda, todas as instâncias têm de limpar a sua L1, normalmente através de mensagens pub/sub do Redis.

Cache na camada de persistência (Hibernate)

NívelÂmbitoNotas
1.º nívelDentro de uma transação (EntityManager).Sempre ligado. Se pedires a mesma entidade duas vezes na mesma transação, só há uma consulta.
2.º nívelPartilhado entre transações.Opcional. Guarda entidades por ID. Bom para tabelas de referência (países, categorias).
Query cacheResultados de consultas.Invalidado sempre que qualquer tabela envolvida muda. Raramente compensa.
Spring Cache ou cache do Hibernate?

Para a maioria dos casos, @Cacheable nos serviços é mais simples, mais visível e cacheia DTOs prontos (e não entidades com ligações lazy). Usa a cache de 2.º nível para entidades de referência muito lidas.

Cache HTTP: deixar o browser trabalhar

A cache mais rápida é a que nem chega ao servidor. Com os cabeçalhos certos, o browser (ou uma CDN) guarda as respostas.

1.º pedidoServidor responde 200 + ETag: "7"
→
Pedido seguinteBrowser envia If-None-Match: "7"
→
Nada mudouServidor responde 304 Not Modified, sem corpo. Poupa largura de banda.

O filtro ShallowEtagHeaderFilter gera ETags automaticamente a partir do conteúdo da resposta. Poupa rede, mas o servidor continua a calcular a resposta toda.

Quando a cache se volta contra ti

«Há só duas coisas difíceis em informática: invalidar caches e dar nomes às coisas.» A frase é antiga, mas continua verdadeira. Conhece os problemas clássicos:

🐘 Cache stampedeUma entrada muito popular expira. No mesmo segundo, 1000 pedidos dão miss e vão todos à base de dados, que cai.
Solução: sync = true, TTL com variação aleatória (jitter) ou renovar antes de expirar (refreshAfterWrite do Caffeine).
🕳️ Cache penetrationPedidos para chaves que não existem (ex.: /produtos/999999) nunca ficam em cache e batem sempre na base de dados. Pode ser um ataque.
Solução: cachear o «não existe» com TTL curto ou usar um filtro de Bloom.
🥛 Dados obsoletosO preço mudou na base de dados, mas a cache continua a mostrar o antigo.
Solução: TTL adequado ao negócio, @CacheEvict/@CachePut em todas as escritas.
🧠 Cache sem limiteA cache cresce até rebentar a memória (OutOfMemoryError).
Solução: definir sempre maximumSize. Vais ver isto na sessão 2.

Invalidação com várias instâncias

Com 3 instâncias da aplicação e caches locais, um @CacheEvict só limpa a instância que tratou o pedido. As outras duas continuam com o valor antigo até o TTL expirar. Opções: aceitar a desatualização durante um TTL curto, usar Redis (cache única) ou difundir eventos de invalidação.

Medir: a cache está mesmo a ajudar?

Com o Actuator e o recordStats, o Spring Boot publica métricas de cada cache automaticamente:

Checklist de decisão

PerguntaSe a resposta for «sim»…
A tarefa passa o tempo à espera de I/O e há várias independentes?Paraleliza (CompletableFuture, virtual threads).
A tarefa não precisa de resposta imediata?@Async ou uma fila de mensagens.
Os mesmos dados são lidos muitas vezes e mudam pouco?Cache.
Os dados têm de estar 100% atualizados (saldo bancário, stock no checkout)?Não faças cache, ou usa TTL muito curto.
O problema é uma consulta lenta por falta de índice ou N+1?Nem paralelismo nem cache: corrige a consulta (sessão 2).
Não mediste ainda onde está a lentidão?Mede primeiro. Otimizar às cegas é perder tempo.
Lembra-te

Paralelismo e cache acrescentam complexidade e novos tipos de erros. Usa-os onde a medição mostrar que fazem diferença, e não por precaução.

Exercícios

1. Contador de visitas seguro fácil

Um @RestController conta visitas com um campo int. Corrige-o para funcionar com muitos pedidos em simultâneo.

Ver solução

2. Email assíncrono com pool próprio médio

Cria um executor emailExecutor com 2 threads de core, 4 de máximo, fila de 50 e CallerRunsPolicy. Usa-o num @Async que simula um envio de 2 segundos.

Ver solução

3. Cache de taxas de câmbio médio

Um serviço CambioService.taxa(String moeda) chama uma API externa (1 s). As taxas podem ter até 60 segundos de atraso. Configura uma cache Caffeine adequada, não guardes resultados nulos e evita o stampede.

Ver solução

Nota: com sync = true o atributo unless não é suportado em algumas versões do Spring. Se o arranque falhar, remove o unless e garante que o método nunca devolve null (lança exceção).

4. Projeto: medir antes e depois projeto

No projeto do curso (catálogo de encomendas), aplica Caffeine ao ProdutoService.buscar e Redis ao CategoriaService.listar. Faz 100 pedidos seguidos com curl num ciclo e compara o tempo total com e sem cache. Regista o hit ratio no Actuator. Guarda os números: vais usá-los na sessão 3.

Resumo da sessão

O essencial em 10 pontos

  • A maior parte do tempo de uma aplicação web é gasto à espera de I/O, não a calcular.
  • Em paralelo, o tempo total é o da tarefa mais lenta; em sequência, é a soma.
  • CompletableFuture encadeia e combina tarefas assíncronas sem bloquear.
  • Beans Spring são singletons: nada de campos mutáveis; usa variáveis locais, Atomic* ou objetos imutáveis.
  • @Async funciona via proxy: chamadas internas (this.) ignoram-no.
  • No ThreadPoolTaskExecutor, threads extra só nascem quando a fila enche.
  • spring.threads.virtual.enabled=true liga virtual threads (Java 21+), ideais para I/O.
  • @Cacheable, @CachePut e @CacheEvict controlam a cache sem prender o código à tecnologia.
  • Caffeine para cache local rápida; Redis para cache partilhada entre instâncias.
  • Define sempre tamanho máximo e TTL, e mede o hit ratio.

Glossário

Thread
Fio de execução independente dentro de um programa.
Virtual thread
Thread leve gerida pela JVM (Java 21+).
Pool de threads
Grupo de threads reutilizáveis que executam tarefas de uma fila.
Race condition
Erro causado por threads que acedem aos mesmos dados ao mesmo tempo.
Deadlock
Threads bloqueadas para sempre à espera umas das outras.
Proxy
Objeto intermediário que o Spring põe à frente dos teus beans para aplicar anotações.
Cache
Armazenamento rápido de resultados para os reutilizar.
Hit / Miss
Encontrado / não encontrado na cache.
TTL
Tempo de vida de uma entrada na cache.
Evicção
Remoção de entradas da cache.
Stampede
Avalanche de pedidos à fonte quando uma entrada popular expira.