Todos los artículos

Redis en producción: estrategias de caching que realmente funcionan (y algunas que no)

Una guía probada en producción sobre estrategias de caching con Redis: cache-aside, write-through, patrones de invalidación, diseño de TTL y los errores que nos costaron dinero de verdad.

9 min de lectura
  • #redis
  • #caching
  • #backend
  • #performance
  • #aws

Llevo varios años corriendo Redis en producción en plataformas de e-commerce y puedo decirte que la mayoría de los tutoriales de caching omiten las partes que realmente importan. Te muestran SET key value EX 300 y dan el trabajo por terminado. El caching real en producción trata de trade-offs, estrategias de invalidación, modos de falla y saber cuándo NO cachear algo.

Esto es todo lo que me hubiera gustado que alguien me contara antes de aprenderlo a golpes.

Las tres estrategias principales (y cuándo encaja cada una)

Cache-Aside (Lazy Loading)

Es el patrón más común, y con razón. La aplicación consulta primero el cache. Si no está (miss), lee de la base de datos, escribe en el cache y devuelve los datos.

async function getProduct(id: string): Promise<Product> {
  const cacheKey = `product:${id}`;

  // 1. Consultar el cache
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // 2. Cache miss: leer de la BD
  const product = await productRepo.findById(id);
  if (!product) throw new NotFoundException();

  // 3. Poblar el cache
  await redis.set(cacheKey, JSON.stringify(product), 'EX', 600);

  return product;
}

Cuándo funciona: cargas de trabajo con mucha lectura donde los datos desactualizados son aceptables por períodos cortos. Catálogos de productos, perfiles de usuario, datos de configuración.

Cuándo no funciona: datos que cambian con frecuencia y deben ser consistentes de inmediato. Si cacheas el carrito de un usuario con cache-aside y agrega un ítem, verá el carrito viejo hasta que expire el TTL (a menos que invalides activamente; más sobre eso adelante).

Trade-off: simple de implementar, pero cada cache miss es un request lento. Un cache frío después de un deploy o un reinicio de Redis significa una estampida (thundering herd) hacia la base de datos.

Write-Through

Cada escritura va al cache y a la base de datos de forma simultánea. Las lecturas siempre van al cache.

async function updateProduct(id: string, data: UpdateProductDto): Promise<Product> {
  // 1. Actualizar la base de datos
  const product = await productRepo.update(id, data);

  // 2. Actualizar el cache (de forma síncrona, como parte del camino de escritura)
  const cacheKey = `product:${id}`;
  await redis.set(cacheKey, JSON.stringify(product), 'EX', 600);

  return product;
}

Cuándo funciona: datos que se leen con frecuencia y se actualizan de vez en cuando, Y en los que necesitas una fuerte consistencia de lectura. Datos de sesión, feature flags, reglas de precios.

Cuándo no funciona: cargas con mucha escritura. Pagas el costo de latencia de una escritura en Redis en cada escritura a la base de datos, incluso para datos que quizás nadie lea.

Trade-off: el cache siempre está fresco, pero las escrituras son más lentas. Y aun así necesitas un TTL como red de seguridad: si el write-through falla en silencio, sin TTL el cache diverge para siempre.

Write-Behind (Write-Back)

Las escrituras van primero al cache, y el cache las vuelca a la base de datos de forma asíncrona. Es lo más rápido para escribir, pero lo más peligroso.

Cuándo funciona: contadores de alta frecuencia, eventos de analítica, métricas en tiempo real, donde perder algunos puntos de datos es aceptable.

Cuándo no funciona: cualquier cosa transaccional. Si Redis se cae antes de volcar a la base de datos, esos datos se pierden. Yo jamás lo usaría para órdenes, pagos ni nada financiero.

Trade-off: máximo rendimiento de escritura, pero aceptas una posible pérdida de datos. En mi experiencia, este patrón rara vez es la opción correcta para los datos centrales del negocio. Lo usamos para contadores de vistas y pipelines de analítica, y poco más.

Estrategias de TTL más allá de “ponlo en 5 minutos”

El antipatrón de TTL número uno que veo es ponerle el mismo TTL a todo. Tu catálogo de productos y tu conteo de inventario en tiempo real tienen requisitos de frescura totalmente distintos. Trátalos de forma diferente.

Así pensamos el diseño de TTL:

  • Datos de referencia estáticos (países, categorías, tasas de impuestos): TTL de 24 horas. Invalida explícitamente al cambiar.
  • Datos semiestáticos (detalles de producto, perfiles de usuario): TTL de 10-30 minutos. Los datos desactualizados molestan, pero no hacen daño.
  • Datos volátiles (conteos de inventario, precios): TTL de 30-60 segundos. O no cachees en absoluto: a veces la base de datos es la respuesta correcta.
  • Datos de sesión/autenticación: el TTL coincide con la duración de la sesión. Ni más, ni menos.

También usamos TTLs adaptativos según los patrones de acceso:

async function getWithAdaptiveTTL(key: string, fetcher: () => Promise<any>): Promise<any> {
  const cached = await redis.get(key);
  if (cached) {
    // Extender el TTL al acceder (lo usado más recientemente se mantiene caliente)
    const currentTTL = await redis.ttl(key);
    if (currentTTL < 120) {
      await redis.expire(key, 600); // Reiniciar a 10 min al acceder
    }
    return JSON.parse(cached);
  }

  const data = await fetcher();
  await redis.set(key, JSON.stringify(data), 'EX', 600);
  return data;
}

Los datos de acceso frecuente se mantienen calientes. Los de acceso poco frecuente se expulsan de forma natural. La memoria de tu cache se mantiene enfocada en lo que importa.

Invalidación de cache: el verdadero problema difícil

Phil Karlton dijo que hay dos cosas difíciles en ciencias de la computación: la invalidación de cache y ponerle nombres a las cosas. No se equivocaba con la primera.

Estos son los patrones de invalidación que usamos, del más simple al más robusto:

Expiración basada en TTL (pasiva)

Déjalo expirar de forma natural. Está bien cuando los datos desactualizados son aceptables. Es simple, y la simplicidad está subestimada.

Invalidación explícita (activa)

Cuando los datos cambian, borra la clave del cache:

async function updateProduct(id: string, data: UpdateProductDto): Promise<Product> {
  const product = await productRepo.update(id, data);

  // Borrar, no actualizar: deja que la siguiente lectura lo repueble
  await redis.del(`product:${id}`);

  // ¡No olvides los caches relacionados!
  await redis.del(`catalog:${product.categoryId}`);
  await redis.del(`search:products:*`); // Borrado por patrón para los caches de búsqueda

  return product;
}

Importante: borra, no actualices. Si actualizas el cache y la escritura en la base de datos falla o hace rollback, tu cache queda con datos fantasma. Borrar es idempotente y seguro.

Pero fíjate en search:products:*: ahí es donde la invalidación se pone fea. Un cambio de producto puede afectar listados de categorías, resultados de búsqueda, feeds de recomendaciones y destacados de la página de inicio. Olvidar aunque sea uno crea un bug de datos desactualizados increíblemente difícil de rastrear.

Invalidación basada en eventos

Esto es en lo que nos asentamos para cualquier caso no trivial. Los cambios en la base de datos publican eventos, y un consumidor dedicado a la invalidación del cache se encarga de la limpieza:

// Manejador de eventos: servicio/consumidor separado
@OnEvent('product.updated')
async handleProductUpdate(event: ProductUpdatedEvent) {
  const { productId, categoryId, previousCategoryId } = event;

  const keysToInvalidate = [
    `product:${productId}`,
    `catalog:${categoryId}`,
    `homepage:featured`,
  ];

  // Si cambió la categoría, invalidar también la categoría anterior
  if (previousCategoryId && previousCategoryId !== categoryId) {
    keysToInvalidate.push(`catalog:${previousCategoryId}`);
  }

  await Promise.all(keysToInvalidate.map(key => redis.del(key)));
}

Esto centraliza la lógica de invalidación. Cuando se agrega un cache nuevo, añades su regla de invalidación al manejador de eventos: un solo lugar que mantener en lugar de llamadas a redis.del() dispersas por toda la base de código.

Redis Cluster en AWS ElastiCache: lo que aprendimos

Correr Redis en ElastiCache en modo cluster tiene algunas aristas filosas:

  • La distribución de claves importa. Redis Cluster usa hash slots. Si usas MGET sobre claves que caen en slots distintos, falla. Usa hash tags ({product}:123, {product}:456) para ubicar las claves relacionadas en el mismo shard.
  • El failover no es instantáneo. Cuando falla un nodo primario, ElastiCache promueve una réplica. Esto toma entre 10 y 30 segundos. Tu aplicación debe manejar los errores de conexión con elegancia durante el failover. Nosotros usamos ioredis con retryStrategy y reconnectOnError bien configurados.
  • La gestión de memoria es crítica. Configura maxmemory-policy en allkeys-lru para cargas de caching. Hemos visto equipos dejarlo en noeviction (el valor por defecto) y luego preguntarse por qué Redis empieza a devolver errores cuando se llena.
  • Monitorea el lag de replicación. Si tus réplicas de lectura se atrasan, las lecturas desde réplicas sirven datos desactualizados. Alertamos con 1 segundo de lag de replicación.

Cuándo NO cachear

Esta es la sección que la mayoría de los artículos se salta. Estos son los casos en que cachear empeora las cosas:

  • Datos con mucha escritura y poca lectura. Si escribes 10 veces más de lo que lees, pagas el costo de escritura del cache sin el beneficio de lectura.
  • Datos muy personalizados con poca reutilización. Cachear un feed de recomendaciones único por usuario suena bien hasta que te das cuenta de que tu tasa de aciertos es del 3 %. Estás usando Redis como una segunda base de datos cara y volátil.
  • Datos que deben ser consistentes en tiempo real. Saldos de cuentas, inventario de lanzamientos de edición limitada, pujas de subastas. El costo de los datos desactualizados supera el costo de una consulta a la base de datos.
  • Objetos grandes que rara vez se repiten. Cachear en Redis un PDF de reporte de 2 MB es desperdiciar memoria. Ponlo en S3.

La regla general: si tu tasa de aciertos del cache está por debajo del 80 %, investiga si ese cache se está ganando su lugar. Por debajo del 50 %, probablemente perjudica más de lo que ayuda.

Monitoreo de la salud del cache

Un cache que no monitoreas es un cache esperando para traicionarte. Esto es lo que medimos:

  • Hit rate (objetivo: >90 %): cache_hits / (cache_hits + cache_misses). Lo exponemos como métrica de Prometheus y alertamos si baja del 80 %.
  • Percentiles de latencia: p50, p95, p99 para las operaciones de Redis. Si el p99 se dispara, puede haber una hot key o un problema de red.
  • Uso de memoria: porcentaje de la memoria disponible en uso. Alerta al 80 %.
  • Tasa de evicción: si las claves se expulsan más rápido de lo esperado, tu cache es demasiado pequeño para la carga de trabajo.
  • Cantidad de conexiones: un número de conexiones repentinamente alto puede indicar una fuga de conexiones en tu aplicación.

Usamos OpenTelemetry para instrumentar todas las llamadas a Redis y enviar los traces a nuestro stack de observabilidad. Cada operación de cache recibe un span con el patrón de la clave (no la clave completa: no quieres labels de alta cardinalidad), el tipo de operación y si fue un hit o un miss.

// Wrapper de instrumentación OTEL simplificado
async function cachedGet<T>(key: string, fetcher: () => Promise<T>, ttl: number): Promise<T> {
  const span = tracer.startSpan('cache.get', {
    attributes: { 'cache.key_pattern': key.split(':')[0], 'cache.ttl': ttl },
  });

  try {
    const cached = await redis.get(key);
    if (cached) {
      span.setAttribute('cache.hit', true);
      cacheHitCounter.inc({ pattern: key.split(':')[0] });
      return JSON.parse(cached);
    }

    span.setAttribute('cache.hit', false);
    cacheMissCounter.inc({ pattern: key.split(':')[0] });
    const data = await fetcher();
    await redis.set(key, JSON.stringify(data), 'EX', ttl);
    return data;
  } finally {
    span.end();
  }
}

La conclusión

Redis es una de las herramientas más poderosas del arsenal de un ingeniero de backend, pero no es magia. Es una máquina de trade-offs: memoria por velocidad, consistencia por rendimiento, simplicidad por resiliencia. Toda decisión de caching debería empezar con “¿cuál es el costo de los datos desactualizados?” y “¿cuál es el costo de un cache miss?”, no con “cacheemos todo y esperemos lo mejor”.

Empieza con cache-aside para la mayoría de las cosas. Agrega write-through solo donde la consistencia importe. Usa invalidación basada en eventos para todo lo complejo. Monitorea tus hit rates religiosamente. Y nunca, jamás olvides: el cache miss más rápido es el que evitas al no cachear datos que no deberían cachearse en primer lugar.