TechLead
Leccion 4 de 30
9 min de lectura
Diseño de Sistemas

Estrategias de Caché

Domina las estrategias de caché incluyendo patrones cache-aside, read-through, write-through, políticas de desalojo, Redis vs Memcached e invalidación de caché

Por Qué Importa el Caché

El caché es la práctica de almacenar copias de datos frecuentemente accedidos en una capa de almacenamiento de alta velocidad para que las solicitudes futuras de esos datos puedan servirse más rápido. En el diseño de sistemas, el caché es una de las herramientas más efectivas para mejorar el rendimiento y reducir la carga en sistemas downstream.

Considera una aplicación web típica: cada carga de página podría requerir 10-20 consultas a la base de datos. Si tu sitio tiene 10,000 usuarios concurrentes, eso significa 100,000-200,000 consultas por ciclo de carga de página. Una capa de caché bien diseñada puede reducir la carga de la base de datos en un 80-95%.

Caché en Cada Capa

  • Caché del Navegador: Los navegadores cachean recursos estáticos (CSS, JS, imágenes) localmente basándose en cabeceras HTTP de caché.
  • Caché CDN: Los CDNs cachean contenido en ubicaciones edge geográficamente cercanas a los usuarios.
  • Caché de Aplicación: Cachés en memoria como Redis o Memcached almacenan resultados computados, datos de sesión y objetos frecuentes.
  • Caché de Base de Datos: Los cachés de consultas de base de datos y buffer pools cachean páginas de datos frecuentemente accedidas en memoria.
  • Caché de CPU: Los cachés L1/L2/L3 en el procesador almacenan direcciones de memoria frecuentemente accedidas.

Patrones de Caché

Cómo tu aplicación interactúa con el caché y la base de datos se define por el patrón de caché que elijas. Cada patrón tiene diferentes compensaciones en términos de consistencia, complejidad y rendimiento.

1. Cache-Aside (Carga Perezosa)

En el patrón cache-aside, la aplicación es responsable de leer y escribir tanto en el caché como en la base de datos. El caché no interactúa con la base de datos directamente.

Flujo de lectura: Verifica el caché primero. Si los datos se encuentran (cache hit), retórnalos. Si no (cache miss), lee de la base de datos, almacena el resultado en el caché y retórnalo.

Flujo de escritura: Escribe en la base de datos y luego invalida (elimina) la entrada del caché para que la siguiente lectura obtenga datos frescos.

// Cache-Aside Pattern Implementation
class UserService {
  private cache: Redis;
  private db: Database;

  async getUser(userId: string): Promise<User> {
    // Step 1: Check the cache
    const cached = await this.cache.get(`user:${userId}`);
    if (cached) {
      return JSON.parse(cached); // Cache hit
    }

    // Step 2: Cache miss - read from database
    const user = await this.db.query('SELECT * FROM users WHERE id = $1', [userId]);

    // Step 3: Store in cache with TTL
    await this.cache.setex(`user:${userId}`, 3600, JSON.stringify(user));

    return user;
  }

  async updateUser(userId: string, data: Partial<User>): Promise<void> {
    // Step 1: Update the database
    await this.db.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, userId]);

    // Step 2: Invalidate the cache
    await this.cache.del(`user:${userId}`);
    // Next read will fetch fresh data from DB and repopulate cache
  }
}

Ventajas: Solo los datos frecuentemente accedidos se cachean (uso eficiente de memoria), resiliente a fallos del caché (recurre a la BD), simple de implementar.

Desventajas: La primera solicitud de cualquier elemento siempre es un cache miss (arranque en frío), potencial de datos obsoletos entre escritura e invalidación.

2. Read-Through

En el patrón read-through, el caché se sitúa entre la aplicación y la base de datos. La aplicación solo interactúa con el caché. Cuando hay un cache miss, el propio caché es responsable de cargar los datos de la base de datos.

Esto es similar a cache-aside pero la diferencia clave es que la lógica de carga de datos vive en la capa de caché en lugar del código de la aplicación. Esto simplifica la aplicación y centraliza la lógica de caché.

3. Write-Through

En el patrón write-through, cada escritura va al caché primero, y el caché inmediatamente escribe los datos en la base de datos antes de retornar éxito al cliente. Esto asegura que el caché y la base de datos estén siempre sincronizados.

  • Ventajas: Fuerte consistencia entre caché y base de datos, sin datos obsoletos.
  • Desventajas: Mayor latencia de escritura (cada escritura impacta tanto caché como base de datos), el caché puede contener datos que nunca se leen.

4. Write-Behind (Write-Back)

En el patrón write-behind, la aplicación escribe datos en el caché, y el caché escribe asincrónicamente en la base de datos en segundo plano. Esto reduce dramáticamente la latencia de escritura porque la aplicación no espera la escritura en la base de datos.

// Write-Behind Pattern (conceptual implementation)
class WriteBehindCache {
  private cache: Map<string, any> = new Map();
  private writeQueue: Array<{ key: string; value: any }> = [];
  private flushInterval: number = 1000; // Flush every second

  constructor(private db: Database) {
    // Background flush to database
    setInterval(() => this.flushToDatabase(), this.flushInterval);
  }

  async write(key: string, value: any): Promise<void> {
    // Write to cache immediately (fast)
    this.cache.set(key, value);

    // Queue for async database write
    this.writeQueue.push({ key, value });
    // Returns immediately - does NOT wait for DB write
  }

  async read(key: string): Promise<any> {
    return this.cache.get(key); // Always read from cache
  }

  private async flushToDatabase(): Promise<void> {
    const batch = [...this.writeQueue];
    this.writeQueue = [];

    if (batch.length === 0) return;

    // Batch write to database for efficiency
    await this.db.batchInsert(batch);
  }
}

Advertencia: Riesgos del Write-Behind

Write-behind proporciona el mejor rendimiento de escritura pero conlleva un riesgo: si el caché se cae antes de escribir en la base de datos, pierdes datos. Usa este patrón solo cuando alguna pérdida de datos es aceptable (ej. analíticas, logging) o cuando tienes un caché durable (como Redis con persistencia AOF).

Políticas de Desalojo de Caché

Los cachés tienen memoria limitada, así que cuando el caché está lleno y se necesita almacenar un nuevo elemento, el caché debe decidir qué elemento existente eliminar. Esta decisión se rige por la política de desalojo.

Comparación de Políticas de Desalojo

Política Desaloja Mejor Para Debilidad
LRU (Menos Recientemente Usado)Elemento menos recientemente accedidoCargas generalesEscaneos únicos contaminan el caché
LFU (Menos Frecuentemente Usado)Elemento menos frecuentemente accedidoDatos calientes establesElementos antiguos populares persisten
FIFO (Primero en Entrar, Primero en Salir)Elemento más antiguo en cachéCasos de uso simplesIgnora patrones de acceso
TTL (Tiempo de Vida)Elemento después de duración especificadaDatos sensibles al tiempoNecesita ajuste cuidadoso del TTL
AleatorioElemento aleatorioPatrones de acceso uniformesComportamiento impredecible

LRU es la opción predeterminada para la mayoría de los sistemas porque funciona bien en la práctica: los elementos accedidos recientemente tienden a ser accedidos de nuevo pronto. Redis usa un algoritmo LRU aproximado muestreando un pequeño número de claves y desalojando la menos recientemente usada entre ellas.

Redis vs Memcached

Redis y Memcached son los dos sistemas de caché en memoria más populares. Aunque sirven propósitos similares, tienen diferencias significativas que afectan cuál deberías elegir.

Comparación Redis vs Memcached

Característica Redis Memcached
Estructuras de datosStrings, listas, conjuntos, conjuntos ordenados, hashes, streamsSolo strings
PersistenciaSnapshots RDB y log AOFNinguna (caché puro)
ReplicaciónPrimary-replica incorporadaSin replicación nativa
ClusteringRedis Cluster (incorporado)Sharding del lado del cliente
Pub/SubNo
Scripting LuaNo
HilosUn solo hilo (I/O multi-hilo desde 6.0)Multi-hilo
Mejor paraCaché complejo, sesiones, colas, tablas de clasificaciónCaché simple clave-valor a escala

Elige Redis cuando necesites estructuras de datos ricas, persistencia, pub/sub o replicación incorporada. Elige Memcached cuando necesites un caché multi-hilo simple para almacenamiento clave-valor a gran escala y no necesites características avanzadas.

Caché CDN

Una Red de Distribución de Contenido (CDN) cachea contenido en ubicaciones edge alrededor del mundo, sirviendo solicitudes desde la ubicación más cercana al usuario. Los CDNs son esenciales para reducir la latencia para usuarios globales y descargar tráfico de los servidores de origen.

  • Recursos estáticos: Imágenes, CSS, archivos JavaScript, fuentes y videos son candidatos perfectos para CDN.
  • Contenido dinámico: Las respuestas de API también pueden cachearse en el CDN con TTLs cortos (ej. 5-30 segundos) para ganancias significativas de rendimiento.
  • Cabeceras de caché: Controla el comportamiento del CDN usando cabeceras Cache-Control, ETag y Vary.
// Setting cache headers for CDN
app.get('/api/products', async (req, res) => {
  const products = await getProducts();

  // Cache in CDN for 60 seconds, allow stale for 300 seconds while revalidating
  res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
  res.set('CDN-Cache-Control', 'max-age=300'); // CDN-specific header (Cloudflare)

  res.json(products);
});

app.get('/api/user/profile', async (req, res) => {
  const profile = await getUserProfile(req.userId);

  // Private data - do NOT cache in CDN
  res.set('Cache-Control', 'private, no-store');

  res.json(profile);
});

Estrategias de Invalidación de Caché

Phil Karlton dijo famosamente: "Solo hay dos cosas difíciles en Ciencias de la Computación: la invalidación de caché y nombrar cosas." La invalidación de caché es el proceso de eliminar o actualizar datos obsoletos en el caché cuando los datos subyacentes cambian.

1. Expiración Basada en Tiempo (TTL)

Establece un tiempo de vida en cada entrada del caché. Después de que el TTL expira, la entrada se elimina automáticamente. Este es el enfoque más simple pero puede servir datos obsoletos hasta que el TTL expire.

2. Invalidación Basada en Eventos

Cuando los datos cambian en la base de datos, publica un evento que dispara la invalidación del caché. Esto proporciona consistencia casi en tiempo real pero requiere un sistema de eventos.

3. Invalidación Basada en Versión

Incluye un número de versión en la clave del caché. Cuando los datos cambian, incrementa la versión. Las entradas antiguas del caché se vuelven huérfanas naturalmente y eventualmente expiran.

// Event-based cache invalidation with Redis Pub/Sub
class CacheInvalidator {
  private redis: Redis;
  private subscriber: Redis;

  constructor() {
    this.redis = new Redis();
    this.subscriber = new Redis();

    // Listen for invalidation events
    this.subscriber.subscribe('cache-invalidation');
    this.subscriber.on('message', (channel, message) => {
      const { pattern } = JSON.parse(message);
      this.invalidatePattern(pattern);
    });
  }

  async invalidatePattern(pattern: string): Promise<void> {
    // Find all keys matching the pattern
    const keys = await this.redis.keys(pattern);
    if (keys.length > 0) {
      await this.redis.del(...keys);
    }
  }

  // Called when data changes
  async onDataChange(entityType: string, entityId: string): Promise<void> {
    // Publish invalidation event to all cache instances
    await this.redis.publish('cache-invalidation', JSON.stringify({
      pattern: `${entityType}:${entityId}:*`,
    }));
  }
}

Mejores Prácticas de Diseño de Caché

  • Establece TTLs en todo. Incluso si tienes invalidación activa, los TTLs sirven como red de seguridad contra errores.
  • Usa cache-aside para la mayoría de los casos. Es simple, efectivo y resiliente a fallos del caché.
  • Monitorea las tasas de aciertos. Un caché con baja tasa de aciertos está desperdiciando memoria. Apunta a tasas de aciertos del 90%+.
  • Evita la estampida del rebaño. Cuando una entrada popular del caché expira, cientos de solicitudes pueden golpear la base de datos simultáneamente. Usa bloqueo o stale-while-revalidate para prevenir esto.
  • Cachea en la granularidad correcta. Cachea objetos individuales en lugar de páginas enteras cuando sea posible, para invalidación más precisa.

Continuar Aprendiendo