BackendIn-MemoryPlataforma de Despliegue CDNAnti-stampede

️ Sistema de Caché

Dos capas de defensa para proteger Base de Datos Primaria de picos de tráfico: caché en memoria dentro del proceso Node.js de Plataforma de Despliegue, y caché HTTP en el CDN de Plataforma de Despliegue. Cada capa tiene su propósito.

Arquitectura: dos capas

Petición del Usuario

Capa 1: Plataforma de Despliegue Edge CDN

Cache-Control: s-maxage + stale-while-revalidate
Gratis, distribuida globalmente, sin costo de proceso
↓ Solo si el CDN no tiene copia (cache miss)

Capa 2: In-Memory Cache (cache.ts)

Map<string, {data, expiresAt}> en el proceso de Node.js
Anti-stampede con Promise deduplication
↓ Solo si la memoria expiró (TTL)

Base de Datos Primaria DB (Base de Datos SQL en el edge)

La única consulta real a la base de datos
Ejemplo: con 1000 usuarios simultáneos y TTL de 60s, Base de Datos Primaria recibe1 query por minuto, no 1000. El CDN sirve la copia a la mayoría. El in-memory sirve a los que el CDN no alcanzó. Base de Datos Primaria solo ve el primero.

Anti-stampede: el problema y la solución

Cuando el caché expira y 100 usuarios llegan al mismo tiempo, sin protección todos harían 100 queries simultáneas a Base de Datos Primaria. Esto se llama cache stampede o thundering herd.

Sin anti-stampede

  • Cache expira
  • 100 usuarios llegan
  • 100 queries a Base de Datos Primaria simultáneas
  • Base de Datos Primaria se satura → errores 500

Con Promise deduplication

  • Cache expira
  • 100 usuarios llegan
  • Usuario 1 inicia fetch → guarda Promise pending
  • Usuarios 2–100 reciben la misma Promise
  • Solo 1 query llega a Base de Datos Primaria
// Extracto de cache.ts — el corazón del anti-stampede
const pending = new Map<string, Promise<unknown>>();

if (pending.has(key)) {
    return pending.get(key);  // Reutiliza la Promise existente
}

const promise = fetcher().then((data) => {
    store.set(key, { data, expiresAt: now + ttl * 1000 });
    pending.delete(key);
    return data;
});

pending.set(key, promise);
return promise;

API de cache.ts

cached() — obtener o regenerar

import { cached } from '../../lib/cache';

const data = await cached(
    'home-posts-v1',    // Clave única (incluir versión para cache-busting)
    60,                  // TTL en segundos
    async () => {        // Función que obtiene datos frescos
        return await db.select().from(Post).limit(10);
    }
);

invalidate() — borrar una clave

import { invalidate } from '../../lib/cache';

// Al publicar o editar un post:
invalidate('home-posts-v1');
invalidate(`post-${slug}-v1`);

invalidatePrefix() — borrar todas las claves con un prefijo

import { invalidatePrefix } from '../../lib/cache';

// Al editar cualquier post, borrar todo el caché de blog:
invalidatePrefix('blog-');
invalidatePrefix('post-'); // Borra post-slug1, post-slug2, etc.

TTLs por tipo de dato

Endpoint / DatoTTL In-Memorys-maxage CDNstale-while-revalidate
/api/trending600s (10 min)600s3600s (1h)
Posts en home60s60s360s
Post individual por slug60s60s360s
Lista de categorías300s300s1800s
Datos de anime embed (AniList)86400s (24h)
HTML generado de embed86400s (24h)
Datos de cine (Cinemeta)86400s (24h)
Página "Nosotros"86400s604800s (1 semana)

stale-while-revalidate: el CDN sirve la copia expirada mientras regenera en background. El usuario nunca espera.

Versionado de claves para cache-busting

Al cambiar el diseño de un embed o el formato de los datos, la clave en caché puede tener datos del formato viejo. La solución es versionar la clave:

// Versión vieja — datos del diseño anterior
cached('anime-embed-html-v8-' + animeId, ...)

// Versión nueva — fuerza regeneración para todos los items
cached('anime-embed-html-v9-' + animeId, ...)
Cambiar el número de versión invalida el caché de todos los embeds del tipo. Es el equivalente a un "purge all" selectivo sin necesidad de tocar el CDN.

Plantilla de endpoint cacheado

import type { APIRoute } from 'astro';
import { cached } from '../../lib/cache';

const CACHE_TTL = 60; // segundos

async function fetchData() {
    // Tu query a Base de Datos Primaria aquí
    const posts = await db.select().from(Post).limit(10);
    return posts;
}

export const GET: APIRoute = async () => {
    const data = await cached('mi-endpoint-v1', CACHE_TTL, fetchData);

    return new Response(JSON.stringify(data), {
        status: 200,
        headers: {
            'Content-Type': 'application/json',
            'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 6}`,
        },
    });
};

Diagnóstico

Post editado no aparece en producción

Causa: La capa CDN no expiró aún.

Solución: Llamar invalidate() al editar, o purgar desde Plataforma de Despliegue Dashboard → Deployments.

Embed muestra diseño antiguo

Causa: Clave de caché sin incrementar versión.

Solución: Cambiar "html-v9" → "html-v10" en la clave de cached().

Base de Datos Primaria recibe miles de queries

Causa: cached() se llama con claves distintas cada vez.

Solución: Verificar que la clave sea determinista (no incluir timestamps ni random).