Inicio / TypeScript / Conceptos de Backend / Caché: Estrategias y Patrones

Caché: Estrategias y Patrones

Cache-aside, write-through, invalidación por tags, Redis y distributed locks.

Avanzado
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Caché: Estrategias y Patrones

La caché es una capa de almacenamiento temporal de alta velocidad que guarda resultados de operaciones costosas para no repetirlas. Bien implementada, puede reducir la latencia de segundos a milisegundos.


Por qué usar caché

Sin caché:
Cliente → API → DB (consulta SQL costosa) → respuesta en 200ms

Con caché:
Cliente → API → Redis (hit) → respuesta en 2ms
                ↓ miss
               DB → Redis (store) → respuesta en 205ms

Cache-Aside (Lazy Loading)

El patrón más común. La aplicación gestiona la caché manualmente.

import Redis from 'ioredis';

const redis = new Redis({ host: 'localhost', port: 6379 });

class UserService {
  constructor(private userRepo: UserRepository) {}

  async findById(id: number): Promise<User | null> {
    const cacheKey = `user:${id}`;

    // 1. Buscar en caché
    const cached = await redis.get(cacheKey);
    if (cached) {
      console.log(`[CACHE HIT] ${cacheKey}`);
      return JSON.parse(cached) as User;
    }

    // 2. Cache miss: buscar en BD
    console.log(`[CACHE MISS] ${cacheKey}`);
    const user = await this.userRepo.findById(id);

    // 3. Guardar en caché (TTL: 5 minutos)
    if (user) {
      await redis.setex(cacheKey, 300, JSON.stringify(user));
    }

    return user;
  }

  async update(id: number, data: Partial<User>): Promise<User> {
    const user = await this.userRepo.update(id, data);

    // Invalida la caché al actualizar
    await redis.del(`user:${id}`);

    return user;
  }
}

Write-Through

La escritura va simultáneamente a la caché y a la base de datos. La caché siempre está actualizada.

class ProductService {
  async updatePrice(productId: number, newPriceCents: number): Promise<Product> {
    const cacheKey = `product:${productId}`;

    // Escribe en BD y caché al mismo tiempo
    const [product] = await Promise.all([
      this.productRepo.updatePrice(productId, newPriceCents),
      redis.setex(cacheKey, 3600, JSON.stringify({ id: productId, priceCents: newPriceCents })),
    ]);

    return product;
  }
}

Write-Behind (Write-Back)

La escritura va a la caché primero; la base de datos se actualiza de forma asíncrona. Mayor rendimiento, riesgo de pérdida de datos.

class AnalyticsService {
  private queue: Array<{ event: string; data: unknown }> = [];
  private flushInterval: NodeJS.Timeout;

  constructor() {
    // Persiste a BD cada 5 segundos
    this.flushInterval = setInterval(() => this.flush(), 5000);
  }

  track(event: string, data: unknown): void {
    // 1. Escribe en memoria inmediatamente (rápido)
    this.queue.push({ event, data });

    // 2. Actualiza contador en Redis (para tiempo real)
    redis.incr(`events:${event}:count`);
  }

  private async flush(): Promise<void> {
    if (!this.queue.length) return;
    const batch = this.queue.splice(0);  // vacía la cola
    // 3. Persiste en lote a BD (lento, pero asíncrono)
    await this.analyticsRepo.insertBatch(batch);
    console.log(`[Analytics] Flush: ${batch.length} eventos`);
  }
}

Estrategias de invalidación

"Hay solo dos cosas difíciles en informática: invalidar cachés y nombrar cosas" — Phil Karlton

class ProductCacheService {
  // Estrategia 1: TTL (Time To Live) — la más simple
  async cacheProduct(product: Product, ttlSeconds = 300): Promise<void> {
    await redis.setex(`product:${product.id}`, ttlSeconds, JSON.stringify(product));
  }

  // Estrategia 2: Invalidación activa al escribir
  async invalidateProduct(productId: number): Promise<void> {
    const keys = [
      `product:${productId}`,
      `product:${productId}:related`,
      `products:category:*`,     // patrón (cuidado: KEYS es lento en producción)
    ];
    // En producción, usa tagged caches o patrones con SCAN
    await redis.del(...keys);
  }

  // Estrategia 3: Cache Tags (invalidación por grupo)
  async setCached(key: string, value: unknown, tags: string[], ttl = 300): Promise<void> {
    const pipeline = redis.pipeline();
    pipeline.setex(key, ttl, JSON.stringify(value));

    // Guarda la clave en cada tag set
    for (const tag of tags) {
      pipeline.sadd(`tag:${tag}`, key);
      pipeline.expire(`tag:${tag}`, ttl + 60);
    }
    await pipeline.exec();
  }

  async invalidateByTag(tag: string): Promise<void> {
    const keys = await redis.smembers(`tag:${tag}`);
    if (keys.length) {
      await redis.del(...keys);
    }
    await redis.del(`tag:${tag}`);
  }
}

// Uso de cache tags
await cacheService.setCached(`product:1`, product, ['products', 'category:electronics']);
await cacheService.setCached(`product:2`, product2, ['products', 'category:electronics']);

// Invalida todos los productos de una categoría a la vez
await cacheService.invalidateByTag('category:electronics');

Memoización (caché en memoria de la app)

Para resultados de funciones puras y configuraciones que no cambian con frecuencia.

function memoize<T extends (...args: unknown[]) => unknown>(
  fn: T,
  keyFn: (...args: Parameters<T>) => string = (...args) => JSON.stringify(args)
): T {
  const cache = new Map<string, { value: ReturnType<T>; expires: number }>();

  return function(this: unknown, ...args: Parameters<T>): ReturnType<T> {
    const key     = keyFn(...args);
    const cached  = cache.get(key);
    const now     = Date.now();

    if (cached && cached.expires > now) {
      return cached.value;
    }

    const result = fn.apply(this, args) as ReturnType<T>;
    cache.set(key, { value: result, expires: now + 60_000 });  // 1 minuto
    return result;
  } as T;
}

// Uso
const getConfig = memoize(async (env: string) => {
  console.log(`Cargando config para ${env}...`);
  return { dbUrl: process.env.DATABASE_URL };
});

await getConfig('prod');  // carga
await getConfig('prod');  // retorna del cache

Patrones avanzados de Redis

// Distributed Lock (mutex distribuido)
async function withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
  const lockKey  = `lock:${key}`;
  const lockId   = crypto.randomUUID();
  const acquired = await redis.set(lockKey, lockId, 'PX', ttlMs, 'NX');

  if (!acquired) throw new Error(`No se pudo adquirir lock: ${key}`);

  try {
    return await fn();
  } finally {
    // Solo libera si somos el dueño del lock (script Lua para atomicidad)
    const script = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      else
        return 0
      end
    `;
    await redis.eval(script, 1, lockKey, lockId);
  }
}

// Uso: evita procesar la misma orden dos veces
await withLock(`order:${orderId}:process`, 30_000, async () => {
  await orderService.processPayment(orderId);
});

// Rate limiting con sliding window en Redis
async function checkRateLimit(userId: string, limit: number, windowSecs: number): Promise<boolean> {
  const key = `ratelimit:${userId}`;
  const now  = Date.now();
  const windowMs = windowSecs * 1000;

  const pipeline = redis.pipeline();
  pipeline.zremrangebyscore(key, 0, now - windowMs);  // elimina las antiguas
  pipeline.zadd(key, now, `${now}`);                  // añade la actual
  pipeline.zcard(key);                                // cuenta el total
  pipeline.expire(key, windowSecs);

  const results = await pipeline.exec();
  const count   = results?.[2]?.[1] as number;

  return count <= limit;
}

Cuándo usar cada estrategia

Estrategia Cuándo usarla
Cache-Aside Acceso a datos con lecturas frecuentes y escrituras moderadas
Write-Through Cuando la consistencia entre caché y BD es crítica
Write-Behind Eventos de alta frecuencia donde la pérdida mínima es aceptable
TTL corto Datos que cambian frecuentemente
TTL largo + invalidación activa Datos que cambian poco pero se leen mucho
Memoización Cómputos costosos con los mismos argumentos en la misma request
🔒

Ejercicio práctico disponible

Cache-Aside con Invalidación por Tags

Desbloquear ejercicios
// Cache-Aside con Invalidación por Tags
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes