Cache com @Cacheable e Redis no Spring Boot

1. Visão geral

Muitas aplicações consultam a mesma query lenta repetidamente. Exemplos incluem buscar informações de produtos, configurações ou taxas de câmbio. O Spring Cache Abstraction resolve isso com algumas anotações. Redis funciona como um local de armazenamento de cache compartilhado para múltiplas instâncias.

Depois de ler este artigo, você conseguirá fazer o seguinte:

  • Configurar Redis como cache provider.
  • Usar @Cacheable, @CachePut, @CacheEvict.
  • Definir TTL individual para cada cache e armazenar dados em formato JSON.
  • Testar o comportamento do cache com JUnit 5 e Testcontainers.

2. Dependências Maven

Você precisa de dois starters: um para o Spring Cache, outro para o Redis.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Para os testes, adicione o Testcontainers. O Spring Boot já gerencia a versão dessas dependências:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

3. Configuração da conexão Redis

Declare o endereço do Redis e escolha Redis como cache provider no application.yml:

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis

O significado de cada propriedade:

  • spring.data.redis.* configura a conexão. O Spring Boot usa o cliente Lettuce por padrão.
  • spring.cache.type: redis especifica explicitamente o provider. Se houver Redis no classpath, o Spring Boot também o escolhe automaticamente. Especificar claramente ajuda a evitar confusões quando o classpath tem múltiplos providers.

Você pode executar rapidamente um Redis local com Docker:

docker run -d --name redis -p 6379:6379 redis:7-alpine

4. Exemplo básico com @Cacheable

4.1. Model e repositório simulando uma consulta lenta

Usaremos um record simples para um produto:

public record Product(Long id, String name, BigDecimal price) {}

O repositório abaixo simula um banco de dados lento. Ele também conta quantas vezes é chamado para que você possa verificar o cache:

@Repository
public class ProductRepository {

    private final Map<Long, Product> store = new ConcurrentHashMap<>(Map.of(
            1L, new Product(1L, "Nón lá Huế", new BigDecimal("150000")),
            2L, new Product(2L, "Áo bà ba", new BigDecimal("320000"))
    ));
    private final AtomicInteger queryCount = new AtomicInteger();

    public Optional<Product> findById(Long id) {
        queryCount.incrementAndGet();
        sleep(1000); // simula uma consulta levando 1 segundo
        return Optional.ofNullable(store.get(id));
    }

    public Product save(Product product) {
        store.put(product.id(), product);
        return product;
    }

    public void deleteById(Long id) {
        store.remove(id);
    }

    public int getQueryCount() { return queryCount.get(); }

    public void resetQueryCount() { queryCount.set(0); }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

4.2. Ativar cache e marcar o método

Ative o mecanismo de cache com @EnableCaching. Coloque essa anotação em uma classe @Configuration (será escrita na seção 5) ou na classe main. Depois marque o método que precisa de cache:

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(cacheNames = "products", key = "#id")
    public Product findById(Long id) {
        return productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }
}

O fluxo de processamento é assim:

  • cacheNames = "products" é o nome do cache. No Redis, a chave padrão tem a forma products::<key>.
  • key = "#id" é uma expressão SpEL que usa o parâmetro id como chave. Se o método tiver apenas um parâmetro, você pode omitir esse atributo.
  • Na primeira chamada, o Spring não vê a chave no cache. Ele executa o método e armazena o resultado no Redis.
  • Nas chamadas subsequentes com o mesmo id, o Spring retorna o resultado do Redis. O método não é executado.
  • Se o método lançar uma exceção (ProductNotFoundException), o Spring não armazena nada no cache.

Chame findById(1L) duas vezes e verifique no Redis:

redis-cli KEYS '*'
redis-cli TTL products::1
1) "products::1"
(integer) 1798

A primeira chamada leva aproximadamente 1 segundo. A segunda chamada leva apenas alguns milissegundos. O TTL é de cerca de 1800 segundos porque configuraremos 30 minutos para o cache products na próxima seção.

5. Configuração de RedisCacheManager: TTL e serializer

Por padrão, RedisCacheManager usa serialização JDK. Essa abordagem tem três desvantagens:

  • A classe deve implementar Serializable.
  • Os dados no Redis são binários, portanto difíceis de ler.
  • Os dados podem se corromper quando você altera a estrutura da classe.

Por padrão, o cache também não tem TTL, o que significa que as entradas vivem para sempre. Corrigiremos ambos os pontos:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheConfiguration defaultCacheConfig() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }

    @Bean
    public RedisCacheManagerBuilderCustomizer productCacheCustomizer() {
        return builder -> builder.withCacheConfiguration("products",
                RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(30))
                        .disableCachingNullValues()
                        .serializeValuesWith(RedisSerializationContext.SerializationPair
                                .fromSerializer(new Jackson2JsonRedisSerializer<>(Product.class))));
    }
}

Explicação de cada bean:

  • O bean RedisCacheConfiguration é a configuração padrão para todos os caches. O Spring Boot reconhece este bean automaticamente. Cada entrada tem TTL de 10 minutos, não armazena null, e o value é armazenado como JSON.
  • RedisCacheManagerBuilderCustomizer sobrescreve a configuração para o cache products. Este cache tem TTL de 30 minutos e usa um serializer com tipo específico de Product.class.

Por que products usa Jackson2JsonRedisSerializer<>(Product.class)? GenericJackson2JsonRedisSerializer precisa armazenar informações de tipo (@class) no JSON. Com classes final como record, esse mecanismo facilmente retorna um LinkedHashMap em vez do tipo correto, e você encontrará ClassCastException. Um serializer com tipo específico evita completamente esse problema.

Os dados no Redis agora são legíveis diretamente:

redis-cli GET products::1
"{\"id\":1,\"name\":\"Nón lá Huế\",\"price\":150000}"

Nota: Quando você declara o bean RedisCacheConfiguration manualmente, as propriedades spring.cache.redis.time-to-live, spring.cache.redis.key-prefix… no application.yml serão ignoradas. Escolha uma das duas abordagens de configuração, não as misture.

6. Atualizar e remover cache: @CachePut e @CacheEvict

O cache só é útil quando os dados estão corretos. Quando um produto muda, você deve atualizar ou remover a entrada correspondente.

@CachePut(cacheNames = "products", key = "#product.id()")
public Product update(Product product) {
    return productRepository.save(product);
}

@CacheEvict(cacheNames = "products", key = "#id")
public void delete(Long id) {
    productRepository.deleteById(id);
}

@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() {
    // exemplo: chamar após importação em massa de produtos
}

A diferença entre as anotações:

  • @CachePut sempre executa o método e grava o resultado retornado no cache com a chave #product.id(). Portanto, o método deve retornar o objeto exato que precisa ser cacheado. Se retornar void, o cache não terá dados corretos.
  • @CacheEvict remove a entrada pela chave depois que o método executa com sucesso. Se você precisar remover antes da execução do método, adicione beforeInvocation = true.
  • allEntries = true remove todo o cache products. Com Redis, essa operação varre as chaves por padrão. Use apenas quando realmente necessário.

7. Controlar o comportamento do cache: condition, unless, sync

@Cacheable tem alguns atributos úteis:

@Cacheable(cacheNames = "productSearch",
           key = "#keyword.toLowerCase()",
           condition = "#keyword.length() >= 3",
           unless = "#result.isEmpty()")
public List<Product> search(String keyword) {
    // consulta lenta...
    return List.of();
}

@Cacheable(cacheNames = "products", key = "#id", sync = true)
public Product findHotProduct(Long id) {
    return findFromDatabase(id); // método private na mesma classe, lê diretamente do banco
}

O significado de cada atributo:

  • condition é avaliado antes de executar o método. Aqui, palavras-chave menores que 3 caracteres não são consultadas e também não são cacheadas.
  • unless é avaliado depois de executar o método e pode usar #result. Aqui, uma lista vazia não será armazenada.
  • key = "#keyword.toLowerCase()" ajuda “Nón” e “nón” a compartilharem a mesma entrada.
  • sync = true previne cache stampede. Quando múltiplas threads fazem miss na mesma chave, apenas uma thread executa o método, as demais aguardam o resultado. Esse mecanismo só sincroniza dentro de uma JVM, não é um distributed lock entre instâncias.

O cache productSearch não tem configuração específica, então usa a configuração padrão da seção 5, ou seja, TTL de 10 minutos e GenericJackson2JsonRedisSerializer. Com um valor que é List<Product> contendo um record, você deve declarar um serializer com tipo específico da mesma forma que fez com products. Assim você evita o erro ClassCastException mencionado na seção 5.

8. Testando o comportamento do cache

A forma mais confiável é testar com Redis real através do Testcontainers. A anotação @ServiceConnection configura automaticamente o host e a porta para o Spring Boot, você não precisa modificar o application.yml.

@SpringBootTest
@Testcontainers
class ProductServiceCacheTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
                    .withExposedPorts(6379);

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private CacheManager cacheManager;

    @BeforeEach
    void setUp() {
        cacheManager.getCache("products").clear();
        productRepository.resetQueryCount();
    }

    @Test
    void whenFindByIdCalledTwice_thenRepositoryIsQueriedOnce() {
        Product first = productService.findById(1L);
        Product second = productService.findById(1L);

        assertThat(first).isEqualTo(second);
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
        assertThat(cacheManager.getCache("products").get(1L, Product.class))
                .isEqualTo(first);
    }

    @Test
    void whenProductUpdated_thenCacheReturnsNewValue() {
        productService.findById(2L);

        productService.update(new Product(2L, "Áo bà ba lụa", new BigDecimal("450000")));
        Product cached = productService.findById(2L);

        assertThat(cached.name()).isEqualTo("Áo bà ba lụa");
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
    }
}

Esses dois testes verificam os seguintes pontos:

  • Primeiro teste: chamado duas vezes, mas o repositório é consultado apenas uma vez. A entrada também está realmente no cache com o tipo correto Product. Isso confirma que o serializer está funcionando corretamente.
  • Segundo teste: @CachePut grava o novo valor, então a leitura subsequente busca do cache sem chamar o repositório novamente.
  • @BeforeEach limpa o cache e o contador, ajudando os testes a serem independentes um do outro. O container é static portanto inicia apenas uma vez para toda a classe.

9. Erros comuns e quando não usar

Self-invocation. O Spring Cache funciona através de proxy. Se um método em ProductService chamar this.findById(id), a chamada vai diretamente ao objeto, não passa pelo proxy, portanto o cache não é aplicado. É por isso que o exemplo findHotProduct na seção 7 lê diretamente do banco em vez de chamar findById. Anotações em métodos private também não funcionam. A solução é mover o método que precisa de cache para outro bean.

Cachear valores null quando null está desabilitado. Com disableCachingNullValues(), um método retornando null causará um erro:

java.lang.IllegalArgumentException: Cache 'products' does not allow 'null' values;
Avoid storing null via '@Cacheable(unless="#result == null")' ...

Você tem duas opções: adicionar unless = "#result == null", ou lançar uma exceção como no exemplo da seção 4.

Alterar a estrutura da classe. Você adiciona ou renomeia um campo em Product, mas o Redis ainda contém JSON do tipo antigo. O resultado pode ser erro de desserialização ou campos ausentes. Você deve renomear o cache (por exemplo, products-v2) ou limpar o cache ao fazer deploy.

Cachear entidade JPA. Entidades têm lazy proxies e relacionamentos bidirecionais, muito propensos a erros ao serializar. Sempre cache DTO ou record.

Nota: Não use cache quando os dados exigem consistência absoluta, por exemplo, saldo de conta ou estoque durante pagamento. Também não use quando os dados mudam continuamente, pois a taxa de acerto será baixa e você apenas causará uma chamada de rede extra para o Redis. Se estiver executando apenas uma instância e os dados forem pequenos, cache em memória como Caffeine geralmente é mais rápido e simples que Redis.

10. Conclusão

Você configurou Redis como cache provider para o Spring Boot. Você usa @Cacheable para leitura, @CachePut para atualização, @CacheEvict para remover entradas. Você também definiu TTL e serializer JSON individual para cada cache, depois verificou o comportamento com testes usando Redis real através do Testcontainers.

Três coisas para lembrar:

  • Sempre defina TTL.
  • Cache DTO em vez de entidade.
  • Cuidado com self-invocation.

Fazer essas três coisas corretamente ajudará você a evitar a maioria dos problemas ao usar cache em produção.

spring-bootrediscachespring-cachetestcontainers