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: redisespecifica 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 formaproducts::<key>.key = "#id"é uma expressão SpEL que usa o parâmetroidcomo 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 armazenanull, e o value é armazenado como JSON. RedisCacheManagerBuilderCustomizersobrescreve a configuração para o cacheproducts. Este cache tem TTL de 30 minutos e usa um serializer com tipo específico deProduct.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
RedisCacheConfigurationmanualmente, as propriedadesspring.cache.redis.time-to-live,spring.cache.redis.key-prefix… noapplication.ymlserã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:
@CachePutsempre 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 retornarvoid, o cache não terá dados corretos.@CacheEvictremove a entrada pela chave depois que o método executa com sucesso. Se você precisar remover antes da execução do método, adicionebeforeInvocation = true.allEntries = trueremove todo o cacheproducts. 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 = trueprevine 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:
@CachePutgrava o novo valor, então a leitura subsequente busca do cache sem chamar o repositório novamente. @BeforeEachlimpa o cache e o contador, ajudando os testes a serem independentes um do outro. O container éstaticportanto 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.