Performance

Estrategias de Caching

El caching es la forma en que los sistemas mantienen su velocidad bajo carga. Los patrones son simples; lo difícil es saber qué conservar, por cuánto tiempo y cuándo descartarlo.

intermediate15 min readUpdated 16 sept 2026
cache-aside.ts
ts
// cache-aside.ts
export async function getProduct(id: string): Promise<Product> {
  const key = `product:${id}`;

  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached) as Product;

  const product = await db.product.findUniqueOrThrow({ where: { id } });
  await redis.set(key, JSON.stringify(product), { EX: 300 });
  return product;
}
Objetivo principal
Menor latencia y carga
Patrón predeterminado
Cache-aside
TTL típico
60s a 1h
Almacén común
Redis o in-process
La parte difícil
Invalidación
Capa de borde
CDN más cabeceras HTTP

Por que importa

Por qué el caching cambia todo el sistema

Latencia perceptible

Un cache hit retorna en mucho menos de un milisegundo, mientras que el viaje de ida y vuelta a la base de datos que reemplaza cuesta decenas de milisegundos. En una página que lee veinte elementos, esa diferencia define toda la experiencia.

Carga que la base de datos nunca ve

Servir la mayoría de las lecturas desde la caché puede reducir las consultas a la base de datos en un orden de magnitud, lo que otorga margen de maniobra sin tener que migrar a una instancia más grande.

Una idea, muchas capas

El mismo razonamiento se aplica en el navegador, en el CDN, dentro de la aplicación y en la base de datos, cada uno con su propia historia de frescura e invalidación.

La imagen completa

Las tres ideas detrás de cada caché

Almacena una copia más cerca del lector, sírvela hasta que quede obsoleta y decide deliberadamente cómo debe salir.

Ubicación

Localizar

Coloca la copia lo más cerca posible del lector que la corrección permita. Cuanto más cerca, más rápido, pero más difícil de invalidar.

Almacenamiento

Servir

Un almacén en memoria responde a las lecturas sin tocar el disco, y uno compartido permite que cada instancia vea los mismos datos.

Frescura

Expirar

Cada valor almacenado necesita una regla sobre cuánto tiempo permanece válido y cómo se reemplaza cuando deja de serlo.

HTML5 de un vistazo

Dónde puede residir una caché

Caché del navegador

Cache-Control y ETag permiten que el cliente reutilice una respuesta sin preguntar al servidor.

CDN y edge

Las cachés compartidas en el edge absorben el tráfico de respuestas públicas y cacheables.

Memoria de la aplicación

Un Map in-process o una caché LRU es el nivel más rápido, pero vive y muere con cada instancia.

Redis o Memcached

Un almacén en memoria compartido se sitúa junto a la app y sirve a cada instancia de manera consistente.

Caché de base de datos

Los buffer pools, vistas materializadas y planes preparados mantienen la fuente misma veloz.

Invalidación

El TTL, el versionado de claves o los busts explícitos deciden cuándo deja de servirse una copia obsoleta.

Flujo

Cómo funciona una lectura cache-aside

Se consulta primero la caché, y solo un miss llega a la base de datos.

  1. 1

    Consultar la caché

    Construye una clave determinista y solicítala a Redis o al almacén in-process.

  2. 2

    Miss

    La clave no existe, por lo que la caché no puede responder. Este es el momento que requiere trabajo real.

  3. 3

    Cargar desde la fuente

    Consulta la base de datos o recalcula el valor costoso exactamente una vez.

  4. 4

    Escribir de vuelta con un TTL

    Almacena el resultado bajo la misma clave con un tiempo de expiración, para que la siguiente lectura sea un hit.

  5. 5

    Retornar y servir hits

    Lecturas posteriores encuentran el valor en caché y omiten la fuente hasta que el TTL expire o una actualización invalide la clave.

La guia completa

Estrategias de Caching: Todo lo que necesitas saber

¿Por qué usar caché?

Una caché es una copia de datos almacenada en un lugar más accesible y rápido que la fuente original. Cada decisión de implementar caché es, en realidad, una apuesta: que el mismo valor será solicitado nuevamente antes de que cambie, y que servir una copia ligeramente desactualizada es aceptable. Cuando ambas condiciones se cumplen, la recompensa es enorme.

Un solo acierto (hit) genera tres efectos:

  • Latencia. Una consulta en Redis responde en mucho menos de un milisegundo; la misma fila en PostgreSQL requiere varios milisegundos de red, planificación y E/S. En una página que lee veinte elementos, esa diferencia define toda la experiencia.
  • Carga. Si el 95% de las lecturas se sirven desde la caché, la base de datos procesa una sola consulta donde antes procesaba veinte. Esto permite sobrevivir a picos de tráfico o ejecutar una instancia más pequeña sin cambiar una sola línea de la lógica de las consultas.
  • Coste. Menos lecturas de base de datos significan instancias más pequeñas, menos réplicas de lectura y menos tráfico entre regiones. A escala, el almacenamiento en caché es una de las pocas optimizaciones que se paga sola en términos monetarios.

El precio a pagar es la complejidad. Una caché es una segunda copia de la verdad, y cada copia puede entrar en conflicto. La mayor parte de esta guía trata sobre cómo mantener ese conflicto al mínimo y de forma temporal.

Dónde puede residir una caché

El almacenamiento en caché no es un único sistema, sino un stack de capas; cada una está más cerca del lector y cada una tiene una lógica de invalidación diferente.

  • El navegador. Las respuestas HTTP marcadas como Cache-Control: max-age se reutilizan sin realizar una petición. Esto es gratuito, privado y está mayormente bajo el control del cliente.
  • El CDN o edge. Una caché compartida delante de tu origen absorbe el tráfico de las respuestas públicas. Es rápida y global, pero un error aquí es visible para todo el mundo.
  • La aplicación. Una caché en proceso (un Map, un LRU) es el nivel más rápido porque evita la red por completo. También es por instancia, por lo que debe ser pequeña y desechable.
  • Un almacén compartido. Redis o Memcached se sitúan junto a la aplicación y sirven a cada instancia desde una copia consistente. Aquí es donde reside la mayor parte de la caché de aplicación.
  • La base de datos. Los buffer pools, las vistas materializadas, los planes preparados y las cachés de resultados de consultas mantienen la fuente rápida por sí misma. Son la última línea antes del disco.

Una petición puede ser respondida en cualquiera de estas capas. Cuanto más cerca esté la respuesta, más rápida será y más difícil será de invalidar, lo cual es la tensión central de todo este tema.

Cache hit rate: la métrica decisiva

La salud de una caché se mide a través de su hit rate: los aciertos (hits) divididos entre la suma de aciertos y fallos (misses). Es el único número que te indica si la caché está siendo útil.

hit rate = hits / (hits + misses)

La aritmética es drástica. Con un hit rate del 90%, la base de datos recibe una de cada diez solicitudes, lo que supone una reducción de 10x. Al 50%, recibe una de cada dos, solo una reducción de 2x, y la caché ha añadido un salto de red a la mitad del tráfico. Una caché con un hit rate bajo puede resultar más lenta que no tener ninguna caché.

Mídelo directamente desde el almacenamiento. Redis reporta keyspace_hits y keyspace_misses en INFO stats, y las librerías de cliente exponen los mismos contadores. Vigila el hit rate después de cada cambio en una clave, un TTL o una consulta; un pequeño cambio en una clave puede desplomar silenciosamente una caché del 95% al 40%.

El corolario es que debes cachear elementos que realmente se repitan. Una clave única por solicitud tiene, por definición, un hit rate del 0% y solo sirve para desperdiciar memoria.

Los cuatro patrones principales

Casi cualquier implementación de caché sigue una de estas cuatro estructuras. Se diferencian en quién llena la caché y cuándo se actualiza.

Cache-aside (carga perezosa)

La aplicación es la dueña de la lógica. En una lectura, verifica la caché y, si hay un fallo (miss), carga los datos desde la fuente y escribe el resultado de vuelta. Este es el patrón por defecto porque es sencillo, funciona con cualquier almacén y solo almacena en caché los datos que realmente se solicitan.

async function getUser(id: string) {
  const key = `user:${id}`;

  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);

  const user = await db.user.findUniqueOrThrow({ where: { id } });
  await redis.set(key, JSON.stringify(user), "EX", 600);
  return user;
}

El coste es que el primer lector siempre paga el precio completo, y la caché puede contener datos obsoletos hasta que expire su TTL o una escritura los elimine.

Read-through

Read-through desplaza la alternativa de respaldo (fallback) a la propia capa de caché: la aplicación siempre consulta la caché, y esta está configurada con un cargador que se ejecuta en caso de fallo. Algunas librerías y proxies implementan esto, por lo que el código de la aplicación no tiene una ruta de fallo explícita. El comportamiento es el mismo que en cache-aside; solo cambia la ubicación de la lógica.

Write-through

Write-through actualiza la caché y la fuente en la misma operación. Las lecturas siempre están “calientes” y nunca ven datos más antiguos que la última escritura exitosa.

async function updateUser(id: string, patch: Partial<User>) {
  const user = await db.user.update({ where: { id }, data: patch });
  await redis.set(`user:${id}`, JSON.stringify(user), "EX", 600);
  return user;
}

El coste es la latencia de escritura y que almacena en caché datos que podrían no leerse nunca, pero mantiene la caché consistente con la ruta de escritura, que es exactamente lo que necesitan las actualizaciones orientadas al usuario.

Write-behind (write-back)

Write-behind escribe en la caché inmediatamente y vuelca los datos a la fuente de forma asíncrona. Las escrituras son muy rápidas y pueden agruparse (batching), lo cual es valioso para contadores y telemetría. El riesgo es grave: si la caché muere antes del volcado, la escritura se pierde. Úsalo solo donde sea aceptable perder las últimas escrituras, y nunca para transacciones monetarias o registros oficiales.

TTLs y frescura

Cada valor almacenado en caché debe tener un time to live (TTL). El TTL es un presupuesto de obsolescencia: es el tiempo máximo que un lector puede ver un valor que ya no es válido en la fuente. Elegirlo es tanto una decisión de producto como técnica.

  • Precios e inventario: segundos. Un precio incorrecto se traduce en un ticket de soporte.
  • Perfiles, feeds y listados: minutos. Una pequeña desviación es invisible.
  • Datos de referencia, feature flags y configuración: de minutos a horas.
  • Assets estáticos y contenido que cambia raramente: días, versionados por nombre de archivo.

Añade jitter al TTL. Si mil claves se escriben en el mismo momento con el mismo TTL, expirarán juntas y producirán un pico de carga. Aleatorizar un pequeño porcentaje distribuye las recargas.

const ttl = 600 + Math.floor(Math.random() * 60);
await redis.set(key, JSON.stringify(value), "EX", ttl);

Incluso con una invalidación explícita, mantén un TTL como red de seguridad. Un bug que olvide eliminar una clave debería costarte unos minutos de obsolescencia, no una mentira permanente.

Invalidación: la parte difícil

Hay una razón por la cual la invalidación de caché es el remate del chiste más antiguo de la informática. Almacenar el valor es trivial, pero eliminarlo en el momento justo, de cada capa y sin condiciones de carrera, es genuinamente difícil.

Tres técnicas cubren casi todos los casos.

Versionado de claves. En lugar de eliminar, cambia la clave. Añade un prefijo de versión a las claves que incrementes cuando los datos subyacentes cambien; así, las entradas antiguas se vuelven inaccesibles y expiran por sí solas.

const version = (await redis.get(`user:${id}:v`)) ?? "1";
const key = `user:${id}:v${version}`;

Este método está libre de condiciones de carrera y funciona entre instancias, razón por la cual es el enfoque preferido para cualquier recurso compartido.

Invalidación explícita (bust). Elimina la clave al escribir. Es el enfoque más obvio y el más fácil de implementar mal, ya que cada ruta de escritura debe recordar hacerlo.

await db.user.update({ where: { id }, data: patch });
await redis.del(`user:${id}`);

Invalidación basada en eventos. Publica un evento de cambio y deja que cada instancia y capa reaccione. Así es como se mantiene la coherencia en una purga de CDN o en una caché de múltiples servicios, y es escalable a sistemas que no controlas.

Cualquiera que sea tu elección, sé consistente con el orden y prefiere eliminar antes que sobrescribir durante una actualización. Eliminar es idempotente; sobrescribir puede resucitar un valor que un escritor concurrente ya había superado.

Cache stampede y thundering herd

Cuando una clave popular expira, todas las solicitudes en curso fallan en el mismo instante. Si llegan mil solicitudes por segundo y la clave tarda 200 ms en reconstruirse, cientos de consultas idénticas golpean la base de datos simultáneamente. Esto es un cache stampede, también llamado thundering herd, y puede tumbar un servicio que estaba saludable un momento antes.

Cuatro mitigaciones, en orden aproximado de preferencia:

  • Stale-while-revalidate. Sirve el valor obsoleto inmediatamente y actualízalo en segundo plano. Los lectores nunca esperan y la fuente recibe una sola actualización. HTTP tiene un encabezado exactamente para esto.
  • Locking o single-flight. Solo el primer llamador vuelve a computar; todos los demás esperan brevemente o reciben datos obsoletos. Redis SET key value NX EX es un bloqueo distribuido sencillo.
  • TTLs con jitter. Distribuye las expiraciones para que todas las claves no mueran al mismo tiempo.
  • Expiración temprana probabilística. Cada lectura tiene una probabilidad pequeña y creciente de actualizarse antes de que la clave expire realmente, de modo que la actualización se distribuya entre muchas solicitudes.

El bloqueo siempre debe tener una expiración, o de lo contrario, un worker que falle dejará la clave bloqueada para siempre.

Qué cachear y qué no hacer jamás

Cachea los datos que sean costosos de producir, que se lean con mucha más frecuencia de la que cambian, que toleren un ligero desfase (staleness) y que sean compartidos por muchos lectores. Los listados de productos, perfiles públicos, configuraciones, fragmentos renderizados y agregados costosos son buenos candidatos.

Nunca cachees:

  • Decisiones de autorización en una caché compartida. Un cambio de rol o el cierre de sesión deben surtir efecto inmediatamente, y un CDN jamás debe servir la respuesta privada de un usuario a otro.
  • Datos sensibles por usuario bajo una clave compartida. Cada valor específico de usuario necesita una clave que incluya al usuario, y una directiva de caché private si llega a alcanzar el edge.
  • Secretos y credenciales. Una caché es otro lugar desde donde pueden filtrarse.
  • Datos que no puedas invalidar. Si un valor no tiene una clave natural ni un evento asociado, la caché eventualmente servirá algo incorrecto sin forma de solucionarlo.

Una prueba útil: si no puedes describir cómo sale un valor de la caché, no lo pongas ahí.

Almacenamiento en caché de HTTP en el edge

La caché más económica es aquella que nunca llega a tu servidor. HTTP te ofrece un control preciso sobre ella.

Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=30
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Vary: Accept-Encoding

max-age define cuánto tiempo puede el navegador reutilizar la respuesta. s-maxage lo anula para cachés compartidas, como un CDN. stale-while-revalidate permite que el edge sirva una copia obsoleta mientras obtiene una actualizada en segundo plano, lo que evita las avalanchas de peticiones (stampedes) para contenido público. private y no-store mantienen las respuestas sensibles totalmente fuera de las cachés compartidas.

Un ETag permite realizar peticiones condicionales. El cliente envía If-None-Match y, si la etiqueta aún coincide, respondes con un 304 Not Modified sin cuerpo, ahorrando ancho de banda y garantizando que el contenido esté actualizado.

app.get("/posts", async (req, res) => {
  const body = JSON.stringify(await listPosts());
  const tag = `"${createHash("sha256").update(body).digest("hex")}"`;

  res.set("Cache-Control", "public, max-age=30, stale-while-revalidate=60");
  res.set("ETag", tag);

  if (req.headers["if-none-match"] === tag) return res.status(304).end();
  res.type("application/json").send(body);
});

Vary es fácil de olvidar y es importante: si una respuesta depende de Accept-Encoding o Accept-Language, indícalo; de lo contrario, una caché podría entregar la variante incorrecta al cliente equivocado.

In-memory vs Redis vs CDN

Las tres capas compartidas no son competidoras; son una jerarquía.

Un caché in-process es la búsqueda más rápida posible porque nunca atraviesa la red. Es ideal para datos pequeños, muy solicitados y mayormente de lectura, como configuraciones o un mapa de permisos. Sus limitaciones son que cada instancia tiene su propia copia (por lo que la memoria se multiplica y los valores pueden divergir) y que desaparece al hacer un deploy.

Redis es el caché compartido de la aplicación. Una única copia lógica sirve a todas las instancias, sobrevive a los reinicios y soporta TTLs, operaciones atómicas y estructuras de datos más allá de simples strings. Su coste es un viaje de ida y vuelta por la red, que suele ser inferior a un milisegundo dentro de la misma red.

Un CDN es la capa más externa. Almacena en caché respuestas públicas cerca de los usuarios en todo el mundo y puede absorber un tráfico enorme antes de que llegue a ti. Solo funciona para respuestas que son idénticas para muchos usuarios, y purgarlo es una operación deliberada y, a veces, lenta.

Una estructura común en producción es un pequeño caché in-process delante de Redis para las claves más solicitadas, con un CDN delante de toda la API para las peticiones GET públicas.

Caching negativo

Normalmente se describe que las cachés almacenan valores, pero almacenar la ausencia de un valor es igual de útil. Si una consulta para un registro inexistente es costosa y se repite, almacena el fallo (miss).

const hit = await redis.get(key);
if (hit === "__miss__") return null;

const user = await db.user.findUnique({ where: { id } });
if (!user) {
  await redis.set(key, "__miss__", "EX", 60);
  return null;
}

Mantén los TTL negativos cortos, ya que un registro que no existía hace un minuto podría existir ahora. Este patrón protege contra la cache penetration, que ocurre cuando una avalancha de solicitudes de claves inexistentes evade la caché y golpea la base de datos en cada ocasión. Protégete de un atacante que genere infinitas claves inexistentes únicas validando la entrada y aplicando rate limiting antes de llegar a la caché.

Consistencia y los dos problemas difíciles

Una caché hace que un sistema sea eventualmente consistente: durante un breve periodo, diferentes lectores pueden ver valores distintos. Por lo general, esto no es un problema, pero hay situaciones en las que sí lo es.

  • Read-your-writes. Un usuario que acaba de actualizar su perfil espera ver el cambio. Invalida la caché al escribir, o lee directamente de la fuente durante un corto periodo después de que el mismo usuario haya realizado una escritura.
  • Replication lag. Si las lecturas se dirigen a una réplica, una caché llenada desde dicha réplica puede tener un retraso respecto a la primaria. Invalida la caché desde la ruta de escritura de la primaria.
  • Cachés entre regiones. Una purga en una región no llega instantáneamente a otra. Utiliza claves versionadas o acepta el retraso de propagación.

El planteamiento honesto es que el almacenamiento en caché intercambia consistencia por velocidad, y la única forma de hacerlo de manera segura es decidir explícitamente qué nivel de obsolescencia es aceptable e integrar la invalidación en la ruta de escritura en lugar de añadirla como un parche posterior.

Las claves de caché son una API

Una clave de caché parece un detalle de implementación, pero se comporta como una interfaz pública. Es el punto de acuerdo entre cada lector y escritor, y es visible en Redis, en los slow logs y en los dashboards. Diseña las claves deliberadamente.

Tres reglas para mantener la coherencia:

  • Usa namespaces por versión y entorno. v1:user:42 te permite cambiar la estructura más adelante y evita que staging comparta claves con producción.
  • Sé determinista. La misma búsqueda lógica siempre debe producir la misma cadena de texto. Normaliza las mayúsculas y minúsculas, recorta los espacios de entrada y nunca incluyas un valor que cambie en cada llamada.
  • Nunca pongas secretos ni datos personales en una clave. Las claves se registran en logs, se exportan y son visibles para los operadores.
export const cacheKeys = {
  user: (id: string) => `v1:user:${id}`,
  userPosts: (id: string, cursor: string) => `v1:user:${id}:posts:${cursor}`,
  productBySku: (sku: string) => `v1:product:sku:${sku.trim().toLowerCase()}`,
};

Centralizar la construcción de claves en un solo módulo es lo que hace posible la invalidación. Cuando una escritura necesita eliminar una clave, llama a la misma función que utilizó la lectura; cuando la estructura cambia, hay un único lugar para actualizar la versión.

Políticas de desalojo (Eviction policies)

Una caché a la que se le permite crecer sin límites es, básicamente, una fuga de memoria con pasos adicionales. Tanto Redis como las cachés en proceso necesitan un límite y una política sobre qué eliminar cuando se alcanza dicho límite.

Redis ofrece maxmemory y maxmemory-policy. La opción habitual para una caché pura es allkeys-lru, que desaloja la clave menos utilizada recientemente, o allkeys-lfu, que favorece las claves utilizadas frecuentemente cuando los patrones de acceso están sesgados.

redis-cli CONFIG SET maxmemory 2gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru

Nunca utilices noeviction para una caché: una vez que la memoria esté llena, las escrituras fallarán y tu aplicación empezará a lanzar errores en lugar de simplemente fallar en la búsqueda (cache miss). Para las cachés en proceso, utiliza una librería LRU acotada en lugar de un Map simple, y define tanto un número máximo de entradas como un tamaño máximo.

Los desalojos no son fallos, pero un aumento en el recuento de desalojos es una señal. Significa que el conjunto de trabajo ya no cabe y que la tasa de aciertos (hit rate) está a punto de caer. La solución es asignar más memoria a la caché o cachear menos elementos.

Cache warming

Una caché está más fría en el momento más peligroso: justo después de un deploy, un reinicio o un escalado. Cada instancia comienza vacía, el tráfico llega a pleno volumen y la fuente de datos recibe toda la carga hasta que la caché se llena. Esto es un stampede provocado por tu propio despliegue.

Existen dos enfoques. El lazy warming acepta la ventana de caché fría y confía en el bloqueo (locking) y en el stale-while-revalidate para sobrevivir. Es sencillo y no requiere maquinaria adicional. El proactive warming ejecuta una tarea que carga las claves conocidas como “hot keys” antes de que la nueva versión reciba tráfico.

async function warmCache() {
  const popular = await db.product.findMany({
    orderBy: { views: "desc" },
    take: 500,
    select: { id: true },
  });

  for (const { id } of popular) {
    await cached(`product:${id}`, 300, () =>
      db.product.findUniqueOrThrow({ where: { id } }),
    );
  }
}

Calienta solo aquello que sabes que es demandado. Calentar todo es simplemente una copia lenta y costosa de la base de datos que, en su mayoría, será expulsada sin haber sido utilizada.

Observabilidad para cachés

Una caché que no mides es una caché en la que no puedes confiar. Hay cuatro métricas que cuentan la historia completa:

  • Hit rate — ¿se están sirviendo realmente las lecturas desde la caché?
  • Latencia — ¿es un hit significativamente más económico que la fuente original, incluyendo el salto de red?
  • Evictions — ¿el conjunto de datos activos está superando la capacidad de la memoria?
  • Errores y timeouts — ¿se está convirtiendo la caché en una fuente de fallos?
redis-cli INFO stats | grep -E 'keyspace_(hits|misses)|evicted_keys'
redis-cli INFO memory | grep used_memory_human

Emite también un contador de hits y misses desde la aplicación, etiquetado por el nombre de la caché. Esto es lo que te permite correlacionar una caída en el hit-rate con un deploy, y es lo primero que debes revisar cuando la carga de la base de datos aumenta sin una razón obvia.

Degradación progresiva cuando el cache no está disponible

Si la pérdida del cache provoca la caída de la API, el cache se ha convertido en un punto único de fallo para un sistema cuyo propósito fundamental es ser opcional. La fuente de verdad sigue existiendo; la aplicación debería ser capaz de acceder a ella.

Envuelve cada operación de cache para que un fallo se convierta en un miss en lugar de un error, mantén los timeouts cortos y considera implementar un circuit breaker que deje de llamar a un cache que esté fallando durante un periodo de enfriamiento.

async function safeGet(key: string) {
  try {
    return await redis.get(key);
  } catch (err) {
    logger.warn({ err, key }, "cache_read_failed");
    return null;
  }
}

Lo mismo se aplica a las escrituras en el cache: un set fallido nunca debería hacer que la solicitud falle. La única excepción es el write-behind, donde el cache es parte de la ruta de escritura; esa es otra razón para reservarlo únicamente para datos que te puedas permitir perder.

Cache de consultas costosas y agregados

No todo lo que vale la pena cachear es una sola fila. Los joins costosos, los agregados de dashboards y los resultados de búsqueda suelen ser los mejores candidatos, ya que son lentos de computar y cambian con poca frecuencia.

Una vista materializada es un cache que reside en la base de datos: almacena el resultado de una consulta y se actualiza según un horario o bajo demanda. Te ofrece la frescura de un batch job y la velocidad de lectura de una tabla.

CREATE MATERIALIZED VIEW daily_revenue AS
SELECT date_trunc('day', created_at) AS day,
       sum(total_cents)             AS revenue_cents
FROM orders
WHERE status = 'paid'
GROUP BY 1;

REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;

Para resultados que son demasiado dinámicos para una vista, cachea la respuesta serializada en Redis bajo una clave que incluya cada entrada que la afecte: filtros, orden de clasificación y página. Dos solicitudes para páginas diferentes representan valores distintos, por lo que deben tener claves diferentes.

Probando una caché

Los errores de caché son de esos que pasan las pruebas pero fallan en producción, así que prueba explícitamente los comportamientos que importan: la ruta de fallo (miss), la ruta de acierto (hit), la invalidación y el error.

test("loads once and serves the second read from cache", async () => {
  let calls = 0;
  const load = async () => {
    calls += 1;
    return { id: "1" };
  };

  await cached("user:1", 60, load);
  await cached("user:1", 60, load);

  expect(calls).toBe(1);
});

test("falls back to the source when the cache is unavailable", async () => {
  redis.get = async () => {
    throw new Error("connection_refused");
  };

  await expect(cached("user:1", 60, load)).resolves.toEqual({ id: "1" });
});

Usa un Redis real en un contenedor para las pruebas de integración, o un fake en memoria que implemente get, set y del. Prueba también los casos negativos: una actualización que debería invalidar, un TTL que debería expirar y una caída de la caché que debería degradar el servicio en lugar de fallar por completo.

Mejores prácticas

  • Implementa el caché solo después de medir; añade un caché a una lectura lenta y repetitiva, no por defecto.
  • Asigna un TTL a cada clave, incluso cuando realices invalidaciones explícitas.
  • Utiliza claves deterministas y con namespaces, como user:42:profile, y asígnales una versión para los datos compartidos.
  • Prefiere eliminar o versionar las claves en lugar de sobrescribirlas al actualizar.
  • Añade jitter a los TTL y utiliza stale-while-revalidate para sobrevivir a los stampedes.
  • Mantén los datos de autorización y los datos por usuario fuera de los cachés compartidos; marca las respuestas como private.
  • Trata el caché como algo opcional en el código para que una caída del servicio degrade la experiencia en lugar de provocar un fallo total.
  • Monitoriza la tasa de aciertos (hit rate) y el recuento de evicciones, no solo la latencia.
  • Almacena en caché tanto los aciertos como los fallos (misses) cuando las búsquedas de datos ausentes sean costosas.

Errores comunes

  • Implementar caching sin un TTL y depender de una purga manual que nunca ocurre.
  • Compartir una misma clave entre usuarios y filtrar los datos de una persona a otra.
  • Invalidar el caché en algunas rutas de escritura pero no en otras.
  • Establecer el mismo TTL para todas las claves, provocando que todas expiren al mismo tiempo.
  • Cachear una decisión de autorización que debería haber sido revocada tras un cambio de rol.
  • Usar el caché como base de datos y perder escrituras cuando se reinicia.
  • Olvidar Vary y servir una codificación de contenido incorrecta desde un CDN.
  • No medir nada, haciendo que un cambio en una clave reduzca la tasa de aciertos (hit rate) sin que nadie lo note.
  • Cachear algo que es único por solicitud, lo que garantiza una tasa de aciertos del 0%.

Próximos pasos

El almacenamiento en caché es una herramienta para controlar la carga; sus complementos naturales son aquellos que limitan el volumen de trabajo. Redis profundiza en el almacén sobre el cual se construyen la mayoría de las cachés, y la Paginación es la versión a nivel de solicitud de la misma idea: nunca hagas más trabajo del estrictamente necesario. Si la base de datos detrás de la caché es el cuello de botella, el Connection Pooling reduce el coste de cada conexión, y PostgreSQL cubre la fuente de verdad que estás protegiendo.

En la practica

Cuatro tareas de caching que escribirás primero

El helper, la ruta de escritura, la protección contra stampede y el HTTP edge.

lib/cache.ts
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

export async function cached<T>(
  key: string,
  ttlSeconds: number,
  load: () => Promise<T>,
): Promise<T> {
  const hit = await redis.get(key);
  if (hit !== null) return JSON.parse(hit) as T;

  const value = await load();
  await redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
  return value;
}

// const product = await cached(`product:${id}`, 300, () =>
//   db.product.findUniqueOrThrow({ where: { id } }),
// );

Cache-aside vs write-through

Cache-aside solo llena la caché en una lectura, por lo que los datos no utilizados nunca ocupan memoria. Write-through mantiene la caché caliente pero paga el costo en cada escritura.

Cache-aside
// On read: miss, load, then populate.
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);

const value = await load();
await redis.set(key, JSON.stringify(value), "EX", 300);
return value;
Write-through
// On write: update the store and the cache together.
const value = await db.product.update({ where: { id }, data: patch });
await redis.set(key, JSON.stringify(value), "EX", 300);
return value;

Solo TTL vs invalidación explícita

Un TTL por sí solo es simple y se autorepara, pero un lector puede ver datos obsoletos durante toda la ventana. La invalidación explícita es más fresca y más fácil de implementar incorrectamente.

Solo TTL
// Stale for at most five minutes, then it heals itself.
await redis.set(key, JSON.stringify(value), "EX", 300);

// No update path to maintain, and a crashed
// writer cannot leave a permanently wrong value.
Bust explícito
// Fresher, but every write path must remember to do this.
await db.product.update({ where: { id }, data: patch });
await redis.del(`product:${id}`);

// Forget one call site and the cache lies forever.

Compromisos

¿Vale la pena la complejidad del caching?

Una caché es un sistema extra con sus propios modos de fallo. Añádela cuando las mediciones justifiquen las piezas móviles.

Strengths

  • Ganancias drásticas en latencia

    Mover una lectura de una base de datos basada en disco a la memoria puede convertir una consulta de 20 ms en una búsqueda de 0.2 ms, y eso se acumula en toda una página.

  • Protección bajo carga

    Una caché absorbe picos de tráfico que de otro modo se encolarían en la base de datos, lo que a menudo es la diferencia entre un servicio degradado y uno caído.

  • Ahorros de costos reales

    Menos lecturas de base de datos significan instancias más pequeñas, menos réplicas y menos egress, lo que se refleja directamente en la factura.

Trade-offs

  • La invalidación es genuinamente difícil

    Una caché puede servir datos que ya no existen o esconder datos que sí existen. Cada ruta de escritura se convierte en un lugar propenso a errores.

  • Un segundo dominio de fallo

    La caché puede caerse, llenarse o devolver valores corruptos. El código debe poder recurrir a la fuente y tratar la caché como opcional.

  • Las lecturas obsoletas sorprenden a los usuarios

    Los usuarios esperan ver sus propias escrituras inmediatamente. Sin una invalidación cuidadosa, un perfil o carrito en caché parecerá un bug.

Preguntas frecuentes

Preguntas frecuentes

Keep learning

Related topics from the roadmap.

$ comienza a aprender

Listo para aprender Caching Strategies?

Nuestro tutorial interactivo te guia a traves de Caching Strategies paso a paso — con quizzes y codigo real que puedes ejecutar en el navegador.