Cache con @Cacheable y Redis en Spring Boot
1. Descripción general
Muchas aplicaciones ejecutan una y otra vez la misma consulta lenta. Por ejemplo, obtener información del producto, configuración o tipo de cambio. Spring Cache Abstraction resuelve esto con unas pocas anotaciones. Redis actúa como almacén de caché compartido para múltiples instancias.
Después de leer este artículo, podrás:
- Configurar Redis como proveedor de caché.
- Usar
@Cacheable,@CachePut,@CacheEvict. - Establecer TTL individual para cada caché y almacenar datos en formato JSON.
- Probar el comportamiento de la caché con JUnit 5 y Testcontainers.
2. Dependencias Maven
Necesitas dos starters: uno para Spring Cache, uno para 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 las pruebas, agrega Testcontainers. Spring Boot ya gestiona la versión de estas dependencias:
<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. Configurar la conexión a Redis
Declara la dirección de Redis y elige Redis como proveedor de caché en application.yml:
spring:
data:
redis:
host: localhost
port: 6379
cache:
type: redis
Significado de cada propiedad:
spring.data.redis.*configura la conexión. Spring Boot usa el cliente Lettuce de forma predeterminada.spring.cache.type: redisespecifica explícitamente el proveedor. Si hay Redis en el classpath, Spring Boot también lo elegirá automáticamente. Especificarlo ayuda a evitar confusiones cuando hay múltiples proveedores en el classpath.
Puedes ejecutar rápidamente un Redis local con Docker:
docker run -d --name redis -p 6379:6379 redis:7-alpine
4. Ejemplo básico con @Cacheable
4.1. Modelo y repositorio que simula una consulta lenta
Usamos un record simple para el producto:
public record Product(Long id, String name, BigDecimal price) {}
El repositorio a continuación simula una base de datos lenta. También cuenta cuántas veces se llama para que podamos verificar la caché:
@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 una consulta que tarda 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. Activar caché y marcar el método
Activa el mecanismo de caché con @EnableCaching. Coloca esta anotación en una clase @Configuration (que escribiremos en la sección 5) o en la clase principal. Luego marca el método que necesita ser cacheado:
@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));
}
}
El flujo de procesamiento es el siguiente:
cacheNames = "products"es el nombre de la caché. En Redis, la clave predeterminada tiene la formaproducts::<key>.key = "#id"es una expresión SpEL que toma el parámetroidcomo clave. Si el método tiene solo un parámetro, puedes omitir este atributo.- En la primera llamada, Spring no encuentra la clave en la caché. Ejecuta el método y luego guarda el resultado en Redis.
- En las llamadas posteriores con el mismo
id, Spring devuelve el resultado de Redis. El método no se ejecuta. - Si el método lanza una excepción (
ProductNotFoundException), Spring no guarda nada en la caché.
Llama findById(1L) dos veces y luego verifica Redis:
redis-cli KEYS '*'
redis-cli TTL products::1
1) "products::1"
(integer) 1798
La primera llamada tarda alrededor de 1 segundo. La segunda solo tarda unos pocos milisegundos. El TTL aún es de aproximadamente 1800 segundos porque configuraremos 30 minutos para la caché products en la siguiente sección.
5. Configurar RedisCacheManager: TTL y serializador
De forma predeterminada, RedisCacheManager usa la serialización JDK. Este enfoque tiene tres desventajas:
- La clase debe implementar
Serializable. - Los datos en Redis son binarios, por lo que son difíciles de leer.
- Los datos pueden corromperse cuando cambias la estructura de la clase.
La caché predeterminada también no tiene TTL, lo que significa que las entradas viven para siempre. Corregimos ambos puntos:
@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))));
}
}
Explicación de cada bean:
- El bean
RedisCacheConfigurationes la configuración predeterminada para todas las cachés. Spring Boot acepta este bean automáticamente. Todas las entradas tienen un TTL de 10 minutos, no almacenannully los valores se almacenan en formato JSON. RedisCacheManagerBuilderCustomizeranula la configuración individual para la cachéproducts. Esta caché tiene un TTL de 30 minutos y usa un serializador con tipo específicoProduct.class.
¿Por qué products usa Jackson2JsonRedisSerializer<>(Product.class)? GenericJackson2JsonRedisSerializer necesita almacenar información de tipo (@class) en JSON. Con clases final como record, este mecanismo tiende a devolver LinkedHashMap en lugar del tipo correcto, y encontrarás ClassCastException. El serializador con tipo específico evita completamente este problema.
Los datos en Redis ahora se pueden leer directamente:
redis-cli GET products::1
"{\"id\":1,\"name\":\"Nón lá Huế\",\"price\":150000}"
Nota: Cuando declaras manualmente el bean
RedisCacheConfiguration, las propiedadesspring.cache.redis.time-to-live,spring.cache.redis.key-prefix… enapplication.ymlserán ignoradas. Elige uno de los dos enfoques de configuración, no los mezcles.
6. Actualizar y eliminar caché: @CachePut y @CacheEvict
La caché solo es útil cuando los datos en ella son correctos. Cuando cambia un producto, debes actualizar o eliminar la entrada correspondiente.
@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() {
// ejemplo: se llama después de importar productos en masa
}
Diferencias entre las anotaciones:
@CachePutsiempre ejecuta el método y luego escribe el resultado devuelto en la caché con la clave#product.id(). Por lo tanto, el método debe devolver el objeto correcto que debe cachearse. Si devuelvevoid, la caché no tendrá los datos correctos.@CacheEvictelimina la entrada por clave después de que el método se ejecute correctamente. Si necesitas eliminar antes de ejecutar el método, agregabeforeInvocation = true.allEntries = trueelimina toda la cachéproducts. Con Redis, esta operación escanea claves por patrón. Solo debes usarlo cuando sea realmente necesario.
7. Controlar el almacenamiento en caché: condition, unless, sync
@Cacheable tiene algunos atributos adicionales útiles:
@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 privado en la misma clase, lee directamente de la base de datos
}
Significado de cada atributo:
conditionse evalúa antes de ejecutar el método. Aquí, las palabras clave más cortas de 3 caracteres no se buscan ni se almacenan en caché.unlessse evalúa después de ejecutar el método y puede usar#result. Aquí, la lista vacía no se almacenará.key = "#keyword.toLowerCase()"ayuda que “Nón” y “nón” compartan la misma entrada.sync = truepreviene el cache stampede. Cuando múltiples threads pierden la misma clave, solo un thread ejecuta el método, los demás esperan el resultado. Este mecanismo solo sincroniza dentro de una JVM, no es un bloqueo distribuido entre instancias.
La caché productSearch no tiene configuración individual, así que usa la configuración predeterminada de la sección 5, es decir, TTL de 10 minutos y GenericJackson2JsonRedisSerializer. Con un valor que es List<Product> que contiene record, debes declarar un serializador con tipo específico de la misma manera que con products. De esta manera, evitas el error ClassCastException mencionado en la sección 5.
8. Probar el comportamiento de la caché
La forma más confiable de probar es con Redis real a través de Testcontainers. La anotación @ServiceConnection configura automáticamente el host y el puerto para Spring Boot, no necesitas modificar 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);
}
}
Estas dos pruebas verifican los siguientes puntos:
- La primera prueba: se llama dos veces pero el repositorio solo se llama una vez. La entrada también existe realmente en la caché con el tipo correcto
Product. Esto confirma que el serializador funciona correctamente. - La segunda prueba:
@CachePutescribe un valor nuevo, así que la siguiente lectura lo obtiene de la caché sin llamar al repositorio una vez más. @BeforeEachlimpia la caché y el contador, ayudando a que las pruebas sean independientes entre sí. El contenedor esstatic, así que solo se inicia una vez para toda la clase.
9. Errores comunes y cuándo no usar caché
Self-invocation. Spring Cache funciona a través de un proxy. Si un método en ProductService llama a this.findById(id), la llamada va directamente al objeto, sin pasar por el proxy, así que la caché no se aplica. Por eso el ejemplo findHotProduct en la sección 7 lee directamente de la base de datos en lugar de llamar a findById. Las anotaciones en métodos private tampoco tienen efecto. La solución es separar el método que necesita caché a otro bean.
Almacenar en caché valor null cuando está deshabilitado. Con disableCachingNullValues(), si el método devuelve null causará un error:
java.lang.IllegalArgumentException: Cache 'products' does not allow 'null' values;
Avoid storing null via '@Cacheable(unless="#result == null")' ...
Tienes dos formas de manejarlo: agregar unless = "#result == null" o lanzar una excepción como en el ejemplo de la sección 4.
Cambiar la estructura de la clase. Agregas o cambias el nombre de un campo en Product, pero Redis aún tiene JSON del tipo antiguo. El resultado puede ser un error de deserialización o datos faltantes del campo. Deberías cambiar el nombre de la caché (por ejemplo products-v2) o limpiar la caché cuando implementes.
Cachear entidad JPA. Las entidades tienen proxies perezosos y relaciones bidireccionales, muy propensas a errores al serializar. Cachea DTO o record en su lugar.
Nota: No debes usar caché cuando los datos requieren consistencia absoluta, como saldo de cuenta o inventario durante el pago. Tampoco debes usar caché cuando los datos cambian continuamente, porque la tasa de acierto será baja y solo gastarás una llamada de red adicional a Redis. Si solo ejecutas una instancia y los datos son pequeños, la caché en memoria como Caffeine suele ser más rápida y simple que Redis.
10. Conclusión
Has configurado Redis como proveedor de caché para Spring Boot. Usas @Cacheable para leer, @CachePut para actualizar, @CacheEvict para eliminar entradas. También estableciste TTL y serializador JSON individual para cada caché, y verificaste el comportamiento con pruebas de Redis real a través de Testcontainers.
Tres cosas que recordar:
- Siempre establece TTL.
- Cachea DTO en lugar de entidades.
- Ten cuidado con self-invocation.
Hacer correctamente estas tres cosas te ayudará a evitar la mayoría de los problemas al llevar el caché a producción.