Revalidación en el borde: caducidad, purga y estampida
La semántica exacta de stale-while-revalidate y stale-if-error, la purga por etiquetas que sí escala, y cómo evitar que mil peticiones lleguen a tu origen a la vez.
Cachear es fácil; el problema siempre ha sido invalidar. Hay dos modelos y solo uno escala: esperar a que caduque, o avisar cuando cambia. El primero es simple y produce contenido viejo; el segundo es exacto y exige que alguien mantenga la correspondencia entre datos y URLs. La combinación de los dos, con las directivas de contenido caducado, es lo que permite tener a la vez alta frescura y una tasa de aciertos alta, y hay un fallo clásico —la estampida— que aparece justo cuando el sitio empieza a tener tráfico.
- Distinguir caducidad de invalidación y saber cuándo se necesita cada una.
- Escribir la semántica exacta de
stale-while-revalidateystale-if-error. - Diseñar un esquema de purga por etiquetas que escale con el número de páginas.
- Reconocer y prevenir la estampida de caché.
Caducidad frente a invalidación
Caducidad. Le pones un tiempo de vida a la respuesta y la caché la considera fresca hasta que pase. Es lo más simple, no requiere ninguna comunicación entre tu aplicación y la caché, y su precio es que hay una ventana durante la cual se sirve contenido viejo. La pregunta de diseño es “¿cuántos segundos puede estar equivocado esto?”, y tiene respuesta para casi todo el contenido.
Invalidación. Cuando el dato cambia, tu aplicación avisa a la caché de que ciertas entradas ya no valen. Es exacta, permite tiempos de vida larguísimos, y su precio es que alguien tiene que mantener la correspondencia entre “ha cambiado el producto 42” y “hay que purgar estas dieciocho URLs”. Esa correspondencia es donde falla en la práctica: se olvida la página de categoría, o el listado de novedades, o el mapa del sitio.
La combinación correcta en un sitio real es tiempos de vida largos más purga por etiquetas, con contenido caducado como red de seguridad para cuando la purga falle o el origen esté caído.
stale-while-revalidate y stale-if-error
Estas dos directivas son las que más rendimiento aportan por carácter escrito, y la mayoría de la gente no las usa porque no tiene clara su semántica. Es exacta y sencilla.
Cache-Control: public, max-age=0, must-revalidate
CDN-Cache-Control: public, s-maxage=60, stale-while-revalidate=86400, stale-if-error=604800
La línea de abajo define tres ventanas para una caché compartida:
- De 0 a 60 segundos: la respuesta es fresca. Se sirve sin más.
- De 60 segundos a 24 horas: la respuesta está caducada pero dentro de la ventana de revalidación. Se sirve inmediatamente al usuario, tal cual, y en paralelo se lanza una revalidación contra el origen que actualizará la copia para las siguientes peticiones. El usuario no espera nada.
- A partir de 24 horas: la respuesta ya no se puede servir caducada de forma normal. Pero si el origen falla o no responde,
stale-if-errorpermite servirla igualmente durante siete días.
La segunda ventana es la que cambia el comportamiento del sistema. Sin ella, la petición que llega justo después de caducar espera a que el origen responda: esa petición concreta paga el tiempo completo del servidor. Con ella, ninguna petición espera nunca por una revalidación. La latencia del percentil 99 mejora de forma espectacular, porque los casos malos eran precisamente esos.
La tercera ventana es un seguro de disponibilidad que no cuesta nada. Con una semana de stale-if-error, una caída del origen de dos horas es invisible para todos los visitantes de contenido cacheado. Merece la pena ponerla siempre y con un valor generoso: en el peor caso, es contenido de hace unos días en lugar de una página de error.
Purga por etiquetas
Purgar por URL no escala. Cuando cambia un producto hay que purgar su ficha, las páginas de categoría donde aparece, el listado de novedades, los resultados de búsqueda que lo contienen y la portada si estaba destacado. Mantener esa lista a mano en el código es garantía de que se quedará desactualizada.
La solución es invertir la relación: el origen etiqueta cada respuesta con los identificadores de las entidades que contiene, y la purga se hace por etiqueta.
# Respuesta de /producto/42
Cache-Tag: producto-42, categoria-zapatos, marca-acme
# Respuesta de /categoria/zapatos
Cache-Tag: categoria-zapatos, producto-42, producto-51, producto-77
# Respuesta de /
Cache-Tag: portada, producto-42, producto-19
Cuando el producto 42 cambia, se purga la etiqueta producto-42 y desaparecen las tres. Nadie ha tenido que mantener una lista: la propia respuesta declara de qué depende, en el momento en que se genera, que es el único momento en que esa información se conoce con certeza.
El nombre de la cabecera varía por proveedor —Cache-Tag o Surrogate-Key son los dos habituales— pero el mecanismo es el mismo. Y hay un límite práctico que conviene conocer: el número de etiquetas por respuesta suele estar acotado. Una página de listado con doscientos productos no puede etiquetar los doscientos; en ese caso se etiqueta con la colección —categoria-zapatos— y se acepta que un cambio en cualquier producto purgue el listado entero.
Hay una variante importante: la purga blanda. En vez de borrar la entrada, la marca como caducada. La diferencia parece menor y no lo es: con la purga dura, la siguiente petición encuentra la caché vacía y espera al origen; con la purga blanda, encuentra una entrada caducada, la sirve inmediatamente por stale-while-revalidate y revalida por detrás. La purga blanda con contenido caducado es la combinación que permite invalidar constantemente sin que ningún usuario espere nunca. Si tu proveedor la ofrece, es casi siempre la opción correcta.
La estampida
El fallo que aparece cuando el sitio empieza a tener tráfico de verdad. Una entrada muy popular caduca. En ese instante hay cuatrocientas peticiones en vuelo para esa clave. Las cuatrocientas fallan, las cuatrocientas van al origen, el origen ejecuta cuatrocientas veces la misma consulta, se satura, tarda más, y mientras tanto siguen llegando peticiones que también fallan. Un pico de tráfico que la caché debería haber absorbido se convierte en una caída.
Cuatro defensas, y conviene tener varias.
Coalescencia de peticiones. La caché detecta que ya hay una petición en vuelo para esa clave y hace esperar a las demás en vez de reenviarlas. La mayoría de los proveedores serios lo hacen por defecto para la misma clave en el mismo nodo; conviene verificarlo, no darlo por supuesto. Si lo implementas tú en una función de borde:
// Coalescencia dentro de un aislado. Solo una peticion al origen
// por clave y por aislado.
const enVuelo = new Map();
async function traerCoalescido(clave, traer) {
const existente = enVuelo.get(clave);
if (existente) return (await existente).clone();
const promesa = traer();
enVuelo.set(clave, promesa);
try {
const respuesta = await promesa;
return respuesta.clone();
} finally {
enVuelo.delete(clave);
}
}
El clone() es obligatorio: el cuerpo de una respuesta solo se puede consumir una vez. Y hay que ser honesto con el alcance: esto coalesce dentro de un aislado, no globalmente, así que reduce el pico pero no lo elimina.
Contenido caducado. Con stale-while-revalidate, ninguna petición espera: se sirve lo viejo y una sola revalidación va al origen. Es la defensa más efectiva y la más barata.
Caducidades con dispersión. Si generas mil páginas en el mismo despliegue con el mismo tiempo de vida, caducan todas en el mismo segundo. Añadir una variación aleatoria las reparte.
const base = 3600;
const dispersion = Math.floor(Math.random() * 600); // hasta 10 minutos
cabeceras.set(
'CDN-Cache-Control',
'public, s-maxage=' + (base + dispersion) +
', stale-while-revalidate=86400, stale-if-error=604800'
);
Caché en niveles. Con un nodo escudo entre el borde y el origen, la estampida se detiene en el escudo, que recibe los fallos de todos los nodos y hace una sola petición al origen.
Hay un instinto muy arraigado, y muy razonable en apariencia, que dice que servir datos viejos es un error y que un sistema serio siempre entrega lo correcto. Ese instinto viene de las bases de datos, donde tiene sentido, y aplicado a una capa de presentación web cuesta una fortuna en latencia y en disponibilidad. Detente a pensar qué significa realmente “fresco” en una página web. El usuario mira su pantalla durante un minuto. Durante ese minuto, el contenido que ve es una foto de un instante anterior, y no se actualiza solo. Es decir, el contenido siempre está caducado desde el punto de vista del usuario; la única pregunta es cuántos segundos de desfase tenía en el momento de cargar, y si esa cifra es tres o treinta le resulta indistinguible. Un catálogo, un artículo, un listado, una página de precios, unos resultados de búsqueda: todos toleran perfectamente decenas de segundos, muchos toleran minutos, y nadie lo nota. Ahora mira lo que compras aceptándolo. Con stale-while-revalidate, ninguna petición de usuario espera nunca a tu origen, así que el percentil 99 de latencia deja de depender de la latencia de tu base de datos. Con stale-if-error, tu web sigue en pie cuando tu origen no lo está, y eso convierte una caída de dos horas en un incidente interno en lugar de un incidente visible. Con purga blanda, puedes invalidar en cada escritura sin que nadie pague por ello. Las tres cosas son consecuencia de una única decisión: aceptar explícitamente un presupuesto de obsolescencia. Y aquí está lo que hace que esto sea una lección de ingeniería y no de configuración: ese presupuesto no lo puede fijar un ingeniero solo, porque es una pregunta de producto —“¿cuántos segundos puede estar equivocado el precio en la ficha?”— cuya respuesta depende del negocio. Lo que sí es responsabilidad del ingeniero es plantear la pregunta, en esos términos, y por cada tipo de contenido. Cuando no se plantea, el valor por defecto que se acaba aplicando es cero segundos, que es el más caro de todos y que nadie eligió conscientemente. Y el contenido que de verdad no tolera ni un segundo de desfase —un saldo, un stock en el último paso de la compra— no es que necesite una caché mejor: es que no debe cachearse, y hay que sacarlo de la respuesta cacheable con las técnicas de separación en lugar de degradar la política de todo lo demás.
- Haz la lista de tus tipos de contenido y escribe, para cada uno, cuántos segundos puede estar desactualizado. Lleva la pregunta a producto.
- Añade
stale-while-revalidateystale-if-errora tus rutas cacheables y mide el percentil 99 de latencia antes y después. - Implementa el etiquetado de respuestas y la purga por etiqueta para una entidad. Verifica que purgar la etiqueta limpia todas las páginas que la contenían.
- Comprueba si tu proveedor hace coalescencia de peticiones y si ofrece purga blanda. Actívalas.
- Simula una estampida: purga en duro una ruta muy popular en hora punta y observa el pico en tu origen. Repite con purga blanda.