Caché distribuido para aplicaciones web a medida
Cómo diseñar e implementar un sistema de caché distribuido para aplicaciones web a medida de alto tráfico
Llevo quince años diseñando backends que aguantan tráfico de verdad, y puedo asegurar una cosa: la base de datos siempre acaba siendo el cuello de botella. Siempre. Las consultas que iban como un tiro con 100 usuarios concurrentes --20 milisegundos, sin despeinarse-- se arrastran a 800 ms cuando llegan 5.000 usuarios a la vez. Y a partir de ahí, la cosa empeora de forma exponencial. Un sistema de caché distribuido bien planteado puede reducir la carga sobre la base de datos entre un 70% y un 95%, manteniendo tiempos de respuesta por debajo de 50 ms incluso en los picos más agresivos. Lo que viene a continuación es un recorrido por la arquitectura, los patrones y las decisiones técnicas que separan un caché que funciona en producción de uno que solo queda bien en la pizarra.
Por qué el caché se vuelve crítico a partir de cierta escala
La relación entre tráfico y rendimiento de base de datos no es lineal. Ojalá lo fuera. Un PostgreSQL bien afinado en un servidor con 16 GB de RAM y SSD NVMe puede manejar unas 3.000 queries por segundo para consultas típicas de lectura. Parece sobrado, hasta que te paras a contar: una sola página de una aplicación web moderna dispara entre 5 y 15 consultas distintas. Datos del usuario autenticado, contenido principal, sidebar, configuración, permisos, notificaciones pendientes.
Haz las cuentas. Con 500 usuarios concurrentes navegando, ya estamos en 5.000-7.000 queries por segundo. Lanza una campaña de marketing que triplique el tráfico durante 48 horas y se acabó la fiesta. Las conexiones a la base de datos se saturan, los timeouts saltan por todas partes, los usuarios ven errores 502 o esperan 10 segundos para cargar una página. He visto esto demasiadas veces.
El caché rompe esa dinámica. Almacena los resultados de consultas frecuentes en memoria de acceso ultrarrápido. Una lectura de Redis tarda entre 0.1 y 0.5 milisegundos frente a los 5-50 ms de una consulta a base de datos indexada. Dos órdenes de magnitud de diferencia. Eso se traduce directamente en capacidad para absorber tráfico.
Y no solo mejora la experiencia del usuario. También reduce costes de infraestructura, que es el argumento que convence en las reuniones de presupuesto. Un servidor de base de datos capaz de manejar 20.000 queries por segundo cuesta entre 800 y 2.000 euros al mes en cualquier proveedor cloud. Un servidor de Redis equivalente, que puede manejar 100.000+ operaciones por segundo, ronda los 200-400 euros. La aritmética habla sola.
Tipos de caché: in-memory, distribuido y CDN
Antes de tocar una línea de código, merece la pena entender las tres capas de caché disponibles. Cada una tiene su sitio en la arquitectura, y confundirlas es un error que he cometido yo mismo más de una vez.
Caché in-memory (local)
El más sencillo. Un diccionario en la memoria del propio proceso de la aplicación. En Node.js, un Map o una librería como node-cache. En Python, un diccionario con TTL o cachetools. En Java, Caffeine o Guava Cache.
Ventajas: latencia prácticamente nula (no hay llamada de red) y se implementa en minutos. La limitación fundamental: no se comparte entre instancias. Si la aplicación corre en 4 réplicas detrás de un balanceador, cada réplica mantiene su propia copia. Inconsistencias garantizadas y consumo de memoria multiplicado por 4.
El caché local funciona bien para datos que cambian raramente y ocupan poco: configuración de la aplicación, tablas de referencia, traducciones. Para todo lo demás, necesitas algo distribuido.
Caché distribuido
Un servicio de caché externo al que todas las instancias de la aplicación se conectan por red. Redis y Memcached son los dos estándares de la industria. La gracia del caché distribuido es que resuelve el problema de la consistencia entre réplicas: cuando una instancia invalida una entrada, todas las demás ven la invalidación al instante.
La latencia es mayor que la del caché local (típicamente 0.3-1 ms por operación), pero sigue siendo un orden de magnitud más rápida que una consulta a base de datos. Y la capacidad de escalar horizontalmente sumando nodos al cluster lo convierte en la pieza adecuada para aplicaciones con tráfico real.
Caché en CDN
Los Content Delivery Networks --Cloudflare, Fastly, AWS CloudFront-- cachean respuestas HTTP completas en servidores edge distribuidos geográficamente. Funcionan para contenido estático (imágenes, CSS, JavaScript) y para respuestas de API que pueden ser públicas y no varían por usuario.
El CDN es la primera línea de defensa. Las peticiones que se sirven desde el edge ni siquiera llegan al servidor de aplicación. Para una web que recibe el 60% de su tráfico en páginas públicas, un CDN bien configurado absorbe la mayoría de las peticiones sin tocar el backend.
La estrategia que mejor funciona combina las tres capas: CDN para contenido público y estático, caché distribuido para datos compartidos entre instancias, y caché local para datos de configuración que cambian pocas veces al día. Es como una arquitectura por capas, donde cada nivel filtra tráfico antes de que llegue al siguiente.
Redis vs Memcached: una comparativa técnica honesta
Esta decisión se debate desde hace más de una década. La respuesta se ha ido inclinando progresivamente hacia Redis, pero Memcached sigue teniendo su hueco. Vamos a los criterios que importan cuando estás en producción, no en un tutorial.
Estructuras de datos
Memcached almacena pares clave-valor simples donde el valor es una cadena o un blob binario. Punto. Redis soporta strings, hashes, listas, sets, sorted sets, streams, bitmaps e HyperLogLog. Esta riqueza de estructuras permite implementar patrones que en Memcached exigirían múltiples operaciones o lógica adicional en la aplicación.
Un ejemplo que siempre uso: para mantener un ranking de productos más vistos, Redis permite un sorted set donde cada incremento es una operación atómica ZINCRBY. En Memcached, habría que leer el valor actual, incrementarlo en la aplicación y escribirlo de vuelta, con riesgo de race conditions. Adivina cuál de los dos ha provocado bugs a las 3 de la mañana.
Persistencia
Redis puede persistir datos en disco mediante RDB snapshots o AOF (Append Only File). Memcached es puramente in-memory: si el proceso se reinicia, se pierde todo. Para caché puro esto no debería importar --el caché se reconstruye desde la base de datos--, pero en la práctica un reinicio de Memcached que vacía el caché completo puede provocar un thundering herd que tumbe la base de datos.
Redis con persistencia habilitada puede arrancar cargando el estado previo, evitando el cold start. En aplicaciones de alto tráfico, esta diferencia puede ser la que separa un reinicio transparente de una caída en cascada. Lo aprendí por las malas en 2017.
Rendimiento bruto
Memcached tiene una ligera ventaja en operaciones simples de GET/SET con valores pequeños. Benchmarks independientes muestran que Memcached puede alcanzar un 10-15% más de throughput en escenarios de clave-valor puro con valores menores de 1 KB. Redis compensa con operaciones complejas que se ejecutan en servidor sin round trips adicionales.
Clustering y alta disponibilidad
Redis Cluster soporta sharding automático con hasta 1.000 nodos y replicación master-slave con failover automático vía Redis Sentinel. Memcached no tiene clustering nativo: la distribución de claves la gestiona el cliente mediante hashing consistente, y no hay failover automático. Cuando un nodo de Memcached cae, estás solo.
La recomendación de trinchera
Para aplicaciones web a medida en 2026, Redis es la elección predeterminada. La riqueza de estructuras de datos, la persistencia, el clustering nativo y el ecosistema de herramientas (RedisInsight, redis-cli, integraciones con todos los frameworks) lo hacen superior para el 90% de los casos. Memcached sigue siendo viable para escenarios muy específicos: caché de objetos simples a volumen extremo donde cada microsegundo cuenta y no se necesita ninguna funcionalidad avanzada.
Patrones de caché: cache-aside, write-through y write-behind
Elegir el patrón correcto determina cómo fluyen los datos entre la aplicación, el caché y la base de datos. Cada patrón tiene sus tradeoffs. Elegir mal aquí te persigue durante meses.
Cache-aside (lazy loading)
El patrón más extendido. La aplicación consulta primero el caché. Si encuentra el dato (cache hit), lo devuelve directamente. Si no (cache miss), consulta la base de datos, almacena el resultado en el caché y lo devuelve.
función obtenerUsuario(id):
usuario = cache.get("user:" + id)
si usuario existe:
retornar usuario
usuario = db.query("SELECT * FROM users WHERE id = ?", id)
cache.set("user:" + id, usuario, TTL=300)
retornar usuario
Ventajas: solo se cachean los datos que realmente se consultan, la aplicación controla completamente qué se almacena, y un fallo del caché no tumba la aplicación (degrada a rendimiento sin caché, pero sigue funcionando).
Desventajas: la primera petición siempre sufre latencia alta (cache miss + escritura al caché), y los datos pueden quedar desactualizados si se modifican en la base de datos sin invalidar el caché. Ese segundo punto es el que genera las reuniones de "por qué el usuario ve su nombre antiguo".
Write-through
Cada escritura a la base de datos actualiza simultáneamente el caché. La aplicación nunca lee datos obsoletos porque se actualizan en el momento de la escritura.
Ventajas: consistencia fuerte entre caché y base de datos. Eliminación de lecturas obsoletas.
Desventajas: mayor latencia en las escrituras (dos operaciones en lugar de una), se cachean datos que quizá nunca se lean (desperdicio de memoria), y la complejidad de mantener ambas escrituras atómicas.
Funciona bien cuando la ratio lectura/escritura es muy alta (100:1 o más) y la consistencia de los datos no admite compromisos.
Write-behind (write-back)
La aplicación escribe solo al caché, y un proceso asíncrono persiste los cambios a la base de datos en batch. Es el inverso del cache-aside.
Ventajas: latencia de escritura bajísima (solo escribe en memoria), posibilidad de agrupar escrituras (reduciendo carga en la base de datos), ideal para contadores, métricas o datos de sesión que se actualizan con alta frecuencia.
Desventajas: riesgo de pérdida de datos si el caché falla antes de persistir, complejidad significativa en la implementación, y dificultad para manejar errores de escritura a base de datos. No es un patrón para principiantes.
Cuándo usar cada uno
Para la mayoría de aplicaciones web a medida, cache-aside es el punto de partida. Simple, predecible, tolerante a fallos. Write-through se añade para los datos donde la consistencia manda (perfiles de usuario, permisos, configuración). Write-behind se reserva para escenarios de alta frecuencia de escritura donde la pérdida eventual de algunos datos es aceptable: analíticas, contadores de visitas, logs de actividad.
Invalidación de caché: el verdadero jefe final
Phil Karlton dijo que las dos cosas más difíciles en informática son nombrar cosas y la invalidación de caché. Después de quince años, confirmo que tenía toda la razón. Un caché que no se invalida correctamente sirve datos obsoletos. Uno que se invalida demasiado agresivamente pierde toda su efectividad. Encontrar el punto medio requiere conocer bien las estrategias disponibles.
Invalidación explícita
Cuando la aplicación modifica un dato, invalida explícitamente las entradas de caché relacionadas. El enfoque más directo y más preciso.
función actualizarPerfil(userId, datos):
db.update("UPDATE users SET ... WHERE id = ?", userId, datos)
cache.delete("user:" + userId)
cache.delete("user_profile:" + userId)
cache.delete("team_members:" + datos.teamId)
El reto está en identificar todas las claves de caché afectadas por una modificación. Si un usuario cambia su nombre y ese nombre aparece en la caché de su perfil, en la lista de miembros del equipo y en el feed de actividad, hay que invalidar las tres claves. Olvidar una genera datos inconsistentes que pueden persistir durante horas. Me ha pasado. Más de una vez.
Una técnica que funciona bien es usar tags o namespaces. Redis no soporta tags nativamente, pero se pueden simular con sets:
cache.sadd("tags:user:123", "user:123", "user_profile:123", "team_members:5")
función invalidarUsuario(userId):
claves = cache.smembers("tags:user:" + userId)
cache.delete(...claves)
cache.delete("tags:user:" + userId)
Invalidación basada en eventos
En lugar de invalidar desde el mismo código que escribe en la base de datos, se publican eventos de cambio que un suscriptor gestiona por separado. Esto desacopla la lógica de negocio de la gestión del caché, que es una separación de responsabilidades que se agradece mucho cuando el sistema crece.
Un patrón habitual usa los pub/sub de Redis o un sistema de mensajería como RabbitMQ o Kafka. Cuando se actualiza un usuario, se publica un evento user.updated con el ID. Un servicio suscriptor recibe el evento e invalida todas las claves relacionadas.
Este enfoque escala mejor en aplicaciones grandes con múltiples servicios que comparten datos cacheados. Cada servicio gestiona la invalidación de su propio caché en respuesta a los eventos que le afectan.
TTL como red de seguridad
Incluso con invalidación explícita, los TTLs (Time To Live) actúan como última línea de defensa contra datos obsoletos. Si un bug en el código de invalidación deja una clave sin borrar, el TTL garantiza que eventualmente se actualizará.
Los TTLs óptimos varían según el tipo de dato. Configuración de la aplicación que cambia una vez al día: TTL de 1 hora. Datos de perfil de usuario que cambian pocas veces: TTL de 5-15 minutos. Resultados de búsqueda o listados que se actualizan frecuentemente: TTL de 30-60 segundos.
TTLs demasiado cortos anulan el beneficio del caché. TTLs demasiado largos amplifican el impacto de los fallos de invalidación. La regla que aplico: el TTL tiene que ser lo bastante largo para que la mayoría de accesos sean cache hits, y lo bastante corto para que un dato obsoleto no provoque una incidencia de negocio.
El problema del cache stampede y cómo prevenirlo
Un cache stampede (también llamado thundering herd o dog-piling) ocurre cuando una clave muy consultada expira y cientos de peticiones simultáneas encuentran un cache miss al mismo tiempo. Todas consultan la base de datos en paralelo, generando una carga masiva que puede tumbar el sistema entero.
Pongamos un caso real. Una clave que almacena el catálogo de productos más vendidos. Se consulta 500 veces por segundo y tiene un TTL de 60 segundos. Cuando expira, durante los 50-200 milisegundos que tarda la consulta a base de datos, llegan entre 25 y 100 peticiones que encuentran la clave vacía y lanzan la misma query simultáneamente. El resultado es una avalancha que puede llevarse la base de datos por delante.
Solución 1: Locking (mutex)
La primera petición que encuentra el cache miss adquiere un lock distribuido en Redis. Las demás peticiones esperan brevemente y reintentan leer del caché. Cuando la primera completa la consulta y actualiza el caché, las demás encuentran el dato disponible.
función obtenerCatalogoConLock():
catalogo = cache.get("catalogo_top")
si catalogo existe:
retornar catalogo
lockAdquirido = cache.set("lock:catalogo_top", "1", NX=true, EX=5)
si lockAdquirido:
catalogo = db.query("SELECT ... ORDER BY ventas DESC LIMIT 50")
cache.set("catalogo_top", catalogo, TTL=60)
cache.delete("lock:catalogo_top")
retornar catalogo
sino:
esperar(50ms)
retornar obtenerCatalogoConLock() // reintentar
Solución 2: Probabilistic early expiration
Se renueva la clave antes de que expire, con una probabilidad que aumenta a medida que se acerca la expiración. Esto distribuye las renovaciones en el tiempo en lugar de concentrarlas en el instante exacto.
El algoritmo XFetch implementa esta idea: cada lectura calcula si debería renovar la clave basándose en el tiempo restante de TTL y un factor aleatorio. A falta de 10 segundos para la expiración, quizá un 5% de las peticiones renuevan la clave. A falta de 2 segundos, un 30%. Alguna petición acaba renovando la clave antes de que expire, sin generar un stampede. Elegante.
Solución 3: Never expire + background refresh
Las claves no tienen TTL. Un proceso en background refresca los datos periódicamente. Las peticiones siempre encuentran un cache hit, aunque el dato pueda estar ligeramente desactualizado.
Esta técnica funciona especialmente bien para datos que se calculan con queries pesadas y donde unos segundos de desactualización son aceptables: dashboards, rankings, estadísticas agregadas. Es la que uso como primera opción para ese tipo de datos.
Monitorización y métricas del sistema de caché
Un sistema de caché sin monitorización es una bomba de relojería. Funciona genial hasta que un día deja de funcionar y no tienes ni idea de por qué. Las tres métricas fundamentales son el hit rate, la latencia y el consumo de memoria.
Hit rate
El porcentaje de peticiones que se sirven desde el caché sin consultar la base de datos. Un sistema de caché maduro debería mantener un hit rate por encima del 85%. Por debajo del 70%, algo falla: los TTLs son demasiado cortos, la invalidación es demasiado agresiva, o se están cacheando los datos equivocados.
Redis proporciona el hit rate directamente con el comando INFO stats, que devuelve keyspace_hits y keyspace_misses. La fórmula es simple: hit_rate = hits / (hits + misses) * 100.
Latencia de operaciones
La latencia media y los percentiles p95 y p99 de las operaciones de caché. Si la latencia de Redis supera los 2 ms de media, hay un problema de red, de tamaño de valores, o de saturación del servidor. Redis incluye el comando SLOWLOG que registra las operaciones que superan un umbral configurable.
Herramientas como RedisInsight, Prometheus con el exportador de Redis, o Datadog proporcionan dashboards con alertas automáticas cuando la latencia supera umbrales predefinidos. Montar esto antes de que lo necesites es una de esas inversiones que parecen innecesarias hasta que te salvan un viernes a las 22:00.
Consumo de memoria
Redis almacena todo en RAM. Cuando se queda sin memoria, según la política de eviction configurada (maxmemory-policy), puede rechazar nuevas escrituras, eliminar claves por LRU (Least Recently Used), eliminar claves por LFU (Least Frequently Used), o eliminar claves aleatorias.
La política recomendada para caché es allkeys-lru: cuando la memoria se llena, se eliminan las claves menos recientemente accedidas. Esto garantiza que el caché siempre contiene los datos más relevantes sin intervención manual.
Monitorizar la ratio used_memory / maxmemory permite anticipar problemas. Por encima del 80% de uso, toca planificar una ampliación de la capacidad o revisar los TTLs y las claves almacenadas.
Casos de uso reales donde el caché distribuido marca la diferencia
Plataforma de e-commerce con picos estacionales
Un cliente con una tienda online que procesa 2.000 pedidos diarios en condiciones normales y 15.000 durante Black Friday. Sin caché, la base de datos PostgreSQL con 4 millones de productos colapsaba durante los picos. La implementación de Redis como caché distribuido con un patrón cache-aside para el catálogo de productos, precios y stock redujo las consultas a base de datos un 82% y permitió absorber el tráfico de Black Friday sin escalar verticalmente la base de datos.
Las claves más consultadas (página principal, categorías populares, productos destacados) se precalentaban 30 minutos antes de cada campaña mediante un script que simulaba la navegación esperada. Esto eliminó los cache misses iniciales que habrían generado un stampede al arrancar la campaña. Una medida simple que evitó un desastre previsible.
Aplicación SaaS multitenant
Una aplicación B2B con 400 empresas cliente, cada una con entre 10 y 500 usuarios activos. Cada petición de API requería validar el token de autenticación, cargar los permisos del usuario y la configuración del tenant. Tres consultas que se repetían en cada endpoint.
El sistema de caché almacenaba los tokens validados con un TTL de 5 minutos, los permisos del usuario con un TTL de 2 minutos (e invalidación explícita cuando un administrador cambiaba roles), y la configuración del tenant con un TTL de 30 minutos. Resultado: la latencia media de la API bajó de 180 ms a 45 ms, y el servidor de base de datos pasó del 70% de utilización de CPU al 15%.
Sistema de gestión documental con búsqueda
Un portal interno con 800.000 documentos donde los usuarios buscaban por múltiples criterios combinados. Las búsquedas complejas tardaban entre 2 y 8 segundos en Elasticsearch. La implementación de caché para los resultados de las 500 búsquedas más frecuentes (identificadas por hash de los parámetros de búsqueda) redujo el tiempo de respuesta a menos de 100 ms para el 65% de las búsquedas, que resultaron ser repeticiones de las mismas queries con variaciones menores.
El caché como pilar de la arquitectura, no como parche de urgencia
Diseñar un sistema de caché no es elegir Redis y empezar a cachear todo lo que se mueve. Eso lo hice una vez. No salió bien. Requiere un análisis previo de los patrones de acceso a datos, una estrategia de invalidación coherente, y una monitorización que detecte problemas antes de que los usuarios los detecten por ti.
Los equipos que obtienen mejores resultados son los que tratan el caché como una pieza más de la arquitectura, no como un parche que se aplica cuando la base de datos ya agoniza. Planificar la estrategia de caché durante el diseño de la aplicación permite tomar decisiones de modelado de datos que facilitan la invalidación, estructurar las APIs para maximizar el hit rate, y dimensionar la infraestructura con criterio en vez de con prisas.
Si tu equipo está desarrollando una aplicación web a medida que necesita manejar tráfico elevado, o si ya tienes una aplicación en producción con problemas de rendimiento que sospechas que un sistema de caché podría resolver, en Tangram Consulting trabajamos con equipos técnicos para diseñar e implementar arquitecturas de caché que se integran con la aplicación existente sin reescribir el código base.
La inversión en un sistema de caché distribuido bien diseñado se amortiza rápido: menos infraestructura de base de datos, mejor experiencia de usuario, y capacidad para crecer sin que cada pico de tráfico se convierta en una crisis operativa. Los números hablan por sí solos cuando el hit rate supera el 85% y las consultas a base de datos caen un 80%.