Cacheando datos en Spring: Una experiencia consumiendo datos de referencia de poca variación

23-09-2026

En el proyecto actual en el que trabajo, nos enfrentamos a la necesidad de consumir datos de referencia desde un sistema externo para enriquecer nuestra información y realizar cálculos. Nuestro mayor temor era sobrecargar este servicio con peticiones repetitivas que devolvían casi siempre el mismo contenido. Para mitigar esto y acelerar los tiempos de respuesta, decidimos implementar una capa de caché. Como utilizamos Spring Framework, y preferimos no reinventar la rueda, investigamos cómo hacerlo de forma nativa aprovechando sus herramientas. En este artículo te cuento lo que descubrimos, la implementación final y nuestra hoja de ruta para el futuro.

Te comparto por aquí un artículo sobre caché, agnóstico a la tecnología, como lectura previa que publiqué para evitarte el relleno de teoría aquí: Cachéame si puedes... pero solo si lo necesitas

La Estrategia (Antes del código)

Antes de escribir una sola línea, tuvimos que definir dónde y cómo guardaríamos los datos. Basándonos en la problemática de “datos maestros” (que cambian poco pero se leen mucho), tomamos estas decisiones de arquitectura:

  1. Caché In-memory (Local). Al empezar con una sola instancia de la aplicación, no necesitamos la complejidad de un servidor externo (como Redis). Usar la RAM local nos da velocidad de nanosegundos.
  2. Motor: Caffeine. El ConcurrentMap por defecto de Spring es demasiado simple. Elegimos Caffeine porque nos permite definir TTL (Time-to-Live), es decir, que los datos caduquen automáticamente para evitar que se queden obsoletos eternamente.
  3. Patrón: Cache-Aside (Lectura diferida). La aplicación es responsable de todo: mira en caché, si no está, busca en la fuente y actualiza el caché.

Configuración

Para que Spring Boot gestione esto por nosotros, necesitamos instalar las “tuberías”.

Las dependencias

En nuestro build.gradle, añadimos la abstracción de caché de Spring y la implementación de Caffeine:

 

dependencies { implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'com.github.ben-manes.caffeine:caffeine' }

Reglas de caducidad (TTL)

Aquí atacamos el primer problema: ¿Cómo evitamos datos muy viejos? Configuramos application.properties para que cualquier dato guardado “muera” a los 10 minutos.

 

spring.cache.type=caffeine # expireAfterWrite=10m: El dato se borra 10 minutos después de crearse. # maximumSize=500: Protección para no desbordar la memoria RAM. spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m

El interruptor general

Nada funciona si no activamos la funcionalidad en la clase principal:

 

@SpringBootApplication @EnableCaching // <--- ¡No olvides esto! public class MiAplicacion { ... }

Implementación y protección (Cache-Aside)

Aquí es donde Spring nos ahorra el trabajo. Queremos interceptar el método que llama a la API externa.

El problema de la “Estampida” (Thundering Herd)

Si 5,000 usuarios piden el conjunto de datos simultáneamente y el caché está vacío, las 5,000 peticiones golpearán la API externa, pudiendo tumbarla. ¿La solución? Usamos la anotación @Cacheable con el atributo sync=true. Esto crea un bloqueo inteligente (mutex): solo la primera petición pasa a la API; las otras 4,999 esperan y consumen el dato de la caché cuando esté listo.

 

@Service public class CosasService { // value="cosa": El nombre de nuestra "caja" de almacenamiento. // sync=true: Evita la estampida bloqueando hilos concurrentes en caso de Cache Miss. @Cacheable(value = ["cosas"], sync = true) public List<Cosa> obtenerTodasLasCosas() { // Esta línea solo se ejecuta si el dato NO está en caché. return clienteApi.getCosas(); // Llamada lenta (2s) } }

Caché dinámico (Parámetros)

¿Qué pasa si los datos de referencia dependen de parámetros? Por ejemplo, tarifas por zona. Cuando usas @Cacheable en un método con argumentos, Spring no usa una clave estática. Por defecto, utiliza un algoritmo (SimpleKeyGenerator) que combina todos los parámetros del método para calcular una “huella digital” (hash) única.

  • Si llamas a getTarifaPorZona("Europa"), Spring busca en la caja “tarifas” la etiqueta "Europa".
  • Si llamas a getTarifaPorZona("Asia"), busca la etiqueta "Asia".

Esto permite que un mismo método sirva para cachear múltiples variantes del dato sin que se mezclen.

 

@Service public class TarifasService { // Si llamo a getTarifa("Europa"), Spring guarda el resultado bajo la clave "Europa". // Si llamo a getTarifa("Asia"), crea una entrada nueva para "Asia". @Cacheable(["tarifas"]) public Tarifa getTarifaPorZona(String zona) { return repositorio.buscar(zona); } // 🧠 Truco Pro: Controlando la clave con SpEL // A veces tienes parámetros que NO deben afectar al caché (ej. un flag 'esUrgente' para logging). // Si no hacemos nada, Spring crearía dos entradas distintas: "2023-true" y "2023-false". // Con key="#anio", forzamos a que la clave sea solo "2023", ignorando el booleano. @Cacheable(value = ["reportes"], key = "#anio") public Reporte getReporte(int anio, boolean esUrgente) { return generador.crear(anio); } }

Hasta aquí ya tendríamos una caché robusta y funcional solucionando nuestros problemas. Hagamos un repaso del ciclo del dato

Resumen del ciclo de vida del dato

Para visualizar cómo interactúan todas las piezas que hemos montado (siguiendo con el ejemplo de tarifas de países"):

  1. Minuto 0 (Petición Inicial):
  • Usuario pide “Tarifas Europa”.
  • Caché Miss → Bloqueo (Sync) → Llamada API → Guarda → Retorna.
  1. Minuto 1 (Lectura rápida):
  • Usuario pide “Tarifas Europa”.
  • Caché Hit → Retorno instantáneo (sin ir a la API).
  1. Minuto 10 (Expiración TTL):
  • Caffeine detecta que han pasado 10 minutos desde la escritura.
  • El dato se elimina automáticamente de la memoria según la regla expireAfterWrite.
  1. Minuto 10+ (Nueva petición):
  • Usuario pide “Tarifas Europa”.
  • Caché Miss (porque caducó) → Llama a API (obtiene dato actualizado) → Guarda.

Así funcionaría nuestra aplicación en esta primera fase. No obstante, es lógico que surjan dudas: ¿Es realmente útil un caché de solo 10 minutos si los datos varían cada varias semanas? ¿Qué ocurrirá con la sincronización si necesitamos escalar a múltiples instancias?

Nosotros también nos planteamos estas cuestiones. Antes de cerrar la implementación, evaluamos cómo abordar estos futuros cambios de forma sencilla y sin grandes refactorizaciones. Aunque por ahora hemos pospuesto resolver estas cuestiones debido a que el sistema externo aún no emite notificaciones y el coste de un caché distribuido no está justificado. Sin embargo, estamos seguros que hemos dejado el camino preparado. A continuación, te detallo cómo abordaremos estas implementaciones cuando sea necesario.

Mejorando nuestra estrategia de Invalidación (Webhooks)

Resolvamos la duda: “¿Solo 10 minutos? A lo mejor varían cada par de días o semanas esos datos, ¿realmente es útil esta caché?”

El TTL de 10 minutos permite ahorrar peticiones en esos primeros 10 minutos pero vemos que esos datos puede que no varíen en periodos de días o semanas o meses, pero no sabemos cuando. Pues la solución es simple: que la fuente de datos nos diga cuándo se han producido cambios. Esto implica que de nuestro lado implementemos un Webhook para estos avisos. En este sentido, usando Spring, podemos añadir la anotación @CacheEvict con la “opción nuclear” (allEntries=true) para vaciar el caché por completo y forzar una recarga en la siguiente petición.

 

@RestController public class WebhookController { // allEntries=true: Borra TODO el contenido de la caja "paises". // Es más seguro que intentar borrar claves individuales si no sabemos qué cambió. @CacheEvict(value = ["paises"], allEntries = true) @PostMapping("/api/hooks/cambios-referenciales") public void alRecibirCambio() { System.out.println("♻️ Datos renovados. Caché purgado."); } }

Se puede hacer un cambio más quirurgico por ID u otra información, ya que al final la caché es un map; pero en nuestro caso nos vale que se haga un reemplazo completo

Escalando horizontalmente con más instancias

Si en el futuro la aplicación tiene tanto éxito que necesitamos desplegar múltiples instancias, la caché local (In-Memory) dará problemas de inconsistencia (cada servidor tendrá datos distintos). Pero esto no es problema para lo que ya teníamos. La migración es sencilla gracias a la abstracción de Spring:

  1. Sustituimos la dependencia caffeine por spring-boot-starter-data-redis.
  2. Configuramos el host de Redis en application.properties.
  3. Y en el código no hay que cambiar nada, es agnóstico de la tecnología subyacente

El futuro ciclo del dato

Con la futura incorporación del Webhook que hemos planificado, el ciclo de vida de nuestra información dará un salto cualitativo. Evolucionará de ser puramente pasivo (confiar ciegamente en el TTL) a ser reactivo:

  1. Estado Base: El dato reside en memoria para acceso inmediato, con el TTL actuando solo como red de seguridad final.
  2. Detección: El sistema externo notifica la modificación mediante el Webhook.
  3. Reacción: @CacheEvict purga instantáneamente el dato obsoleto.
  4. Actualización: La siguiente consulta encuentra el vacío y recarga la versión más reciente.

Conclusiones

Esta investigación nos ha permitido implementar una arquitectura base sólida y trazar el camino para su evolución. Hemos conseguido una solución que hoy ya es:

  • Rápida: Aprovechando la velocidad de la memoria RAM.
  • Robusta: Protegiendo al servicio externo de “estampidas” mediante el bloqueo inteligente (sync=true).

Y que, gracias a la abstracción de Spring, queda preparada para el día de mañana ser:

  • Escalable: Lista para cambiar Caffeine por Redis solo con configuración.
  • Reactiva: Dispuesta para integrar invalidación por eventos sin tocar la lógica de negocio.

Spring Boot ha demostrado ser la herramienta ideal, permitiéndonos aplicar los patrones adecuados para nuestro caso (Cache-Aside, Mutex) con simples anotaciones y dejándonos la puerta abierta a mejoras futuras con un coste de refactorización prácticamente nulo.

Referencias y Lecturas Recomendadas