StackPractices
intermediate Por Mathias Paulenko

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.

Jerarquía de caché del navegador a la base de datos: las peticiones se resuelven primero en el navegador, luego en el edge de CDN, el proxy inverso, la caché de aplicación (Redis) y finalmente el buffer pool de la base de datos.
CapaTecnologíaLatenciaQué cachea
NavegadorCache-Control, Service Worker, LocalStorage~0-1msAssets estáticos, respuestas de API, apps offline-first
CDNCloudFront, Cloudflare, Fastly~10-50msImágenes, CSS, JS, respuestas de API en el edge
Proxy inversoproxy_cache de Nginx, Varnish~1-5msEndpoints de API, páginas renderizadas
AplicaciónRedis, Memcached~1msDatos de sesión, claves calientes, resultados calculados
Base de datosBuffer pool, vistas materializadas~1-10msConsultas 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);
    })
  );
});
CabeceraSignificadoÚsala para
Cache-Control: no-storeNunca cachearDatos sensibles
Cache-Control: no-cacheRevalidar en cada peticiónContenido semi en vivo
Cache-Control: max-age=3600Cachear 1 horaRespuestas de API semi-estáticas
Cache-Control: immutableNunca revalidarNombres de archivo con fingerprint
ETag / Last-ModifiedValidadores de peticiones condicionalesRecursos 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

EstrategiaCómo funcionaIdeal paraRiesgo
Cache-AsideLa app consulta la caché, cae a la DB y puebla en el missLectura intensiva; simple de implementarDatos obsoletos si la invalidación falla
Read-ThroughLa caché actúa de proxy transparente de la DBLectura intensiva; la librería maneja la lógicaLa caché se vuelve dependencia obligatoria
Write-ThroughLas escrituras actualizan caché y DB a la vezConsistencia fuerteMayor latencia de escritura
Write-BehindLas escrituras van a la caché; el volcado a DB es asíncronoEscritura intensiva; consistencia eventualPérdida de datos si la caché muere antes del volcado
Refresh-AheadRefresco en segundo plano antes de expirarPatrones de acceso predeciblesTrabajo desperdiciado en claves poco leídas

Enfoques de invalidación de caché:

EnfoqueCómo funcionaIdeal para
TTL (por tiempo)Expira tras una duración fijaDatos que toleran desactualización breve
Por claveVersión o hash incrustado en la claveDespliegues inmutables, invalidación instantánea
Por eventosInvalidar al cambiar el dato vía bus de mensajesSistemas distribuidos, frescura fuerte
Invalidación activaBorrar o actualizar la caché al escribirRequisitos de consistencia fuerte
Calentamiento de cachéPre-poblar antes del pico de tráficoCarga 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 maxmemory y una política de evicción (allkeys-lru es 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

  1. 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
  2. Sin estrategia de invalidación: sin TTLs ni eventos, los datos obsoletos persisten indefinidamente
  3. Thundering herd: una clave caliente expira y mil peticiones la reconstruyen a la vez — usa locking, jitter o SWR
  4. Envenenamiento de caché: input de usuario sin validar escrito en una caché compartida afecta a todos los usuarios
  5. Ignorar el calentamiento: una caché fría tras el despliegue causa picos de latencia justo cuando el tráfico es máximo
  6. Cachear objetos no serializables: guarda strings y JSON, no objetos ORM ni handles de archivo — al deserializar salen corruptos
  7. Sin política de evicción: el crecimiento sin límite termina en OOM; configura maxmemory-policy explí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 --bigkeys para encontrar valores sobredimensionados, confirma que maxmemory-policy está configurada (no noeviction en 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

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.