wandres.dev
EL EDGE Y LA CDN · Acercar el byte al usuario

La clave de caché: la aritmética de la cardinalidad

Qué forma la clave por defecto, cómo se normaliza para no fragmentar, cómo se añaden dimensiones útiles sin arruinar la tasa de aciertos, y la fórmula que predice si una clave acertará.

⏱ 19 min

La clave de caché es la afirmación de que dos peticiones distintas merecen exactamente la misma respuesta. Definirla es definir una relación de equivalencia sobre el espacio de peticiones, y ahí está todo el juego: si la haces demasiado fina, tienes millones de entradas que nunca aciertan; si la haces demasiado gruesa, sirves la respuesta de una persona a otra. El equilibrio se calcula, no se intuye, y la aritmética es sencilla.

🎯 Al terminar esta lección sabrás
  • Enumerar los componentes de la clave de caché por defecto y cómo los amplía Vary.
  • Calcular la cardinalidad de una clave y estimar la tasa de aciertos que produce.
  • Normalizar las URL para no fragmentar la caché con parámetros irrelevantes.
  • Añadir dimensiones de segmentación controlando su cardinalidad.

Qué forma la clave por defecto

Sin configuración, una caché compartida identifica una respuesta por: el método, el esquema, el host, la ruta y la cadena de consulta completa. A eso se suman las cabeceras que la respuesta declare en Vary.

Tres consecuencias inmediatas y molestas:

La cadena de consulta entra entera y en el orden en que llegó. ?a=1&b=2 y ?b=2&a=1 son dos entradas distintas para la misma página. Y ?utm_source=boletin crea una entrada nueva para cada campaña, aunque la respuesta sea idéntica byte a byte.

El host importa. ejemplo.com y www.ejemplo.com son dos cachés separadas. Si no rediriges consistentemente, tienes el doble de entradas y la mitad de aciertos.

Vary es opaco para ti. No puedes ver cuántas variantes ha generado ni cuáles. Por eso, cuando el proveedor lo permite, es preferible definir la clave explícitamente —incluyendo las cookies y cabeceras concretas que quieres— antes que confiar en Vary: lo que defines lo puedes contar.

La aritmética de la cardinalidad

Aquí está el razonamiento que casi nadie hace y que predice si una configuración va a funcionar.

La cardinalidad de una clave es el número de entradas distintas que puede generar: el producto de las cardinalidades de cada dimensión.

10.000 URLs
  x  3 idiomas
  x  2 clases de dispositivo
  x  5 paises
= 300.000 entradas distintas

Ahora la parte que decide. Una entrada acierta si alguien la pide otra vez antes de que caduque y en el mismo nodo. Con un tiempo de vida T y R peticiones a esa clave dentro de la ventana T en ese nodo, la tasa de aciertos de esa clave es aproximadamente (R - 1) / R: la primera petición falla y llena, las demás aciertan.

Los números que salen de ahí son duros:

Peticiones por ventana y nodo Tasa de aciertos aproximada
1 0 %
2 50 %
5 80 %
20 95 %
100 99 %

Y ahora multiplica por el número de nodos. Si tu proveedor tiene trescientos puntos de presencia y tu tráfico se reparte, cada nodo recibe una fracción del total. Una ruta con seiscientas visitas a la hora y un tiempo de vida de una hora suena bien hasta que ves que son dos peticiones por nodo: 50 % de aciertos, y eso siendo optimista con el reparto.

Hay dos remedios y conviene conocer los dos.

Caché en niveles. El proveedor coloca un nivel intermedio —un nodo escudo— entre los nodos de borde y tu origen. Los fallos de los trescientos nodos van al escudo, no a tu servidor. El escudo ve el tráfico agregado, así que su tasa de aciertos es altísima, y tu origen recibe una fracción minúscula. Es una casilla que se marca en la configuración y es de lo más rentable que existe si tienes tráfico repartido geográficamente.

Tiempos de vida más largos con revalidación. Con stale-while-revalidate puedes poner un tiempo de vida largo sin sacrificar frescura, porque el contenido caducado se sigue sirviendo mientras se refresca por detrás. Alargar la ventana T sube R de forma directa.

Normalizar: quitar lo que no cambia la respuesta

La normalización es la operación de mayor rendimiento por línea de código de todo este nivel.

const PARAMS_IRRELEVANTES = new Set([
  'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
  'gclid', 'fbclid', 'msclkid', 'mc_cid', 'mc_eid', 'ref', 'referrer',
  '_ga', 'igshid', 'ttclid',
]);

function normalizar(url) {
  const u = new URL(url);

  // 1. Fuera los parametros que no cambian la respuesta.
  for (const clave of [...u.searchParams.keys()]) {
    if (PARAMS_IRRELEVANTES.has(clave.toLowerCase())) u.searchParams.delete(clave);
  }

  // 2. Orden estable: ?a=1&b=2 y ?b=2&a=1 son la misma pagina.
  u.searchParams.sort();

  // 3. Host en minusculas y sin puerto por defecto.
  u.hostname = u.hostname.toLowerCase();

  // 4. Barra final consistente.
  if (u.pathname.length > 1 && u.pathname.endsWith('/')) {
    u.pathname = u.pathname.slice(0, -1);
  }

  // 5. El fragmento nunca llega al servidor, pero por si acaso.
  u.hash = '';

  return u;
}

Ojo con el paso 1: solo se pueden quitar los parámetros que de verdad no cambian la respuesta. Si tu aplicación usa ref para algo funcional, quitarlo sirve contenido equivocado. La lista se construye mirando tu código, no copiándola de internet.

El efecto de esto es a menudo espectacular y se puede estimar antes de implementarlo: cuenta cuántas URL distintas ve tu origen en un día y cuántas quedan tras normalizar. En sitios con campañas activas, la reducción de tres a diez veces es habitual, y una reducción de la cardinalidad por cinco multiplica R por cinco.

Añadir dimensiones sin arruinarlo

A veces sí quieres que la respuesta dependa de algo más. País para la moneda, idioma, clase de dispositivo para servir una plantilla distinta. La técnica es agrupar en cubetas: nunca metas el valor bruto en la clave, mete la clase.

// Anadir dimensiones controlando la cardinalidad.
function claveDeCache(peticion) {
  const u = normalizar(peticion.url);

  // Pais -> 4 cubetas, no 200. La respuesta solo cambia por moneda.
  const pais = (peticion.headers.get('cf-ipcountry') || 'XX').toUpperCase();
  const zona = pais === 'ES' ? 'es'
             : ['PT', 'FR', 'IT', 'DE'].includes(pais) ? 'eur'
             : ['MX', 'AR', 'CO', 'CL'].includes(pais) ? 'latam'
             : 'resto';
  u.searchParams.set('__zona', zona);

  // Dispositivo -> 2 cubetas, no miles de cadenas de agente.
  const ua = peticion.headers.get('user-agent') || '';
  u.searchParams.set('__disp', /Mobi|Android|iPhone/i.test(ua) ? 'm' : 'd');

  // Idioma -> las 3 que soportas, no las 40 combinaciones de Accept-Language.
  const idiomas = (peticion.headers.get('accept-language') || '').toLowerCase();
  const idioma = idiomas.startsWith('ca') ? 'ca'
               : idiomas.startsWith('en') ? 'en'
               : 'es';
  u.searchParams.set('__idioma', idioma);

  return new Request(u.toString(), peticion);
}

La diferencia entre esto y usar Vary: Accept-Language, User-Agent, CF-IPCountry es de varios órdenes de magnitud en cardinalidad: cuatro por dos por tres son veinticuatro variantes, frente a las decenas de miles que producen las cabeceras crudas. Y como los parámetros sintéticos son visibles en la URL de la clave, puedes contarlos y depurarlos.

Un detalle sobre la clase de dispositivo que merece mención: si tu CSS es responsivo de verdad, no necesitas esta dimensión en absoluto, y la mejor optimización es eliminarla. Servir plantillas distintas por dispositivo duplica la caché, duplica el trabajo de mantenimiento y produce el bug clásico de que un usuario con la ventana estrecha en escritorio recibe la versión que no toca.

La clave de caché define una relación de equivalencia, y sus dos errores posibles no cuestan lo mismo ni de lejos

Cuando escribes una clave de caché estás afirmando algo muy fuerte: que todas las peticiones que colapsan en la misma clave merecen exactamente la misma respuesta. Es una relación de equivalencia, y como toda partición se puede equivocar en dos direcciones. Demasiado gruesa: metes en la misma clase peticiones que merecían respuestas distintas, y entonces un usuario recibe el contenido de otro. El coste de ese error va desde lo cómico —ver el nombre de un desconocido en la esquina— hasta lo que acaba en un informe de seguridad y en un titular, si lo que se filtró era un carrito, una dirección o un saldo. Demasiado fina: separas peticiones que merecían la misma respuesta, y entonces la caché no acierta y pagas cómputo y latencia de más. El coste de ese error es dinero y milisegundos. Los dos errores son errores, pero no son comparables: uno se arregla con una factura y el otro con un comunicado. Por eso la asimetría tiene que estar en el procedimiento y no solo en la intención. En la práctica significan tres reglas. Primera: las dimensiones se añaden por defecto y se quitan con evidencia, no al revés; si dudas de si la respuesta depende de una cabecera, inclúyela y luego mide si la cardinalidad se dispara. Segunda: lo que hace la respuesta personal nunca se resuelve con la clave, se resuelve sacándolo de la respuesta cacheada, porque una clave por usuario no es una caché, es un almacén con una tasa de aciertos de cero y un riesgo de fuga distinto de cero. Tercera: cada dimensión de la clave necesita un dueño y una justificación escrita, porque las claves de caché se acumulan con los años y nadie se atreve a quitar una dimensión que no entiende. He visto configuraciones con seis dimensiones de las que dos llevaban tres años sin cambiar nada y ninguna se podía quitar porque nadie recordaba por qué estaban. Eso no es un problema de rendimiento: es deuda de conocimiento con manifestación en la factura.

⚔️ Calcula y reduce tu cardinalidad
  1. Cuenta las URL distintas que llegan a tu origen en veinticuatro horas y cuántas quedan tras aplicar la normalización.
  2. Calcula la cardinalidad total de tu clave actual multiplicando las dimensiones. Compárala con tu número de rutas.
  3. Estima R para tu ruta mediana: peticiones por ventana de caducidad y por nodo. Predice la tasa de aciertos y compárala con la real.
  4. Comprueba si tienes caché en niveles activada. Si no, actívala y mide el tráfico que llega al origen antes y después.
  5. Revisa cada dimensión de tu clave y escribe su justificación. Quita las que no puedas justificar.