"Solo hay dos cosas difíciles en informática: la invalidación de caché y nombrar las cosas." Esta célebre broma de Phil Karlton resume la paradoja de la caché: es la técnica más eficaz para acelerar un sistema y, a la vez, una de las fuentes de bugs más sutiles. Una caché guarda copias de datos costosos de obtener en un lugar de acceso rápido, para no recalcularlos ni volver a pedirlos a la base de datos cada vez. Bien usada, reduce la latencia de milisegundos a microsegundos y descarga drásticamente el almacenamiento principal. Mal usada, sirve datos obsoletos, esconde errores y provoca caídas en cascada. En esta lección estudiaremos los niveles de caché, los patrones de lectura y escritura, las estrategias de invalidación y TTL, problemas clásicos como el cache stampede, y veremos ejemplos concretos con Redis.
Contenido
- Qué es una caché y por qué funciona
- Niveles de caché
- Patrones de lectura: cache-aside y read-through
- Patrones de escritura: write-through y write-behind
- Invalidación, TTL y políticas de expulsión
- Problemas de la caché y cómo mitigarlos
- Ejemplo práctico con Redis
- Qué es una caché y por qué funciona
Una caché es un almacén intermedio, rápido y de capacidad limitada, que guarda copias de datos para servirlos sin repetir el trabajo de obtenerlos del origen. Funciona gracias a dos principios:
- Localidad temporal: un dato consultado ahora probablemente se vuelva a consultar pronto.
- Principio de Pareto: un pequeño porcentaje de los datos concentra la mayoría de los accesos (los productos más vendidos, los usuarios más activos).
Las dos métricas fundamentales son el hit ratio (porcentaje de peticiones servidas desde la caché) y la latencia. Un hit evita ir al origen; un miss implica el coste completo más el de guardar en caché.
- Niveles de caché
La caché aparece en muchas capas de una arquitectura. De más cercano al usuario a más cercano al dato:
| Nivel | Dónde vive | Ejemplo | Alcance |
|---|---|---|---|
| Navegador / cliente | Dispositivo del usuario | Cabeceras Cache-Control |
Un usuario |
| CDN | Red de borde | Cloudflare, CloudFront | Global, contenido estático |
| Gateway / proxy inverso | Frontal del sistema | Nginx, Varnish | Todas las peticiones |
| Caché de aplicación (local) | Memoria del proceso | Caffeine, Guava | Una instancia |
| Caché distribuida | Servicio externo compartido | Redis, Memcached | Todas las instancias |
| Caché de base de datos | Motor de BD | Buffer pool | Interna |
La distinción clave para el arquitecto está entre la caché local (in-process: rapidísima pero no compartida y se pierde al reiniciar) y la caché distribuida (Redis: ligeramente más lenta por la red, pero compartida entre todas las instancias y persistente). En sistemas con varias instancias, la caché distribuida evita que cada una mantenga copias incoherentes.
- Patrones de lectura: cache-aside y read-through
3.1 Cache-Aside (Lazy Loading)
Es el patrón más común. La aplicación gestiona la caché explícitamente: mira primero en la caché y, si no está, va a la base y guarda el resultado.
public Producto obtenerProducto(long id) {
String clave = "producto:" + id;
Producto cacheado = cache.get(clave); // 1. ¿está en caché?
if (cacheado != null) {
return cacheado; // HIT: devolvemos sin tocar la BD
}
Producto producto = repositorio.buscarPorId(id); // 2. MISS: vamos a la BD
if (producto != null) {
cache.set(clave, producto, Duration.ofMinutes(10)); // 3. guardamos con TTL
}
return producto;
}Paso a paso:
- Paso 1: se consulta la caché. Si hay hit, se devuelve de inmediato.
- Paso 2: en caso de miss, se acude al origen de datos.
- Paso 3: se guarda en caché con un TTL (tiempo de vida) de 10 minutos para futuras peticiones.
Ventaja: solo se cachea lo que de verdad se pide (lazy). Inconveniente: la lógica de caché se mezcla con la de negocio y el primer acceso siempre es lento (cache miss obligatorio).
3.2 Read-Through
La caché se encarga de cargar el dato del origen cuando falta; la aplicación solo le habla a la caché. La diferencia con cache-aside es de responsabilidad: aquí el código de carga vive dentro de la capa de caché (configurada con un "cache loader"), no en el servicio.
// La caché sabe cómo cargar lo que no tiene; el servicio solo pide
LoadingCache<Long, Producto> cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10))
.build(id -> repositorio.buscarPorId(id)); // loader: se invoca solo en miss
Producto p = cache.get(42L); // si no está, Caffeine llama al loader por nosotros| Aspecto | Cache-Aside | Read-Through |
|---|---|---|
| Quién carga del origen | La aplicación | La caché (loader) |
| Acoplamiento | Lógica de caché en el servicio | Encapsulada en la caché |
| Control | Máximo | Menor, más limpio |
- Patrones de escritura: write-through y write-behind
Cuando los datos cambian, hay que decidir cómo se actualiza la caché.
4.1 Write-Through
Cada escritura va a la caché y a la base de datos de forma síncrona, en la misma operación.
public void actualizarPrecio(long id, BigDecimal nuevo) {
Producto p = repositorio.buscarPorId(id);
p.setPrecio(nuevo);
repositorio.guardar(p); // 1. escribe en la BD
cache.set("producto:" + id, p, Duration.ofMinutes(10)); // 2. y en la caché
}- Ventaja: la caché nunca queda obsoleta respecto a la base; la lectura siguiente siempre es coherente.
- Inconveniente: cada escritura paga el coste de actualizar ambos almacenes, aumentando la latencia de escritura.
4.2 Write-Behind (Write-Back)
La escritura va primero a la caché y se persiste en la base de forma asíncrona, en diferido (en lotes, tras un retardo).
- Ventaja: escrituras muy rápidas; permite agrupar y absorber picos.
- Inconveniente grave: si la caché cae antes de volcar a la base, se pierden datos. Solo apto cuando se tolera esa pérdida o se asegura la durabilidad de la caché.
| Patrón | Latencia de escritura | Riesgo de pérdida | Coherencia caché-BD |
|---|---|---|---|
| Write-through | Alta (síncrona doble) | Baja | Fuerte |
| Write-behind | Baja (asíncrona) | Alta si cae la caché | Eventual |
- Invalidación, TTL y políticas de expulsión
El reto central: ¿cuándo dejan de ser válidos los datos cacheados? Hay dos enfoques complementarios.
5.1 Expiración por TTL
A cada entrada se le asigna un Time To Live: tras ese tiempo, la caché la considera caducada y la recarga en el siguiente acceso. Es simple y autolimpiante.
- TTL corto: datos más frescos, menor hit ratio.
- TTL largo: mejor hit ratio, mayor riesgo de servir datos obsoletos.
La elección depende de cuánta obsolescencia tolere el negocio. Un catálogo puede tolerar minutos; un saldo, segundos o nada.
5.2 Invalidación explícita
Cuando un dato cambia, borramos o actualizamos su entrada de caché de inmediato:
public void actualizarProducto(Producto p) {
repositorio.guardar(p);
cache.delete("producto:" + p.getId()); // invalida; el próximo acceso recargará
}Borrar (en lugar de actualizar) es a menudo más seguro: evita guardar en caché un valor a medio calcular.
5.3 Políticas de expulsión (eviction)
Como la caché tiene capacidad limitada, cuando se llena debe expulsar entradas:
| Política | Criterio | Idónea cuando |
|---|---|---|
| LRU (Least Recently Used) | Expulsa la menos usada recientemente | Hay localidad temporal (lo habitual) |
| LFU (Least Frequently Used) | Expulsa la menos frecuente | Hay datos "calientes" estables |
| FIFO | Expulsa la más antigua | Casos simples |
| TTL/Random | Por caducidad o azar | Cuando el patrón es uniforme |
- Problemas de la caché y cómo mitigarlos
- Cache Stampede (estampida / thundering herd): cuando una entrada muy popular caduca, miles de peticiones simultáneas sufren miss a la vez y golpean la base de datos al unísono, pudiendo tumbarla. Mitigaciones: (a) un lock o single-flight para que solo una petición recalcule mientras las demás esperan; (b) recálculo anticipado (refrescar antes de que caduque); (c) TTL con jitter (añadir aleatoriedad para que no caduquen todas a la vez).
- Cache Penetration: consultas de claves que no existen en la base; nunca se cachean y siempre golpean el origen. Mitigación: cachear el "no existe" (valor nulo con TTL corto) o usar un Bloom filter.
- Cache Avalanche: muchas entradas caducan simultáneamente (p. ej., todas con el mismo TTL fijado al arrancar). Mitigación: TTL con jitter y escalonado.
- Datos obsoletos (stale): el dato cambió en la base pero la caché aún sirve el viejo. Es el coste inherente; se gestiona con la combinación de TTL adecuado e invalidación explícita.
- Ejemplo práctico con Redis
Redis es la caché distribuida más usada: un almacén clave-valor en memoria, rapidísimo y compartido entre instancias. Veamos cache-aside contra Redis con protección anti-stampede.
public Producto obtener(long id) {
String clave = "producto:" + id;
String json = redis.get(clave); // 1. consulta a Redis
if (json != null) {
return deserializar(json); // HIT
}
// 2. MISS: intentamos adquirir un lock para evitar la estampida
String lockKey = "lock:" + clave;
boolean conseguido = redis.set(lockKey, "1", SetParams.setParams().nx().px(3000));
if (!conseguido) {
Thread.sleep(50); // otro hilo está recalculando: esperamos
return obtener(id); // reintentamos: probablemente ya esté
}
try {
Producto p = repositorio.buscarPorId(id); // 3. solo UN hilo va a la BD
int ttl = 600 + new Random().nextInt(60); // 4. TTL con jitter (600-660s)
redis.setex(clave, ttl, serializar(p)); // guarda con expiración
return p;
} finally {
redis.del(lockKey); // 5. liberamos el lock
}
}Análisis del código:
- Paso 1: lectura directa de Redis con
GET. Si hay valor, hit y deserializamos. - Paso 2: ante un miss, intentamos
SET ... NX PX 3000.NXsignifica "solo si no existe", así que solo un hilo consigue el lock;PX 3000le da una caducidad de 3 s para que el lock no quede colgado si el hilo muere. - Si no conseguimos el lock, esperamos un poco y reintentamos: para entonces, el hilo "ganador" probablemente ya habrá poblado la caché.
- Paso 3: solo el hilo con lock consulta la base, evitando la estampida.
- Paso 4: guardamos con
SETEXy un TTL con jitter (600 a 660 s) para que no caduquen todas las claves a la vez (anti-avalancha). - Paso 5: liberamos el lock en el
finally, pase lo que pase.
Y la invalidación al actualizar:
public void actualizar(Producto p) {
repositorio.guardar(p);
redis.del("producto:" + p.getId()); // invalida; el próximo GET recargará desde la BD
}Errores Comunes y Consejos
- Cachear datos que cambian constantemente. Si un dato cambia más rápido que se lee, la caché casi nunca acierta y añade complejidad sin beneficio. Cachea lo que se lee mucho y cambia poco.
- No poner TTL. Una caché sin expiración acumula datos obsoletos indefinidamente. Pon siempre un TTL, aunque sea largo, como red de seguridad.
- TTL idénticos para todas las claves. Provoca avalanchas. Añade jitter.
- Caché local en sistemas multi-instancia sin coordinar. Cada instancia tiene su copia; al invalidar en una, las demás siguen sirviendo lo viejo. Usa caché distribuida o un canal de invalidación (pub/sub).
- Tratar la caché como fuente de verdad. La caché es una copia descartable; la base de datos es la autoridad. Tu sistema debe funcionar (más lento) aunque la caché se vacíe entera.
- Consejo: mide el hit ratio en producción. Una caché con hit ratio bajo no está ayudando; revisa qué cacheas y los TTL.
Ejercicios
Ejercicio 1. Explica la diferencia entre cache-aside y read-through en términos de "quién es responsable de cargar el dato del origen".
Ejercicio 2. Un producto muy popular tiene TTL de 600 s. Justo al caducar, 5.000 peticiones llegan en el mismo segundo. Describe qué problema ocurre y propón dos mitigaciones concretas.
Ejercicio 3. Quieres cachear el saldo de una cuenta bancaria que debe verse siempre actualizado tras una transferencia. ¿Qué patrón de escritura e invalidación usarías y por qué? ¿Qué patrón evitarías?
Soluciones
Solución 1. En cache-aside, la responsabilidad de cargar el dato del origen recae en la aplicación: el código comprueba la caché, y en caso de miss consulta la base y la repuebla manualmente. En read-through, esa responsabilidad la asume la propia caché mediante un loader configurado; la aplicación solo le pide el dato a la caché, que internamente lo carga si falta.
Solución 2. El problema es una cache stampede (thundering herd): al caducar la entrada, las 5.000 peticiones sufren miss simultáneo y golpean la base de datos a la vez, pudiendo saturarla. Dos mitigaciones: (a) un lock single-flight con Redis SET NX para que solo una petición recalcule mientras las demás esperan y reutilizan el resultado; (b) TTL con jitter y/o refresco anticipado del valor antes de que caduque, de modo que nunca quede una ventana de miss masivo.
Solución 3. Usaría write-through (escribir caché y base de datos de forma síncrona) o, más sencillo y seguro, invalidación explícita: tras persistir la transferencia, borrar la clave del saldo para que la siguiente lectura lo recargue actualizado desde la base. Evitaría write-behind, porque su persistencia asíncrona puede perder datos si la caché cae, algo inadmisible en un saldo bancario. Además convendría un TTL muy corto como red de seguridad.
Conclusión
Has completado el recorrido por la caché: sabes qué es y por qué funciona, en qué niveles aparece (de la CDN a Redis), cómo elegir entre patrones de lectura (cache-aside, read-through) y de escritura (write-through, write-behind), y cómo gestionar la invalidación combinando TTL, borrado explícito y políticas de expulsión. Conoces además los peligros clásicos (stampede, penetration, avalanche, datos obsoletos) y cómo mitigarlos con locks, jitter y caché de negativos, ilustrado con un ejemplo real en Redis. Con esta lección cierras el Módulo 7, en el que has aprendido a decidir dónde guardar los datos (SQL vs NoSQL), cómo acceder a ellos limpiamente (Repository, Unit of Work, DAO), cómo gestionarlos en sistemas distribuidos (database per service, Sagas, CQRS) y cómo acelerar su lectura sin sacrificar la coherencia (caché). El siguiente módulo nos lleva fuera del centro de datos propio para explorar la arquitectura en la nube y el despliegue.
Curso de Arquitectura de Aplicaciones
Módulo 1: Fundamentos de la Arquitectura de Aplicaciones
- ¿Qué es la Arquitectura de Aplicaciones?
- El Rol del Arquitecto de Software
- Atributos de Calidad y Requisitos No Funcionales
- Decisiones Arquitectónicas y Compromisos (Trade-offs)
- Documentación de Arquitectura: Vistas y el Modelo C4
Módulo 2: Principios y Tácticas de Diseño
- Acoplamiento, Cohesión y Separación de Responsabilidades
- Principios SOLID Aplicados a la Arquitectura
- DRY, KISS, YAGNI y Otros Principios de Diseño
- Tácticas Arquitectónicas para los Atributos de Calidad
- Gestión de la Deuda Técnica
Módulo 3: Estilos y Patrones Arquitectónicos
- Arquitectura Monolítica
- Arquitectura en Capas (N-Tier)
- Arquitectura Cliente-Servidor
- Arquitectura Hexagonal (Puertos y Adaptadores)
- Arquitectura Limpia y Cebolla (Clean & Onion)
Módulo 4: Arquitecturas Distribuidas y Microservicios
- Introducción a los Sistemas Distribuidos
- Arquitectura de Microservicios
- Descomposición de Servicios y Bounded Contexts
- API Gateway, Service Discovery y Comunicación entre Servicios
- Patrones de Resiliencia: Circuit Breaker, Retry y Bulkhead
- El Teorema CAP y la Consistencia de Datos
Módulo 5: Arquitecturas Dirigidas por Eventos y Mensajería
- Fundamentos de la Arquitectura Orientada a Eventos
- Mensajería Asíncrona: Colas y Brokers
- Patrones de Eventos: Event Sourcing y CQRS
- Gestión de Transacciones Distribuidas: Patrón Saga
- Streaming de Datos en Tiempo Real
Módulo 6: Diseño Dirigido por el Dominio (DDD)
- Conceptos Fundamentales del DDD
- Diseño Estratégico: Bounded Contexts y Lenguaje Ubicuo
- Diseño Táctico: Entidades, Agregados y Repositorios
- Mapeo de Contextos (Context Mapping)
Módulo 7: Datos y Persistencia
- Estrategias de Persistencia: SQL vs NoSQL
- Patrones de Acceso a Datos: Repository, Unit of Work y DAO
- Base de Datos por Servicio y Gestión de Datos Distribuidos
- Caché y Estrategias de Invalidación
Módulo 8: Arquitectura en la Nube y Despliegue
- Fundamentos del Cloud Computing (IaaS, PaaS, SaaS)
- Contenedores y Orquestación con Docker y Kubernetes
- Arquitectura Serverless
- Patrones de Diseño Cloud-Native
- Infraestructura como Código (IaC)
Módulo 9: Calidad, Seguridad y Observabilidad
- Escalabilidad: Horizontal vs Vertical y Balanceo de Carga
- Alta Disponibilidad y Tolerancia a Fallos
- Seguridad por Diseño y Autenticación/Autorización
- Observabilidad: Logging, Métricas y Trazabilidad
- Rendimiento y Pruebas de Carga
