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çãoAntes da chamadaDepois da chamada
@CacheableProcura a chave; em caso de hit, retorna o valor em cache e não executa o métodoEm caso de miss, armazena o resultado
@CachePutNadaSempre armazena o resultado sob a chave
@CacheEvictNada (a menos que beforeInvocation = true)Remove a chave, ou tudo com allEntries = true
@CachingAgrupa várias das anotações acima em um mesmo método—
@CacheConfigPadrõ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ãoSignificado
#id ou #p0O 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 + ':' + #currencyUma chave composta a partir de dois parâmetros
#result.idO valor de retorno — válido apenas em @CachePut e unless
#root.methodNameO 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

ManagerOnde os dados ficamExpiração / tamanhoQuando usar
ConcurrentMapCacheManagerHeap da JVMNenhumaTestes, protótipos
CaffeineCacheManagerHeap da JVMTamanho, tempo, peso; remoção quase ótimaCaches de instância única ou por nó; a escolha padrão
JCacheCacheManagerDepende do provedor (Ehcache 3, Hazelcast, …)Política do provedorConfiguração baseada em padrões ou um deployment JCache existente
RedisCacheManager (Spring Data Redis)RedisTTL por cacheMuitas 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:

  1. 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.
  2. Remoção explícita na escrita. Todo método que altera os dados carrega @CacheEvict ou @CachePut para 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.
  3. 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 é Cache e CacheManager, mais anotações aplicadas por um proxy AOP depois do @EnableCaching.
  • @Cacheable pula o método em caso de hit, @CachePut sempre executa e atualiza, @CacheEvict remove; @Caching combina todas elas.
  • As chaves são, por padrão, os parâmetros; torne-as explícitas com SpEL ou um KeyGenerator e 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.