Elido
15 min de lecturaIngeniería

Estrategia de caché para redirecciones URL: LRU L1 y Redis L2

Cómo la caché de dos niveles frente al origen del acortador mantiene la latencia p95 bajo 15ms: política de expulsión, estrategia de warming y fallos reales.

Marius Voß
DevRel · edge infra
Diagrama de flujo de tres niveles con flechas de la solicitud a LRU L1 (en proceso) al clúster Redis L2 al gRPC de origen, con anotaciones de hit ratio de 98%, 1.8% y 0.2%

El nivel de redirección de un acortador de URL es uno de los pocos sistemas de producción donde la estrategia de caché es la arquitectura. No ocurre ningún otro trabajo significativo en la ruta crítica: cada solicitud resuelve una clave (el slug corto), lee una URL de destino y emite un 301 o 302. Todo lo demás es observabilidad y registro. La caché es lo que determina si la solicitud mediana tarda 800 microsegundos o 12 milisegundos.

Este post documenta la estrategia de caché detrás del servicio edge-redirect de Elido. Dos niveles, una política de expulsión elegida para optimizar la latencia de cola en lugar del hit rate, una estrategia de precalentamiento (warming) más sencilla de lo que parece y los modos de fallo que hemos visto en 18 meses de producción. El cornerstone de redirección p95 < 15ms cubre el presupuesto de latencia completo; este es el análisis profundo específico de la caché.

Por qué dos niveles#

La arquitectura de caché más simple para un servicio de redirección es de un solo nivel: un clúster de Redis entre el proceso de redirección y la base de datos de origen. Cada solicitud que no llega a la base de datos llega a Redis; cada solicitud que no llega a Redis llega a la base de datos. El salto a Redis añade aproximadamente 1ms cuando Redis está en la misma región.

Las cachés de dos niveles añaden una capa en proceso frente a Redis. El primer nivel —llamémoslo L1— vive dentro del espacio de direcciones del proceso de redirección. Un hit en L1 devuelve la URL de destino en unos pocos cientos de nanosegundos, sin necesidad de un round-trip de red. Un miss en L1 cae en Redis (L2), que sirve con una latencia inferior al milisegundo. Un miss en L2 cae en la llamada gRPC de origen contra la base de datos canónica Postgres.

La elección entre uno o dos niveles es esencialmente una cuestión de qué tan plana debe ser la latencia de cola. Redis es rápido pero no es gratis. Un p50 de 1ms hacia Redis se convierte en 4-6ms p99 bajo carga, y el p99.9 puede superar los 20ms cuando hay cualquier contención en la red. Para un SLO que apunta a un p95 < 15ms, cada hit en Redis consume una fracción significativa del presupuesto. Para un p99.9 < 50ms, la cola de Redis es el contribuyente dominante.

Un LRU en proceso absorbe las claves de mayor frecuencia, las que impulsan más del 80% del tráfico. En la distribución de tráfico de Elido, los 1000 enlaces cortos principales por volumen de solicitudes representan más del 70% de las solicitudes de redirección. Esas claves son fáciles de servir en proceso; la cola larga puede caer en Redis sin degradar el p95.

L1: un LRU por proceso#

La caché L1 utiliza Ristretto, el mismo LRU con política de admisión utilizado por Caddy y Dgraph. Lo elegimos por tres razones:

  • Las lecturas concurrentes escalan linealmente con los núcleos de la CPU. Una caché sync.Map más simple se estanca en unos 4M ops/seg en una máquina POP de edge típica; Ristretto mantiene más de 30M en nuestros benchmarks.
  • La política de admisión TinyLFU evita que las cargas de trabajo de escaneo de un solo paso expulsen las claves calientes. Un rastreo de bots que toca 10,000 slugs únicos una vez cada uno no desplaza los enlaces genuinamente populares de la caché.
  • Memoria acotada en lugar de recuento de claves acotado. Podemos configurar "usar hasta 256MB" en lugar de "almacenar hasta 100,000 entradas", que es la configuración que importa para la planificación de capacidad.

La configuración que enviamos es:

cache, err := ristretto.NewCache(&ristretto.Config{
    NumCounters: 10_000_000, // 10M counters → tracks ~1M items
    MaxCost:     256 << 20,   // 256MB
    BufferItems: 64,
    Metrics:     true,
})

NumCounters es el tamaño de la tabla de seguimiento de frecuencia TinyLFU; la regla general en la documentación de Ristretto es 10 veces el recuento de elementos esperado. Con un presupuesto de 256MB y un registro de enlace promedio de 200 bytes, la caché contiene aproximadamente 1.3M de entradas cuando está llena.

El TTL en las entradas de L1 es de 60 segundos. Esto es deliberadamente corto. Una redirección puede cambiar su destino en el dashboard en cualquier momento, y la caché L1 es la capa más lenta de invalidar (Redis puede invalidarse mediante publish; L1 vive en cada proceso y necesita una ruta de invalidación coordinada).

Un TTL de 60 segundos significa que la obsolescencia en el peor de los casos es de 60 segundos después de una actualización de destino. Para la mayoría de los casos de uso esto es aceptable; para los casos donde no lo es (cambios de destino inmediatos durante una campaña en vivo), el botón de invalidación del dashboard emite un fanout que purga todas las cachés L1 en toda la flota. El fanout utiliza Redis pub/sub en un canal al que cada proceso de edge se suscribe al iniciarse.

L2: Clúster Redis con réplicas de lectura#

L2 es un clúster de Redis, desplegado en cada región (Frankfurt, Ashburn, Singapur). Las lecturas van a las réplicas locales; las escrituras van al primario regional y se replican dentro del modelo asíncrono estándar de Redis.

El formato de los datos es pequeño. Un registro de redirección en L2 se ve así:

KEY:   redirect:f.elido.me:abc123
VALUE: {"d":"https://shop.example.com/spring","f":0,"v":12}

Tres campos: URL de destino, flags (filtrado de bots habilitado, contraseña requerida, etc., empaquetados en un uint16) y versión. La versión es la versión de la fila de Postgres; nos permite detectar entradas de caché obsoletas en la lectura.

El TTL en L2 es de 24 horas. Esto es mucho más largo que L1 porque L2 tiene una ruta de invalidación funcional: cuando se crea o actualiza un enlace en la base de datos de origen, la API publica un mensaje pub/sub de Redis en el canal de invalidación regional, y los procesos de redirección expulsan sus entradas L1; la entrada L2 es sobreescrita directamente por la capa de la API.

La invalidación por pub/sub tiene una propiedad sutil: es con pérdida (lossy). Si un proceso de redirección se está reiniciando cuando se publica el mensaje de invalidación, no ve el mensaje y su caché L1 puede servir el valor obsoleto hasta por 60 segundos. Aceptamos esto porque el TTL es el respaldo: la obsolescencia está acotada.

El tamaño del clúster de Redis en cada POP es pequeño. Frankfurt ejecuta tres nodos primarios más tres réplicas; el conjunto de datos total cabe en unos 4GB. Con nuestro hit rate (98% L1, 1.8% L2, 0.2% origen bajo carga normal), el requisito de rendimiento en Redis es moderado, generalmente 5-15k ops/seg en el pico por POP, muy dentro de la capacidad de un solo nodo primario si tuviéramos que consolidar.

La elección de la política de expulsión#

La política de admisión TinyLFU de Ristretto es la elección que más importa para la latencia de cola.

Un LRU ingenuo expulsa la clave menos utilizada recientemente cada vez que necesita hacer espacio. Eso está bien cuando el patrón de acceso es razonablemente uniforme: las claves que se usaron más recientemente son las que tienen más probabilidades de usarse de nuevo. Pero falla bajo dos patrones específicos:

  • Cargas de trabajo de escaneo. Un rastreo de bots que golpea 50,000 slugs únicos en rápida sucesión, bajo un LRU ingenuo, expulsará cada clave caliente y las reemplazará con claves de rastreo que nunca se volverán a acceder. El hit rate de la caché cae, el origen ve un pico de carga y el p95 salta porque la mayoría de las solicitudes ahora pasan por la ruta lenta.
  • Claves calientes por ráfagas. Un enlace que normalmente está frío pero de repente recibe 100k solicitudes en 30 segundos (una publicación viral en redes sociales, el lanzamiento de una campaña de TV) necesita ser cacheado rápidamente. Bajo un LRU ingenuo, desplazará una de las claves calientes existentes.

TinyLFU gestiona ambos escenarios. La política de admisión rastrea las frecuencias de las claves y solo admite una nueva clave en la caché si es más frecuente que la candidata a expulsión. Un rastreo de bots de un solo paso no desplaza las claves calientes porque las claves de rastreo tienen un recuento de frecuencia de 1. Una clave caliente por ráfaga sí entra en la caché, pero solo después de que su frecuencia supere la de la candidata a expulsión, lo que sucede en unos pocos cientos de solicitudes.

El costo es que las primeras 100-500 solicitudes para un enlace recién popular son lentas (caen en L2 o en el origen) hasta que la política de admisión decide cachearlo. Para la mayoría de los casos de uso, este es el compromiso correcto; para campañas donde sabemos de antemano que un enlace tendrá un pico, tenemos un endpoint de precalentamiento (pre-warm) que se describe a continuación.

Warming de caché#

La caché L2 realiza un cold-start cuando un nuevo clúster de Redis se pone en línea. No lo precalentamos desde un snapshot; los primeros 5 minutos después del reinicio de un clúster ven un tráfico elevado al origen hasta que la caché se llena de forma natural.

La caché L1 realiza un cold-start cuando un proceso de redirección se reinicia (despliegues, OOM kills, escalado vertical). Los primeros 30 segundos después del reinicio de un proceso ven cómo la mayoría de las solicitudes caen en L2; los siguientes 60 segundos ven cómo L1 se llena con su working set de claves calientes. La contribución total del cold-start a la carga del origen es pequeña (la mayoría de los procesos de edge se reinician con mucha menos frecuencia que el TTL de la caché).

La excepción: cuando un campaign manager pre-publica un enlace que sabe que tendrá un pico —una URL de un anuncio de TV, una URL de una nota de prensa, un anuncio de lanzamiento— el dashboard ofrece un interruptor de "pre-warm". Activarlo emite una redirección no-op contra el servicio de edge-redirect en cada POP, lo que puebla L1 de antemano. Esto es poco glamuroso y rara vez necesario; el autoscaler gestiona adecuadamente los picos de tráfico imprevistos. El pre-warm es la respuesta para los picos previstos donde los primeros 60 segundos de latencia de caché fría serían visibles.

Qué sucede con la capacidad de L1#

Una caché L1 de 256MB se llena en menos de un minuto en un POP de edge típico. Una vez llena, cada nueva clave requiere que la política de admisión TinyLFU decida si debe expulsar una clave existente.

La observación interesante: en nuestra distribución, el hit rate de L1 se estabiliza alrededor del 98% una vez caliente. La tasa de miss del 2% es la cola larga: el ~30% de los enlaces que representan menos del 30% del tráfico y, por lo tanto, no superan el umbral de frecuencia de TinyLFU. Estos fallan en L1 y aciertan en L2, donde el hit rate es de aproximadamente el 99%. El 0.2% restante del total de solicitudes cae en el origen.

Medimos esta distribución en tres formas de carga de trabajo —tráfico intenso de bots, pico viral, estado estable— y el hit rate de L1 fluctúa entre el 95% y el 99%. El hit rate de L2 es más estable entre el 98% y el 99.5%. Por lo tanto, la carga total del origen desde el nivel de redirección está limitada a aproximadamente el 0.5% del volumen de solicitudes entrantes, que es el número que importa para la planificación de capacidad del origen.

Invalidación de caché en detalle#

El flujo de invalidación es la parte que más a menudo se malinterpreta por cualquiera que lea la arquitectura desde fuera. El detalle:

Cuando la API recibe un PATCH /v1/links/{id} que cambia la URL de destino, suceden tres cosas por orden:

  1. Postgres confirma el cambio con la nueva versión de la fila (UPDATE links SET destination = ?, version = version + 1 WHERE id = ?).
  2. Redis se escribe directamente con el nuevo valor en cada clúster de Redis regional. La escritura se propaga desde la API a cada Redis regional a través de una capa de write-through.
  3. La invalidación por pub/sub se publica en cada canal regional invalidate:redirect. Los procesos de edge-redirect se suscriben a este canal al iniciarse y expulsan la entrada L1 para la clave.

El orden importa. Postgres primero asegura que el almacén canónico tenga el nuevo valor. Redis-write-through antes de publicar asegura que cualquier proceso que se pierda la publicación pero lea de Redis vea el nuevo valor. La publicación es la optimización que mantiene a L1 sincronizada; el TTL es el respaldo si se pierde una publicación.

La carrera (race condition) conocida: un proceso de redirección que está leyendo de Redis (debido a un miss en L1) y una publicación de invalidación concurrente. La lectura puede devolver el nuevo valor (la publicación ocurrió poco antes de la lectura) o el antiguo valor (la publicación ocurrió poco después). Si se devuelve el valor antiguo y se cachea en L1, los próximos 60 segundos pueden servir el valor antiguo a ese proceso. Esto es aceptable; la alternativa —un bloqueo síncrono alrededor de la carrera de lectura-publicación— añade latencia a cada solicitud para evitar un caso límite que afecta a menos del 0.01% de las invalidaciones.

Para los casos de uso donde la ventana de obsolescencia es inaceptable (una URL de destino se retira por razones legales, un destino es repentinamente malicioso), la acción "purge cache" del dashboard emite una invalidación agresiva: pausa todas las lecturas de L1 durante 100ms en toda la flota, expulsa la clave de cada L1 y luego reanuda. Esto se usa rara vez y está sujeto a un límite de tasa por segundo.

Modos de fallo que realmente hemos visto#

Tres fallos de los 18 meses de historia de producción que vale la pena documentar porque dieron forma a la configuración actual.

Failover del primario de Redis con réplicas obsoletas. En el mes 4 de producción, falló un nodo primario en el clúster de Frankfurt. La réplica fue promovida en 30 segundos (failover impulsado por Sentinel). Las réplicas habían estado unos 200ms por detrás del primario en el momento del fallo, lo que significó que las primeras cientos de invalidaciones publicadas justo antes del failover no llegaron a la réplica promovida. Resultado: una breve ventana donde aproximadamente el 0.3% de las redirecciones sirvieron destinos obsoletos. Resolución: ahora ejecutamos réplicas con min-replicas-to-write 1 y min-replicas-max-lag 10, lo que intercambia un pequeño golpe en la disponibilidad de escritura por una garantía de lag de replicación más ajustada.

Thrashing de la caché L1 durante un escaneo de monitoreo sintético. En el mes 9, un servicio de monitoreo de uptime de terceros fue mal configurado para probar cada enlace corto en el espacio de trabajo de un cliente una vez por minuto. El cliente tenía 18,000 enlaces cortos. El patrón de prueba era un escaneo completo cada 60 segundos. Efecto: el hit rate de la caché L1 cayó del 98% al 71% en tres POP de edge porque el patrón de escaneo admitió cada clave probada en la caché. Resolución: añadimos un filtrado basado en User-Agent antes de la capa de admisión de caché: los User-Agents de monitoreo conocidos omiten la caché y sirven de L2 directamente. Este fue un caso límite de TinyLFU: las claves de escaneo parecían lo suficientemente frecuentes como para desplazar a las claves genuinamente calientes.

Desconexión de pub/sub durante un despliegue prolongado. En el mes 13, un despliegue que tardó más de lo esperado (unos 4 minutos) causó que varios procesos de edge permanecieran conectados al antiguo canal de pub/sub después de que el primario de Redis hubiera fallado. Las invalidaciones publicadas en el nuevo primario no llegaron a esos procesos; sus cachés L1 sirvieron valores obsoletos durante la duración del despliegue. Resolución: heartbeats de conexión pub/sub con auto-reconexión ante heartbeats perdidos, y un flush de L1 en el momento del despliegue como precaución.

Qué consideramos y rechazamos#

Algunas alternativas evaluadas y no elegidas:

Una única caché en proceso, sin Redis. Probado. La tasa de miss al origen en cualquier proceso individual es demasiado alta sin un L2; la base de datos de origen necesitaría entre 3 y 5 veces más capacidad. El costo marginal de Redis es pequeño en relación con el ahorro de capacidad del origen.

Un CDN como Cloudflare o Fastly para el cacheo de redirecciones. Probado en staging. La latencia regional de 1-2ms del CDN en un hit de caché es aproximadamente la misma que Redis, pero la historia de la invalidación es materialmente peor (las purgas del CDN tienen una latencia de escala de minutos y costos de purga por URL). El CDN añadió complejidad sin mejorar la latencia ni el hit rate.

Un L1 más grande. El presupuesto de 256MB está dimensionado según el entorno de memoria por proceso; duplicarlo no duplica el hit rate porque el working set caliente ya cabe. Los rendimientos decrecientes comienzan a aparecer alrededor de los 128MB en nuestra distribución; 256MB tiene margen para el crecimiento del tráfico.

Observabilidad#

Las métricas que rastreamos por proceso de edge:

  • cache_l1_hit_total, cache_l1_miss_total — hit rate derivado por proceso.
  • cache_l2_hit_total, cache_l2_miss_total — hit rate derivado por región.
  • cache_origin_request_total — volumen de solicitudes al origen; el objetivo del SLO es < 1% del total de solicitudes.
  • cache_invalidation_total{source="pubsub|ttl|purge"} — recuentos de invalidación por mecanismo.
  • cache_l1_memory_bytes — memoria real utilizada por la caché L1; se alerta al 90% del presupuesto configurado.

Todas las métricas son recolectadas por Prometheus y visualizadas en el conjunto de dashboards de la guía de observabilidad. Los dashboards de Grafana a nivel regional muestran el hit rate de la caché regional a lo largo del tiempo; los dashboards por proceso (usados durante incidentes) muestran el hit rate de L1 por proceso y el uso de memoria.

Cuándo usar esta estrategia y cuándo no#

Una caché de dos niveles tiene sentido cuando:

  • La carga de trabajo es intensiva en lectura con una distribución de claves de cola larga.
  • El working set caliente cabe en la memoria por proceso (unos pocos cientos de megabytes).
  • Los misses de caché son lo suficientemente caros como para que el segundo nivel ahorre carga en la base de datos.
  • El presupuesto de obsolescencia es lo suficientemente ajustado como para que el TTL de L1 por sí solo no sea aceptable.

No tiene sentido cuando:

  • El working set caliente no cabe en la memoria del proceso. En ese caso, los misses de L1 caen en L2 con la suficiente frecuencia como para que L1 contribuya poco.
  • Las escrituras son frecuentes en relación con las lecturas. El costo de invalidación domina.
  • Los datos son únicos por solicitud (no hay beneficio del cacheo en absoluto).

Para la carga de trabajo del acortador de URL, se cumplen las cuatro condiciones de "sí" y la configuración anterior se ha mantenido durante 18 meses de crecimiento en producción. Para otras cargas de trabajo, el número de niveles y la política de expulsión necesitan ser reevaluados.

Lectura relacionada#

Prueba Elido

Acortador de URL alojado en la UE: dominios personalizados, análisis profundo y API abierta. Plan gratuito — sin tarjeta de crédito.

Etiquetas
url redirect cache
ristretto lru
redis cluster
two tier cache
cache invalidation
edge redirect
url shortener performance

Seguir leyendo