Spring Framework
Cache declarativo com a abstração de cache do Spring
Intermediário8 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
A abstração de cache do Spring permite memoizar o resultado de métodos caros com uma anotação e plugar qualquer armazenamento de cache por trás. Este guia explica o que as anotações realmente fazem em tempo de execução, como as chaves são montadas, como escolher e configurar um CacheManager e por que a parte difícil do cache não é armazenar valores, e sim invalidá-los — com os padrões que mantêm os dados em cache corretos.
O que você vai aprender
- Habilitar o cache e aplicar @Cacheable, @CachePut, @CacheEvict e @Caching com a semântica correta
- Controlar as chaves de cache com SpEL e um KeyGenerator próprio, e usar condition e unless corretamente
- Configurar um CacheManager apoiado em ConcurrentMap, Caffeine, JCache ou Redis e definir a expiração no lugar certo
- Explicar por que o cache passa por um proxy e o que isso significa para a autoinvocação e os métodos private
- Projetar uma estratégia de invalidação que mantenha os dados em cache consistentes com a fonte da verdade
Cache é a otimização mais rápida à disposição da maioria das aplicações e a origem de alguns dos seus bugs mais confusos. A abstração de cache do Spring, no módulo spring-context, oferece memoização de resultados de métodos por meio de anotações e separa o que é cacheado de onde fica armazenado. A mecânica é simples; a disciplina — decidir chaves, expiração e invalidação — é onde estão tanto o valor quanto o risco. Este guia tem como alvo o Spring Framework 6.x.
O que é a abstração
A abstração é formada por duas interfaces. Um Cache é um armazenamento chave-valor com nome, com get, put, evict e clear. Um CacheManager resolve os caches pelo nome e os cria sob demanda ou a partir de uma lista configurada. O Spring traz implementações para ConcurrentHashMap, Caffeine e JCache (JSR-107); o Spring Data Redis acrescenta o RedisCacheManager; Hazelcast, Infinispan e outros fornecem as suas.
As anotações são aplicadas por um proxy AOP, exatamente como o @Transactional. O @EnableCaching registra o interceptor e um bean post-processor que cria um proxy para cada bean que tenha anotações de cache. Cada chamada a um método anotado através do proxy consulta o cache antes, depois ou no lugar da chamada ao método real.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
CacheManager cacheManager() {
var manager = new CaffeineCacheManager("products", "priceLists"); // spring-context-support
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats());
return manager;
}
}
Declarar os caches de antemão, como aqui, é melhor do que deixar o manager criá-los dinamicamente: assim, um erro de digitação no nome de um @Cacheable faz a inicialização falhar em vez de criar silenciosamente um cache sem configuração.
As anotações
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) { this.repository = repository; }
@Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
public Product find(long id) {
return repository.findById(id).orElse(null); // runs only on a cache miss
}
@CachePut(cacheNames = "products", key = "#result.id")
public Product update(long id, ProductUpdate change) {
Product saved = repository.save(repository.require(id).apply(change));
return saved; // always runs; result replaces the cached entry
}
@CacheEvict(cacheNames = "products", key = "#id")
public void delete(long id) {
repository.deleteById(id); // runs, then the entry is removed
}
@Caching(evict = {
@CacheEvict(cacheNames = "products", allEntries = true),
@CacheEvict(cacheNames = "priceLists", allEntries = true)
})
public void importCatalogue(Path file) { … }
}
| Anotação | Antes da chamada | Depois da chamada |
|---|---|---|
@Cacheable | Procura a chave; em caso de hit, retorna o valor em cache e não executa o método | Em caso de miss, armazena o resultado |
@CachePut | Nada | Sempre armazena o resultado sob a chave |
@CacheEvict | Nada (a menos que beforeInvocation = true) | Remove a chave, ou tudo com allEntries = true |
@Caching | Agrupa várias das anotações acima em um mesmo método | — |
@CacheConfig | Padrões no nível da classe para cacheNames, keyGenerator, cacheManager | — |
A diferença entre @Cacheable e @CachePut importa mais do que parece. @Cacheable é para leituras: o método é só o plano B quando o cache está frio. @CachePut é para escritas que também produzem o novo valor: o método sempre executa e o cache é atualizado com o que ele retorna. Colocar as duas no mesmo método é contraditório, e a documentação de referência desaconselha isso explicitamente.
@CacheEvict(beforeInvocation = true) remove a entrada mesmo que o método lance uma exceção; o padrão remove só depois de um retorno bem-sucedido, o que é certo para “apagar e depois remover do cache” e errado para fluxos de “remover do cache e depois reconstruir”.
Chaves
Uma entrada de cache vale tanto quanto a sua chave. Por padrão, o SimpleKeyGenerator usa o próprio parâmetro como chave se houver exatamente um, SimpleKey.EMPTY se não houver nenhum e, caso contrário, um SimpleKey que encapsula todos os parâmetros. Todo parâmetro precisa, portanto, de equals e hashCode corretos — um long ou uma String servem; um objeto de domínio que não sobrescreveu equals é uma chave que nunca bate.
O atributo key recebe uma expressão da Spring Expression Language (SpEL) avaliada sobre o método:
| Expressão | Significado |
|---|---|
#id ou #p0 | O parâmetro chamado id (exige -parameters na compilação, o que os plugins de build do Spring Boot habilitam) ou o primeiro parâmetro |
#customer.id + ':' + #currency | Uma chave composta a partir de dois parâmetros |
#result.id | O valor de retorno — válido apenas em @CachePut e unless |
#root.methodName | O nome do método invocado; também #root.target, #root.args |
T(java.time.LocalDate).now() | Uma chamada estática — útil para agrupar por dia, perigosa em outros casos |
Para uma regra de chave compartilhada por muitos métodos, implemente KeyGenerator e referencie-o pelo nome do bean:
@Component("tenantKeyGenerator")
public class TenantKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
return TenantContext.currentTenant() + ":" + method.getName() + ":" + Arrays.deepToString(params);
}
}
@Cacheable(cacheNames = "reports", keyGenerator = "tenantKeyGenerator")
public Report monthly(YearMonth month) { … }
É justamente esquecendo o tenant, o locale ou a moeda na chave que um usuário acaba vendo os dados de outro. Tudo o que muda o resultado do método e não é parâmetro precisa fazer parte da chave — ou o método não deve ser cacheado.
Condições
condition é avaliada antes da chamada e pode referenciar os parâmetros; quando é falsa, o cache não é lido nem escrito. unless é avaliada depois da chamada e pode referenciar #result; quando é verdadeira, o resultado não é armazenado (mas um hit no cache continua sendo retornado). Use condition para não cachear valores de entrada que dificilmente vão se repetir — condition = "#query.length() > 3" — e unless para evitar cachear nulls, erros ou payloads grandes demais.
sync = true no @Cacheable faz com que chamadores concorrentes com a mesma chave esperem por um único cálculo, em vez de todos baterem no backend ao mesmo tempo — o problema do cache stampede. Os managers baseados em Caffeine e JCache dão suporte a isso; com essa opção, unless e múltiplos nomes de cache não são permitidos na mesma anotação.
Escolhendo um cache manager
| Manager | Onde os dados ficam | Expiração / tamanho | Quando usar |
|---|---|---|---|
ConcurrentMapCacheManager | Heap da JVM | Nenhuma | Testes, protótipos |
CaffeineCacheManager | Heap da JVM | Tamanho, tempo, peso; remoção quase ótima | Caches de instância única ou por nó; a escolha padrão |
JCacheCacheManager | Depende do provedor (Ehcache 3, Hazelcast, …) | Política do provedor | Configuração baseada em padrões ou um deployment JCache existente |
RedisCacheManager (Spring Data Redis) | Redis | TTL por cache | Muitas instâncias precisam compartilhar um cache; sobreviver a reinicializações |
Um cache local é rápido — nanossegundos —, mas cada instância da aplicação tem a sua própria cópia, então uma remoção em um nó não limpa os outros. Um cache distribuído é consistente entre os nós, mas toda leitura custa uma ida e volta pela rede e todo valor precisa ser serializável. Muitos sistemas usam os dois: uma camada local de vida curta na frente do Redis. O CompositeCacheManager do Spring consegue encadear managers para as consultas, mas não implementa um write-through em dois níveis; essa lógica fica por sua conta ou do provedor.
Com Redis, escolha o serializador de forma deliberada. A serialização do JDK é o padrão e é frágil entre deployments; um serializador JSON com informação de tipo explícita é a escolha usual. Seja qual for a escolha, as classes cacheadas passam a fazer parte da sua superfície de compatibilidade — mude um campo e as entradas antigas deixam de ser desserializadas até expirarem.
Estratégia de invalidação
O famoso problema difícil. Três abordagens, em ordem crescente de custo e de correção:
- Apenas expiração por tempo. Aceite dados defasados até o limite do TTL. É correto para dados de referência, taxas de câmbio e tudo em que um atraso limitado é aceitável. É o mais simples e o mais robusto, porque não exige coordenação.
- Remoção explícita na escrita. Todo método que altera os dados carrega
@CacheEvictou@CachePutpara as chaves afetadas. É a abordagem mais exata, mas exige encontrar todo caminho de escrita, incluindo atualizações em massa, jobs batch e SQL direto — e, com várias instâncias, é necessário um cache compartilhado entre os nós para funcionar. - Invalidação orientada a eventos. As escritas publicam um evento de domínio; listeners removem as entradas em cada nó ou no cache compartilhado. Isso lida com várias instâncias usando caches locais e desacopla quem escreve de qualquer conhecimento sobre o cache.
Combine a 1 com a 2 ou a 3: um TTL como rede de segurança pega o caminho de escrita que você deixou passar. Remover tudo com allEntries = true a cada escrita é a solução grosseira que muitas vezes é a decisão certa para caches pequenos com relações de chave complexas.
Cache e transações interagem de um jeito em que é fácil errar. @EnableCaching e @EnableTransactionManagement definem, por padrão, a ordem dos seus interceptors como Ordered.LOWEST_PRECEDENCE, então quando um método tem ao mesmo tempo @Transactional e @CacheEvict, não há garantia de qual advice roda na camada mais externa. Se a remoção acontecer antes do commit, um leitor concorrente pode consultar o banco entre a remoção e o commit, ver a linha antiga e repopular o cache com dados defasados, que vão sobreviver até a próxima remoção. Há duas correções: dar a uma das anotações @Enable… um order explícito, para que a remoção seja o advice externo e rode depois do commit, ou envolver o manager em um TransactionAwareCacheManagerProxy (o Spring Data Redis oferece transactionAware() no seu builder), que adia put e evict para a fase pós-commit da transação e os descarta em caso de rollback. A segunda opção é o padrão mais seguro sempre que caches e transações se misturam.
Erros comuns
Autoinvocação. find(id) chamado a partir de outro método de ProductService passa por fora do proxy e, portanto, do cache. Mova o método cacheado para outro bean ou injete o proxy.
Chaves que ignoram o contexto. Tenant, locale, moeda, permissões do usuário — se afeta o resultado e não está na chave, os usuários vão ver os dados uns dos outros.
Cachear objetos mutáveis em um cache local. Todo chamador recebe a mesma instância; a modificação feita por um fica silenciosamente visível para todos. Cacheie valores imutáveis ou cópias defensivas.
ConcurrentMapCacheManager sem limites em produção. Sem limite de tamanho, sem expiração: é um vazamento de memória com um nome bonito.
@Cacheable em um método com efeitos colaterais. Em caso de hit, o método não roda, então o efeito colateral não acontece. Cacheie apenas consultas puras.
Testar o cache com um objeto criado por um simples new. Nada é cacheado. Use um contexto de teste do Spring com @EnableCaching e verifique cacheManager.getCache("products").get(id).
Ignorar as métricas. Um cache com taxa de hit de 5% é custo sem benefício. O recordStats() do Caffeine e as métricas de cache do Spring Boot mostram as taxas de hit; acompanhe-as.
Principais pontos
- A abstração de cache é
CacheeCacheManager, mais anotações aplicadas por um proxy AOP depois do@EnableCaching. @Cacheablepula o método em caso de hit,@CachePutsempre executa e atualiza,@CacheEvictremove;@Cachingcombina todas elas.- As chaves são, por padrão, os parâmetros; torne-as explícitas com SpEL ou um
KeyGeneratore inclua toda entrada que afete o resultado. - Expiração e tamanho são configurados no provedor, não nas anotações; o Caffeine é a escolha padrão para cache local, o Redis a escolha padrão para cache compartilhado.
- A invalidação é o verdadeiro trabalho de design: combine remoção explícita ou eventos com um TTL como rede de segurança e fique atento à ordem em relação às transações.
- Todas as ressalvas de AOP se aplicam — autoinvocação, objetos fora do proxy e métodos com efeitos colaterais anulam o cache silenciosamente.
Fontes e leituras complementares
Perguntas frequentes
- A abstração de cache do Spring tem suporte a tempo de vida (TTL)?
- Não diretamente. A abstração define put, get e evict; expiração, tamanho máximo e política de remoção são propriedades do armazenamento subjacente. Configure isso no provedor de cache: expireAfterWrite no Caffeine, entryTtl na configuração de cache do Redis, uma política de expiração do JCache. O ConcurrentMapCacheManager não tem expiração nenhuma, então as entradas vivem até serem removidas ou até a JVM parar.
- Por que meu método @Cacheable continua sendo executado toda vez?
- As três causas mais comuns: a chamada vem de outro método da mesma classe (a autoinvocação passa por fora do proxy), o método não é public em um proxy baseado em interface, ou falta o @EnableCaching e nenhum proxy foi criado. Com menos frequência, uma expressão condition ou unless está filtrando tudo, ou a chave muda a cada chamada porque algum argumento não tem equals/hashCode adequados.
- Resultados null vão para o cache?
- Por padrão, sim, se o armazenamento aceitar valores null — o Spring encapsula o null para que ConcurrentMap e Caffeine consigam guardá-lo. Isso é útil para cachear um “não encontrado”, mas pega de surpresa quando o valor é null só temporariamente. Adicione unless = "#result == null" para não cachear nulls. O Redis serializa um marcador de null; nos provedores JCache, varia.
- Devo cachear na camada de repository ou na de serviço?
- Cacheie a operação cujo resultado é caro e estável — normalmente um método de serviço que combina várias consultas ou uma chamada remota, usando o identificador de negócio como chave. Cachear cada chamada ao repository costuma gerar muitos caches pequenos com invalidação pouco clara. Qualquer que seja a camada escolhida, coloque as anotações @CacheEvict junto das escritas dos mesmos dados, para enxergar os dois lados juntos.