Estrategias de Caché: Navegador, CDN, Redis y Base de Datos
Implementa caché efectiva en cada capa del stack: caché de navegador, edge de CDN, caché de aplicación con Redis y caché de consultas de base de datos — con estrategias de invalidación que mantienen los datos frescos.
Visión General
La caché es la técnica más efectiva para mejorar el rendimiento de una aplicación. Almacenar datos de acceso frecuente más cerca de quien los consume — en la memoria del navegador, en el edge de una CDN o en almacenes en memoria — reduce la latencia y la carga de la base de datos. Cada capa del stack puede cachear, y cada una tiene latencia, capacidad y mecanismos de invalidación distintos. Esta receta cubre las estrategias principales con implementaciones funcionales, desde las cabeceras de caché del navegador hasta el buffer pool de la base de datos.
Elegir la estrategia correcta depende de dos cosas: qué tan frescos deben estar los datos y si la carga de trabajo lee o escribe más. Si fallas en ambas, la caché se convierte en un problema — datos obsoletos, estampidas y memoria desbordada.
Cuándo Usar
Usa este recurso cuando:
- Las consultas de base de datos se vuelven un cuello de botella bajo carga
- Los tiempos de respuesta de la API superan los 200ms en endpoints de lectura intensiva
- Sirves assets estáticos (imágenes, JS, CSS) a usuarios globales vía CDN
- Construyes aplicaciones de alto tráfico donde datos ligeramente desactualizados son aceptables
- Ejecutas cálculos costosos o consultas idénticas repetidas cuyos resultados se pueden reutilizar
La Jerarquía de Caché
Una petición atraviesa varias capas de caché antes de llegar a la base de datos. Cada capa en la que se resuelve ahorra un orden de magnitud en latencia.
| Capa | Tecnología | Latencia | Qué cachea |
|---|---|---|---|
| Navegador | Cache-Control, Service Worker, LocalStorage | ~0-1ms | Assets estáticos, respuestas de API, apps offline-first |
| CDN | CloudFront, Cloudflare, Fastly | ~10-50ms | Imágenes, CSS, JS, respuestas de API en el edge |
| Proxy inverso | proxy_cache de Nginx, Varnish | ~1-5ms | Endpoints de API, páginas renderizadas |
| Aplicación | Redis, Memcached | ~1ms | Datos de sesión, claves calientes, resultados calculados |
| Base de datos | Buffer pool, vistas materializadas | ~1-10ms | Consultas complejas repetidas, agregaciones |
Una caché que resuelve en el navegador no te cuesta nada. Un miss que llega a la base de datos es lo más caro. Diseña tu estrategia de claves y TTLs para que la mayor parte del tráfico se resuelva lo antes posible en la cadena, según lo que los datos permitan.
Solución
Caché del Navegador
Aprovecha primero la caché integrada del navegador. Un Cache-Control de larga duración sobre assets con hash en el nombre elimina las descargas repetidas; un Service Worker añade soporte offline.
# Nginx: caché agresiva para assets estáticos con fingerprint
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
}
# Respuestas de API: caché condicional
location /api/ {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
}
// Service Worker: caché offline-first (sw.js)
const CACHE_NAME = 'app-v1';
const urlsToCache = ['/static/app.js', '/static/styles.css', '/api/config'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
});
| Cabecera | Significado | Úsala para |
|---|---|---|
Cache-Control: no-store | Nunca cachear | Datos sensibles |
Cache-Control: no-cache | Revalidar en cada petición | Contenido semi en vivo |
Cache-Control: max-age=3600 | Cachear 1 hora | Respuestas de API semi-estáticas |
Cache-Control: immutable | Nunca revalidar | Nombres de archivo con fingerprint |
ETag / Last-Modified | Validadores de peticiones condicionales | Recursos que cambian ocasionalmente |
Caché de CDN y Proxy Inverso
Cachea en el edge y en tu propio proxy para quitarle tráfico al origen. proxy_cache de Nginx te da una capa tipo edge incluso sin una CDN comercial.
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:100m max_size=1g;
server {
location /api/public/ {
proxy_cache app_cache;
proxy_cache_valid 200 5m;
proxy_cache_use_stale error timeout updating;
proxy_cache_key "$scheme$request_method$host$request_uri";
add_header X-Cache-Status $upstream_cache_status;
}
}
{
"routes": [
{
"src": "/api/public/.*",
"headers": {
"Cache-Control": "public, s-maxage=86400, stale-while-revalidate=86400"
}
}
]
}
s-maxage controla las cachés compartidas (CDN, proxies) mientras max-age controla los navegadores. proxy_cache_use_stale sirve una respuesta expirada mientras el origen está caído o lento — la misma idea que stale-while-revalidate, implementada del lado del servidor.
Redis Cache-Aside (Node.js)
El patrón de batalla: consulta la caché, cae a la base de datos si falla, y puebla la caché en el miss.
const redis = require('redis');
const client = redis.createClient();
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// Intenta primero la caché
const cached = await client.get(cacheKey);
if (cached) return JSON.parse(cached);
// Cache miss: consulta la base de datos
const user = await db.users.findById(userId);
if (user) {
await client.setEx(cacheKey, 3600, JSON.stringify(user)); // TTL 1 hora
}
return user;
}
El equivalente en Spring Boot es declarativo — @Cacheable lee, @CacheEvict invalida:
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public Product getProduct(String id) {
return productRepository.findById(id).orElseThrow();
}
@CacheEvict(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
}
Caché de Consultas de Base de Datos
Deja que la base de datos ayude. El ajuste del buffer pool mantiene las páginas calientes en RAM; las vistas materializadas precalculan agregaciones costosas.
-- postgresql.conf: mantén los datos calientes en el buffer pool
shared_buffers = 4GB -- ~25% de la RAM
effective_cache_size = 12GB -- caché total del SO + PostgreSQL
work_mem = 256MB -- memoria de sort/hash por consulta
-- Precalcula una agregación costosa
CREATE MATERIALIZED VIEW daily_sales_summary AS
SELECT
date_trunc('day', created_at) AS day,
sum(amount) AS total_sales,
count(*) AS order_count
FROM orders
WHERE created_at > now() - interval '90 days'
GROUP BY 1;
-- Refresca en un horario programado, o vía pg_cron
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales_summary;
MySQL eliminó su query cache en la 8.0 — usa ProxySQL o una caché de aplicación ahí. En PostgreSQL, apunta a un hit ratio del buffer pool superior al 99% en cargas OLTP.
Explicación
| Estrategia | Cómo funciona | Ideal para | Riesgo |
|---|---|---|---|
| Cache-Aside | La app consulta la caché, cae a la DB y puebla en el miss | Lectura intensiva; simple de implementar | Datos obsoletos si la invalidación falla |
| Read-Through | La caché actúa de proxy transparente de la DB | Lectura intensiva; la librería maneja la lógica | La caché se vuelve dependencia obligatoria |
| Write-Through | Las escrituras actualizan caché y DB a la vez | Consistencia fuerte | Mayor latencia de escritura |
| Write-Behind | Las escrituras van a la caché; el volcado a DB es asíncrono | Escritura intensiva; consistencia eventual | Pérdida de datos si la caché muere antes del volcado |
| Refresh-Ahead | Refresco en segundo plano antes de expirar | Patrones de acceso predecibles | Trabajo desperdiciado en claves poco leídas |
Enfoques de invalidación de caché:
| Enfoque | Cómo funciona | Ideal para |
|---|---|---|
| TTL (por tiempo) | Expira tras una duración fija | Datos que toleran desactualización breve |
| Por clave | Versión o hash incrustado en la clave | Despliegues inmutables, invalidación instantánea |
| Por eventos | Invalidar al cambiar el dato vía bus de mensajes | Sistemas distribuidos, frescura fuerte |
| Invalidación activa | Borrar o actualizar la caché al escribir | Requisitos de consistencia fuerte |
| Calentamiento de caché | Pre-poblar antes del pico de tráfico | Carga predecible, evitar arranque en frío |
Consulta invalidación de caché para un recorrido dedicado.
Variantes
- Caché local en proceso: Caffeine (Java),
lru_cache(Python), LRU-map (Node). La opción más rápida, sin salto de red, pero por instancia — cada proceso guarda su propia copia. - Caché distribuida: Redis, Memcached. Compartida entre instancias; añade un salto de red pero sobrevive despliegues y escala horizontalmente.
- Caché jerárquica: L1 local más L2 en Redis. Lo mejor de ambos mundos a costa de una invalidación más compleja.
- Caché con cómputo en el edge: Cloudflare Workers, Fastly Compute. Cachea y computa en el edge; útil para personalización cerca del usuario.
Lo que funciona
- Ajusta los TTLs según la volatilidad de los datos: perfiles de usuario (1h), catálogos de productos (24h), precios (10s), sesiones (15m)
- Cachea en múltiples capas: navegador + CDN + Redis + buffer pool de la DB, cada una con su propio TTL
- Diseña para el fallo de la caché: si Redis cae, la app debe degradarse, no romperse — nunca conviertas la caché en una dependencia dura
- Monitorea las tasas de acierto: por debajo del 80% en una clave caliente indica mala configuración; apunta a >90% en la caché de app y >99% en el buffer pool
- Versiona tus claves de caché: incluye la versión de la app para que un despliegue lo invalide todo de una vez
- Limita la memoria deliberadamente: configura
maxmemoryy una política de evicción (allkeys-lrues un default sensato) antes de que Redis haga OOM en el host - Usa hashing consistente en cachés shardadas: Redis Cluster o sharding del lado del cliente evita hotspots
Errores Comunes
- Cachearlo todo: cachea datos estáticos o de cambio lento; los datos por usuario o de cambio rápido no pertenecen a cachés compartidas
- Sin estrategia de invalidación: sin TTLs ni eventos, los datos obsoletos persisten indefinidamente
- Thundering herd: una clave caliente expira y mil peticiones la reconstruyen a la vez — usa locking, jitter o SWR
- Envenenamiento de caché: input de usuario sin validar escrito en una caché compartida afecta a todos los usuarios
- Ignorar el calentamiento: una caché fría tras el despliegue causa picos de latencia justo cuando el tráfico es máximo
- Cachear objetos no serializables: guarda strings y JSON, no objetos ORM ni handles de archivo — al deserializar salen corruptos
- Sin política de evicción: el crecimiento sin límite termina en OOM; configura
maxmemory-policyexplícitamente
Avanzado: Protección contra la Estampida de Caché
const redis = require('redis');
const client = redis.createClient();
async function getWithLock(key, ttl, builder) {
const cached = await client.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `${key}:lock`;
const acquired = await client.set(lockKey, '1', { NX: true, EX: 10 });
if (!acquired) {
await new Promise(resolve => setTimeout(resolve, 100));
return getWithLock(key, ttl, builder);
}
try {
const value = await builder();
await client.setEx(key, ttl, JSON.stringify(value));
return value;
} finally {
await client.del(lockKey);
}
}
SET NX de Redis hace que solo una petición consulte la base de datos mientras las demás reintentan hasta que la caché se llene. El lock expira a los 10 segundos para evitar deadlocks si el builder falla. Para la mayoría de cargas, stale-while-revalidate (más abajo) es una solución más simple al mismo problema.
Avanzado: Caché Multinivel
import redis
import hashlib
import json
r = redis.Redis()
def get_user(user_id):
# L1: caché local en memoria (a nivel de proceso)
if hasattr(get_user, '_cache') and user_id in get_user._cache:
return get_user._cache[user_id]
# L2: caché compartida en Redis
key = f'user:{user_id}'
cached = r.get(key)
if cached:
user = json.loads(cached)
if not hasattr(get_user, '_cache'):
get_user._cache = {}
get_user._cache[user_id] = user
return user
# L3: base de datos
user = db.users.find_by_id(user_id)
if user:
r.setex(key, 3600, json.dumps(user))
if not hasattr(get_user, '_cache'):
get_user._cache = {}
get_user._cache[user_id] = user
return user
L1 resuelve claves calientes con latencia sub-milisegundo pero solo vive dentro de un proceso. L2 comparte datos entre instancias. L3 es la fuente de verdad. Invalida L1 en el despliegue reiniciando el proceso o prefijando las claves con una versión — si no, las instancias divergen.
Avanzado: Diseño de Claves de Caché
def make_cache_key(resource, params, version='v1'):
param_hash = hashlib.md5(
json.dumps(params, sort_keys=True).encode()
).hexdigest()[:12]
return f'{version}:{resource}:{param_hash}'
Las buenas claves de caché son determinísticas, versionadas y llevan prefijo de espacio de nombres. El segmento de versión te permite invalidar todas las claves de una vez en el despliegue. Generar un hash de las combinaciones de parámetros mantiene las claves cortas y resistentes a colisiones. Mantén los IDs de usuario fuera de las claves de caché compartida — usa un espacio de nombres separado para datos por usuario.
Avanzado: Invalidación por Eventos
Los TTLs son un compromiso: demasiado largos y los datos envejecen, demasiado cortos y la tasa de aciertos cae. Publicar un evento de invalidación al escribir elimina ese compromiso para datos que deben estar frescos.
import redis
r = redis.Redis(host='redis', port=6379)
p = r.pubsub()
def handle_invalidation(message):
key = message['data']
r.delete(f"cache:product:{key}")
p.subscribe(**{'product-updates': handle_invalidation})
p.run_in_thread(sleep_time=0.001)
# Al actualizar el producto, publica el evento
r.publish('product-updates', product_id)
Cada instancia de la app se suscribe y descarta la clave afectada al actualizarse. Para esquemas multinivel, combina esto con un prefijo de versión para que las cachés L1 expiren en el siguiente despliegue aunque un evento se pierda.
Avanzado: Invalidación de CDN
# Invalidación de CloudFront vía AWS CLI
aws cloudfront create-invalidation \
--distribution-id E123ABC \
--paths "/*"
# Purga de Cloudflare vía API
curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache" \
-H "Authorization: Bearer API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"purge_everything": false, "files": ["https://example.com/api/products"]}'
Purga URLs específicas cuando puedas — las purgas completas son lentas, tienen rate limit y enfrían todos los edges a la vez. Para cambios de contenido predecibles, usa URLs versionadas (/api/v2/products) en lugar de invalidar rutas viejas, y configura s-maxage para que los edges expiren por sí solos.
Avanzado: Implementación de Stale-While-Revalidate
async function swr(key, ttl, builder) {
const cached = await client.get(key);
const staleFlag = await client.get(`${key}:stale`);
if (cached) {
const data = JSON.parse(cached);
// Obsoleto: sirve ya, refresca en segundo plano
if (staleFlag === '1') {
builder().then(value => {
client.setEx(key, ttl, JSON.stringify(value));
client.del(`${key}:stale`);
}).catch(err => console.error('SWR refresh failed:', err));
}
return data;
}
const value = await builder();
await client.setEx(key, ttl, JSON.stringify(value));
return value;
}
SWR sirve datos cacheados incluso después de que envejezcan, y luego refresca en segundo plano. Activa el flag de obsoleto al 80% del TTL. Ninguna petición espera una reconstrucción, lo que elimina las estampidas a costa de servir ocasionalmente datos algo viejos.
Avanzado: Calentamiento de Caché en el Despliegue
import redis
import asyncio
r = redis.Redis()
async def warm_cache(keys, builder):
tasks = []
for key in keys:
tasks.append(warm_single(key, builder))
await asyncio.gather(*tasks)
async def warm_single(key, builder):
value = await builder(key)
r.setex(key, 3600, json.dumps(value))
El calentamiento de caché pre-puebla Redis antes de que llegue el tráfico. Ejecútalo como paso post-despliegue en CI/CD. Identifica las claves calientes desde analytics o redis-cli --hotkeys — calentar las top 100-500 suele cubrir el 80% del tráfico y previene el pico de latencia de la caché fría que sigue a cada reinicio.
Cuándo Evitar
- Datos en tiempo real: precios de trading, marcadores en vivo, pujas de subasta — la desactualización tiene coste financiero directo
- Datos sensibles por compliance: contextos HIPAA o GDPR donde las copias cacheadas pueden violar acuerdos de tratamiento de datos
- Cargas de escritura intensiva: la tasa de aciertos se mantiene baja porque los datos cambian antes de expirar
- Datasets pequeños: si todo el dataset cabe en la memoria del proceso, una caché distribuida añade complejidad para nada
Solución de Problemas
- Tasa de aciertos estancada bajo el 80%: revisa primero el diseño de claves — claves que incluyen timestamps o IDs aleatorios nunca aciertan. Luego contrasta los TTLs con los patrones de acceso reales; un TTL de 60s sobre datos leídos cada hora garantiza misses.
- La memoria de Redis no para de crecer: ejecuta
redis-cli --bigkeyspara encontrar valores sobredimensionados, confirma quemaxmemory-policyestá configurada (nonoevictionen una carga de caché) y serializa valores como JSON compacto en vez de pickles propios del lenguaje. - Datos obsoletos tras cada despliegue: sube el segmento de versión de tus claves, o suscribe las instancias a un canal de invalidación para que las escrituras se propaguen al momento.
- Pico de latencia justo tras reiniciar o desplegar: la caché está fría. Calienta las claves top en un paso post-despliegue, o habilita
proxy_cache_use_stale/ SWR para que las peticiones no hagan cola en las reconstrucciones. - Una clave que recibe todos los golpes: problema de celebridad o partición caliente. Replica la clave (
key:1…key:N) y lee una réplica aleatoria, o mantenla en L1 con un TTL corto. - Instancias distintas sirviendo datos distintos: las cachés L1 divergieron. Usa claves con prefijo de versión, invalidación por pub/sub o TTLs cortos en L1 para que la divergencia se autocorrija.
Véase También
- Código complementario en GitHub: versiones ejecutables de cada ejemplo de esta receta — cache-aside, lock anti-stampede, SWR, caché multinivel, invalidación por pub/sub, configuración de Nginx, Service Worker y la vista materializada.
- RFC 9111 — HTTP Caching: la especificación normativa detrás de
Cache-Control,ETagy la semántica de cachés compartidas. - MDN — Cache-Control: referencia directiva por directiva con el comportamiento de los navegadores.
- Políticas de evicción de Redis: en qué se diferencian las opciones de
maxmemory-policyy cuándo aplica cada una. - Documentación de Cloudflare Cache: reglas de caché en el edge, claves de caché y mecánicas de purga.
Preguntas frecuentes
¿Cómo prevengo las estampidas de caché?
Usa un mutex o SET NX de Redis para que solo una petición reconstruya la caché mientras las demás esperan. Alternativas: escalona los TTLs con jitter aleatorio para que las claves no expiren a la vez, o usa stale-while-revalidate para que ninguna petición se bloquee en una reconstrucción.
¿Debería cachear respuestas de GraphQL?
Sí, pero cachea por hash de la query más las variables — el mismo endpoint sirve payloads distintos. Apollo Server soporta directivas cacheControl, y las persisted queries se pueden cachear por ID de query.
¿Cuál es la diferencia entre Redis y Memcached?
Redis soporta estructuras de datos (listas, sets, sorted sets), persistencia y pub/sub — lo que también lo hace útil para invalidación por eventos. Memcached es más simple y marginalmente más rápido para caché clave-valor pura. Elige Redis salvo que tengas una razón concreta para no hacerlo.
¿Debería usar caché write-behind?
Solo si puedes tolerar una pérdida breve de datos y tienes un mecanismo de reintento o reconciliación. Un nodo de caché que muere antes del volcado asíncrono pierde esas escrituras. Write-through o cache-aside es más seguro para la mayoría de aplicaciones.
¿Cuándo debería evitar la caché?
Cuando los datos cambian rápido y la desactualización es inaceptable — saldos bancarios, inventario durante una venta flash. También sáltala en endpoints de bajo tráfico donde la tasa de aciertos queda bajo el 20%; el coste operativo supera el beneficio.
¿Cómo mido la efectividad de la caché?
Rastrea tasa de aciertos, tasa de misses, tasa de evicciones y latencia. INFO stats de Redis expone keyspace_hits y keyspace_misses. Apunta a más del 80% en claves calientes, y usa redis-cli --bigkeys para detectar claves que consumen memoria desproporcionada.
Recursos Relacionados
Guía de Optimización de Performance Web
manual detallado para optimizar el rendimiento de aplicaciones web con mejores Core Web Vitals y experiencia de usuario.
PatternProxy Pattern para Cacheo de Respuestas de API
Como implementar un proxy de cacheo que intercepta llamadas a APIs y almacena respuestas para reducir latencia y evitar peticiones redundantes
RecipePatrones de Cache de Redis para Aplicaciones de Alto
Como implementar patrones cache-aside, write-through y write-behind con Redis para reducir carga de base de datos y mejorar tiempos de respuesta
RecipeImplementar Estrategias de Invalidación de Caché
Cómo mantener la caché consistente con las bases de datos usando TTL, write-through, write-behind y patrones de invalidación event-driven.
RecipeCDN Edge Caching: Cloudflare, CloudFront, Fastly
Configura reglas de edge caching en Cloudflare, AWS CloudFront y Fastly: políticas de TTL, claves de caché, purga y stale-while-revalidate.
GuideEstrategias de Caching con Redis
caching con Redis: cache-aside, read-through, write-through, write-behind y refresh-ahead. Cubre politicas de eviction, tuning de TTL, serializacion y operaciones de produccion.