Spring Framework
Caché declarativa con la abstracción de caché de Spring
Intermedio8 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
La abstracción de caché de Spring te permite memoizar el resultado de métodos costosos con una anotación y conectar detrás cualquier almacén de caché. Esta guía explica qué hacen realmente las anotaciones en tiempo de ejecución, cómo se construyen las claves, cómo elegir y configurar un CacheManager y por qué lo difícil de la caché no es guardar valores sino invalidarlos, con los patrones que mantienen correctos los datos cacheados.
Lo que aprenderás
- Habilitar la caché y aplicar @Cacheable, @CachePut, @CacheEvict y @Caching con la semántica adecuada
- Controlar las claves de caché con SpEL y un KeyGenerator propio, y usar correctamente condition y unless
- Configurar un CacheManager respaldado por ConcurrentMap, Caffeine, JCache o Redis y fijar la expiración donde corresponde
- Explicar por qué la caché funciona a través de un proxy y qué implica para la autoinvocación y los métodos private
- Diseñar una estrategia de invalidación que mantenga los datos cacheados coherentes con la fuente de verdad
La caché es la optimización más rápida de la que disponen la mayoría de las aplicaciones y el origen de algunos de sus bugs más desconcertantes. La abstracción de caché de Spring, en el módulo spring-context, te da memoización de resultados de métodos mediante anotaciones y separa qué se cachea de dónde se almacena. La mecánica es sencilla; la disciplina —decidir claves, expiración e invalidación— es donde están tanto el valor como el riesgo. Esta guía se centra en Spring Framework 6.x.
Qué es la abstracción
La abstracción se compone de dos interfaces. Un Cache es un almacén clave-valor con nombre, con get, put, evict y clear. Un CacheManager resuelve las cachés por nombre y las crea bajo demanda o a partir de una lista configurada. Spring incluye implementaciones para ConcurrentHashMap, Caffeine y JCache (JSR-107); Spring Data Redis añade RedisCacheManager; Hazelcast, Infinispan y otros aportan las suyas.
Las anotaciones las aplica un proxy AOP, exactamente igual que @Transactional. @EnableCaching registra el interceptor y un bean post-processor que envuelve en un proxy cada bean que lleve anotaciones de caché. Cada llamada a un método anotado a través del proxy consulta la caché antes, después o en lugar de invocar el 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 las cachés de antemano, como aquí, es mejor que dejar que el manager las cree dinámicamente: así, una errata en el nombre de un @Cacheable hace fallar el arranque en lugar de crear en silencio una caché sin configurar.
Las anotaciones
@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) { … }
}
| Anotación | Antes de la llamada | Después de la llamada |
|---|---|---|
@Cacheable | Busca la clave; si hay acierto, devuelve el valor cacheado y no ejecuta el método | Si hay fallo, guarda el resultado |
@CachePut | Nada | Guarda siempre el resultado bajo la clave |
@CacheEvict | Nada (salvo con beforeInvocation = true) | Elimina la clave, o todo el contenido con allEntries = true |
@Caching | Agrupa varias de las anteriores en un mismo método | — |
@CacheConfig | Valores por defecto a nivel de clase para cacheNames, keyGenerator, cacheManager | — |
La diferencia entre @Cacheable y @CachePut importa más de lo que parece. @Cacheable es para lecturas: el método es solo el respaldo cuando la caché está fría. @CachePut es para escrituras que además producen el nuevo valor: el método se ejecuta siempre y la caché se refresca con lo que devuelve. Poner ambas en un mismo método es contradictorio, y la documentación de referencia lo desaconseja explícitamente.
@CacheEvict(beforeInvocation = true) desaloja aunque el método lance una excepción; por defecto, el desalojo solo ocurre tras un retorno correcto, lo cual es lo adecuado para «borrar y luego desalojar» y lo inadecuado para flujos de «desalojar y luego reconstruir».
Claves
Una entrada de caché vale lo que vale su clave. Por defecto, SimpleKeyGenerator usa el propio parámetro como clave si hay exactamente uno, SimpleKey.EMPTY si no hay ninguno y, en otro caso, un SimpleKey que envuelve todos los parámetros. Por tanto, cada parámetro necesita un equals y un hashCode correctos: un long o un String sirven, pero un objeto de dominio que no ha sobrescrito equals es una clave que nunca coincide.
El atributo key recibe una expresión de Spring Expression Language (SpEL) que se evalúa sobre el método:
| Expresión | Significado |
|---|---|
#id o #p0 | El parámetro llamado id (requiere -parameters al compilar, algo que activan los plugins de build de Spring Boot) o el primer parámetro |
#customer.id + ':' + #currency | Una clave compuesta a partir de dos parámetros |
#result.id | El valor de retorno; solo es válido en @CachePut y unless |
#root.methodName | El nombre del método invocado; también #root.target, #root.args |
T(java.time.LocalDate).now() | Una llamada estática: útil para agrupar por día, peligrosa en cualquier otro caso |
Para una regla de clave que comparten muchos métodos, implementa KeyGenerator y referéncialo por su nombre de 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) { … }
Olvidar el tenant, el locale o la moneda en la clave es justo lo que hace que un usuario acabe viendo los datos de otro. Todo lo que cambie el resultado del método y no sea un parámetro debe formar parte de la clave, o ese método no debe cachearse.
Condiciones
condition se evalúa antes de la llamada y puede referirse a los parámetros; si es falsa, la caché ni se lee ni se escribe. unless se evalúa después de la llamada y puede referirse a #result; si es verdadera, el resultado no se guarda (aunque un acierto de caché se sigue devolviendo). Usa condition para no cachear valores de entrada que difícilmente se repetirán —condition = "#query.length() > 3"— y unless para evitar cachear null, errores o payloads demasiado grandes.
sync = true en @Cacheable hace que las llamadas concurrentes con la misma clave esperen a un único cálculo en lugar de ir todas al backend a la vez: es el problema del cache stampede. Lo soportan los managers basados en Caffeine y JCache; con esta opción, unless y varios nombres de caché no están permitidos en la misma anotación.
Elegir un cache manager
| Manager | Dónde viven los datos | Expiración / tamaño | Cuándo usarlo |
|---|---|---|---|
ConcurrentMapCacheManager | Heap de la JVM | Ninguna | Tests, prototipos |
CaffeineCacheManager | Heap de la JVM | Tamaño, tiempo, peso; desalojo casi óptimo | Cachés de una sola instancia o por nodo; la opción por defecto |
JCacheCacheManager | Depende del proveedor (Ehcache 3, Hazelcast, …) | Política del proveedor | Configuración basada en estándares o un despliegue JCache existente |
RedisCacheManager (Spring Data Redis) | Redis | TTL por caché | Muchas instancias deben compartir una caché; sobrevivir a reinicios |
Una caché local es rápida —nanosegundos—, pero cada instancia de la aplicación guarda su propia copia, así que un desalojo en un nodo no limpia los demás. Una caché distribuida es coherente entre nodos, pero cada lectura supone un viaje de ida y vuelta por la red y cada valor tiene que ser serializable. Muchos sistemas usan ambas: una capa local de vida corta delante de Redis. El CompositeCacheManager de Spring puede encadenar managers para las búsquedas, pero no implementa un write-through de dos niveles; esa lógica te toca a ti o al proveedor.
Con Redis, elige el serializador de forma deliberada. La serialización JDK es la predeterminada y es frágil entre despliegues; lo habitual es un serializador JSON con información de tipos explícita. Elijas lo que elijas, las clases cacheadas pasan a formar parte de tu superficie de compatibilidad: cambia un campo y las entradas antiguas dejarán de deserializarse hasta que expiren.
Estrategia de invalidación
El famoso problema difícil. Tres enfoques, de menor a mayor coste y corrección:
- Solo expiración por tiempo. Aceptas datos desfasados hasta el TTL. Es correcto para datos de referencia, tipos de cambio y cualquier cosa en la que un retraso acotado sea aceptable. Es lo más simple y lo más robusto, porque no necesita coordinación.
- Desalojo explícito al escribir. Cada método que modifica los datos lleva
@CacheEvicto@CachePutpara las claves afectadas. Es preciso, pero hay que localizar todas las rutas de escritura, incluidas las actualizaciones masivas, los jobs batch y el SQL directo; y, con varias instancias, hace falta una caché compartida entre nodos para que funcione. - Invalidación dirigida por eventos. Las escrituras publican un evento de dominio; los listeners desalojan en cada nodo o en la caché compartida. Soporta varias instancias con cachés locales y desacopla a quienes escriben de cualquier conocimiento sobre la caché.
Combina el 1 con el 2 o el 3: un TTL como red de seguridad atrapa la ruta de escritura que se te haya pasado. Desalojar con allEntries = true en cualquier escritura es el instrumento contundente que a menudo resulta ser la decisión correcta para cachés pequeñas con relaciones de claves complejas.
Caché y transacciones interactúan de una forma en la que es fácil equivocarse. Tanto @EnableCaching como @EnableTransactionManagement fijan por defecto el orden de su interceptor en Ordered.LOWEST_PRECEDENCE, así que cuando un método lleva a la vez @Transactional y @CacheEvict, no está garantizado qué advice se ejecuta en la capa más externa. Si el desalojo ocurre antes del commit, un lector concurrente puede consultar la base de datos entre el desalojo y el commit, ver la fila antigua y volver a poblar la caché con datos desfasados que sobrevivirán hasta el siguiente desalojo. Hay dos soluciones: dar a una de las anotaciones @Enable… un order explícito para que el desalojo sea el advice externo y se ejecute tras el commit, o envolver el manager en TransactionAwareCacheManagerProxy (Spring Data Redis ofrece transactionAware() en su builder), que aplaza put y evict a la fase posterior al commit de la transacción y los descarta si hay rollback. La segunda opción es la más segura por defecto siempre que se mezclen cachés y transacciones.
Errores comunes
Autoinvocación. find(id) llamado desde otro método de ProductService se salta el proxy y, con él, la caché. Mueve el método cacheado a otro bean o inyecta el proxy.
Claves que ignoran el contexto. Tenant, locale, moneda, permisos del usuario: si influye en el resultado y no está en la clave, los usuarios verán los datos de otros.
Cachear objetos mutables en una caché local. Todos los que llaman reciben la misma instancia; la modificación de uno es visible en silencio para todos. Cachea valores inmutables o copias defensivas.
Un ConcurrentMapCacheManager sin límites en producción. Sin límite de tamaño ni expiración: es una fuga de memoria con un nombre bonito.
@Cacheable en un método con efectos secundarios. Si hay acierto, el método no se ejecuta, así que el efecto secundario no ocurre. Cachea solo consultas puras.
Probar la caché con un objeto creado con un simple new. No se cachea nada. Usa un contexto de test de Spring con @EnableCaching y comprueba cacheManager.getCache("products").get(id).
Ignorar las métricas. Una caché con un 5 % de aciertos es coste sin beneficio. El recordStats() de Caffeine y las métricas de caché de Spring Boot muestran las tasas de acierto; vigílalas.
Ideas clave
- La abstracción de caché son
CacheyCacheManager, más unas anotaciones que aplica un proxy AOP tras@EnableCaching. @Cacheablese salta el método si hay acierto,@CachePutse ejecuta siempre y refresca,@CacheEvictelimina;@Cachinglas combina.- Por defecto las claves son los parámetros; hazlas explícitas con SpEL o un
KeyGeneratore incluye cualquier entrada que afecte al resultado. - La expiración y el tamaño se configuran en el proveedor, no en las anotaciones; Caffeine es la opción estándar local y Redis la estándar compartida.
- La invalidación es el verdadero trabajo de diseño: combina desalojo explícito o eventos con un TTL como red de seguridad, y cuida el orden respecto a las transacciones.
- Se aplican todas las salvedades de AOP: la autoinvocación, los objetos sin proxy y los métodos con efectos secundarios anulan la caché sin avisar.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿La abstracción de caché de Spring admite un tiempo de vida (TTL)?
- No directamente. La abstracción define put, get y evict; la expiración, el tamaño máximo y la política de desalojo son propiedades del almacén subyacente. Configúralos en el proveedor de caché: expireAfterWrite en Caffeine, entryTtl en la configuración de caché de Redis, una política de expiración de JCache. ConcurrentMapCacheManager no tiene ninguna expiración, así que las entradas viven hasta que se desalojan o se detiene la JVM.
- ¿Por qué mi método @Cacheable se sigue ejecutando en cada llamada?
- Las tres causas habituales: la llamada viene de otro método de la misma clase (la autoinvocación se salta el proxy), el método no es public con un proxy basado en interfaces, o falta @EnableCaching y no se ha creado ningún proxy. Con menos frecuencia, una expresión condition o unless lo está filtrando todo, o la clave cambia en cada llamada porque un argumento no tiene un equals/hashCode correcto.
- ¿Se cachean los resultados null?
- Por defecto sí, si el almacén admite valores null: Spring envuelve el null para que ConcurrentMap y Caffeine puedan guardarlo. Es útil para cachear un «no encontrado», pero sorprende cuando el valor es null solo de forma temporal. Añade unless = "#result == null" para no cachear los null. Redis serializa un marcador de null; en los proveedores JCache varía.
- ¿Conviene cachear en la capa de repositorio o en la de servicio?
- Cachea la operación cuyo resultado es costoso y estable: normalmente un método de servicio que combina varias consultas o una llamada remota, con el identificador de negocio como clave. Cachear cada llamada al repositorio suele producir muchas cachés pequeñas con una invalidación poco clara. Elijas la capa que elijas, pon las anotaciones @CacheEvict junto a las escrituras de esos mismos datos para ver ambos lados a la vez.