Una redirección es un bloqueo síncrono. El usuario hace clic en tu enlace corto, su navegador se para, y nada más ocurre hasta que llega el 302 y su siguiente carga de página puede comenzar. La redirección no es una tarea en segundo plano que puedas desprioritizar. Cada milisegundo que añades aquí es un milisegundo restado de la página que realmente importa.
Por eso establecimos un presupuesto duro antes de escribir la primera línea de services/edge-redirect: p50 5ms, p95 15ms en un cache hit, medido en el POP, excluyendo el handshake TLS completo. No aspiracional. Si algo nos empuja por encima de la línea, se elimina o se mueve a un camino async.
Hemos estado ejecutando tres regiones de producción - Fráncfort (FRA), Ashburn (ASH) y Singapur (SGP) - durante varios meses ahora. Este artículo es una explicación completa de cómo funciona el hot path, por qué los números se ven como se ven, y qué hicimos mal la primera vez.
TL;DR#
- El hot path es Go + fasthttp en Hetzner FRA/ASH y OVH SGP, detrás de Caddy con enrutamiento anycast. Sin puntuación de bots síncrona, sin desafío JS en el camino de redirección.
- Caché de dos niveles: ristretto LRU en proceso (L1, ~88% de tasa de hit) respaldado por Redis Cluster (L1+L2 combinados ~99,4%). gRPC al origen
api-coresolo en miss en frío (~0,6% de peticiones). - p95 de 90 días por región: FRA 12,1ms, ASH 13,4ms, SGP 14,2ms. Cold miss añade ~22ms en p95, aún dentro del presupuesto.
- La invalidación de caché en la mutación de enlace es Redis pub/sub, propagación sub-segundo p99. El TTL de L1 es 60 segundos como red de seguridad.
Por qué un techo de 15ms#
Antes de entrar en arquitectura: ¿por qué 15ms y no 50ms o 5ms?
El piso de 5ms es directo - eso es aproximadamente lo que cuesta el tránsito físico de red en la mediana para un visitante europeo que llega a un POP de Fráncfort. No puedes socavar la física. El techo de 50ms es demasiado holgado - a 50ms p95, estás añadiendo un retardo notable antes de cada vista de página para una fracción significativa de tu tráfico. La investigación sobre rendimiento web muestra consistentemente que retardos de red sub-50ms empiezan a ser perceptibles en dispositivos móviles donde la latencia de radio se compone con el tiempo de procesamiento, un punto que las directrices de programación consciente de la red de Apple hacen explícito.
El número 15ms aterrizó desde algunas restricciones concretas. Primero, las redirecciones se componen. Si una campaña de marketing envía tráfico a través de un enlace acortado que luego redirige a una página de producto, la latencia de redirección se suma al TTFB de la landing page. Las Core Web Vitals de Google usan LCP como señal primaria, y una cadena de redirección que añade 50ms en p95 es medible. Segundo, queremos suficiente margen de presupuesto para ejecutar la evaluación de reglas para smart links en línea en el hot path - las dimensiones de enrutamiento (país, dispositivo, SO, idioma, hora, referrer) necesitan ejecutarse dentro del mismo envolvente de latencia que una redirección plana, o tendríamos que quitar el soporte de smart link del edge. A 15ms con un coste de evaluación de regla de ~0,3ms, hay espacio.
El presupuesto de 15ms aplica al tráfico de cache hit. Los cold miss tienen permitido ser más lentos - la llamada gRPC al origen añade latencia - pero los cold miss por diseño son lo bastante raros como para no mover significativamente el p95.
La arquitectura#
Tres POPs, cada uno con el mismo binario: services/edge-redirect, escrito en Go usando fasthttp. El rendimiento del servidor de fasthttp es aproximadamente 8x net/http en el suite de benchmark y, más prácticamente para nosotros, su camino de petición sin asignación mantiene predecibles las pausas de GC bajo carga sostenida. La librería estándar net/http está bien para la mayoría de servicios; para un manejador de redirección que necesita mantener tiempo de procesamiento sub-milisegundo a alta concurrencia, evitar la asignación de heap por petición vale la pena la API menos ergonómica.
Caddy se sienta delante como terminador TLS y proxy inverso. El TLS bajo demanda para dominios personalizados de inquilinos (descrito en detalle en la página de funciones de dominios personalizados) aprovisiona certificados en la primera petición. Evaluamos HAProxy y nginx como alternativas - ambos son rápidos, ambos tienen patrones maduros de despliegue anycast, pero el TLS bajo demanda de Caddy es el camino más limpio al ciclo de vida de certificado sin intervención para un número arbitrario de dominios de cliente, y eso importa más para nosotros que exprimir otra fracción de un milisegundo en la capa proxy.
El enrutamiento anycast significa que cuando un visitante llega a f.elido.me, s.elido.me o b.elido.me, el DNS resuelve a un prefijo anycast compartido y la red enruta la conexión TCP al POP más cercano. No hay lógica de enrutamiento geo a nivel de aplicación: la red hace la selección del POP. La introducción a anycast de Cloudflare es la explicación pública más clara de por qué esto importa - la propiedad clave es que el failover se maneja en la capa BGP, no por expiración del TTL de DNS. Si FRA pierde conectividad, ASH se convierte en el camino más corto para el tráfico europeo en segundos, no minutos. Los documentos de infraestructura de red cloud de Hetzner cubren la configuración de enrutamiento subyacente para sus regiones FRA y ASH.
Importante: no hay puntuación de bots síncrona en el hot path. Una comprobación de puntuación de bot que toma 10ms destruiría por sí sola el presupuesto p95. Todas las señales de calidad de tráfico - detección de anonimizadores, puntuación de ASN de alojamiento, deduplicación de clic - se ejecutan en url-scanner y click-ingester como trabajadores async de camino frío. La redirección se dispara y el clic va en la cola de Redpanda; la adjudicación de calidad ocurre después del hecho.
El caché de dos niveles#
El caché es donde vive el presupuesto. La lógica:
// Simplified cache lookup: L1 → L2 → origin, with singleflight dedup
func (h *RedirectHandler) resolve(ctx *fasthttp.RequestCtx, slug string) (*Link, error) {
// L1: in-process ristretto LRU - sub-microsecond on hit
if link, ok := h.l1.Get(slug); ok {
return link.(*Link), nil
}
// L2 + origin share a singleflight group to prevent thundering herd
// on concurrent cold misses for the same slug
val, err, _ := h.sf.Do(slug, func() (interface{}, error) {
// L2: Redis Cluster - single RTT, typically 0.3–0.8ms within POP
if data, err := h.redis.Get(ctx, cacheKey(slug)).Bytes(); err == nil {
link, err := unmarshalLink(data)
if err == nil {
h.l1.Set(slug, link, linkCost(link))
return link, nil
}
}
// Origin: gRPC to api-core - cold miss, ~20ms extra
link, err := h.origin.GetLink(ctx, &pb.GetLinkRequest{Slug: slug})
if err != nil {
return nil, err
}
payload, _ := marshalLink(link)
h.redis.Set(ctx, cacheKey(slug), payload, redisTTL)
h.l1.Set(slug, link, linkCost(link))
return link, nil
})
if err != nil {
return nil, err
}
return val.(*Link), nil
}
L1 es ristretto, el caché LRU con control de admisión de Dgraph. El controlador de admisión importa: un LRU ingenuo bajo una carga de trabajo de escaneo (un bot golpeando miles de slugs únicos) evictaría entradas calientes para hacer espacio para frías que nunca serán solicitadas de nuevo. La política de admisión basada en TinyLFU de ristretto resiste esto - rastrea contadores de frecuencia de forma barata y rechaza admitir una entrada que nunca ha sido vista antes cuando el caché está bajo presión. El efecto neto es que la tasa de hit del caché bajo tráfico de escaneo adversarial se mantiene cerca de la tasa de hit orgánica en lugar de colapsar.
L2 es Redis Cluster. Cada POP tiene su propia instancia de cluster para mantener el tráfico entre regiones fuera del hot path. FRA y ASH comparten una instancia separada de Redis para señales de invalidación pub/sub (más sobre eso abajo); SGP tiene la suya. Un solo GET de Redis dentro del mismo centro de datos está fiablemente por debajo de 1ms. La tasa de hit combinada L1+L2 se sitúa en aproximadamente 99,4% en los últimos 90 días - lo que significa que las llamadas al origen ocurren en aproximadamente 1 de cada 167 peticiones.
Para el caso de uso de solutions/developers - equipos que usan la API para acuñar enlaces a alto volumen - la implicación práctica es que un enlace recién creado experimentará un cold miss por POP, luego estará caliente durante la duración de su TTL. Los enlaces que no ven tráfico expiran de ambos cachés limpiamente sin desalojo manual.
A dónde van los 15ms#
El diagrama a continuación desglosa el presupuesto p95 de cache hit por fase:
El segmento dominante es el retorno de red - aproximadamente 9ms mediano, lo que significa que la distancia física entre el visitante y el POP representa el 60% del presupuesto. No podemos comprimir esto. El despliegue multi-región es la única palanca: añadir un POP reduce el RTT mediano para visitantes en esa región. La siguiente región en el roadmap reduce p95 de SGP para tráfico del sur de Asia, donde actualmente estamos enrutando 14ms porque Singapur es el POP más cercano.
La reanudación de sesión TLS a 2ms asume TLS 1.3 0-RTT con un ticket de sesión ya en mano. Para una primera visita desde un dispositivo dado, un handshake TLS completo añade aproximadamente 10-15ms encima - por eso el presupuesto de 15ms se limita explícitamente a tráfico de cache-hit + sesión-reanudada, que es la gran mayoría del tráfico de clic en la práctica. El RFC 7234 gobierna la semántica de caché para la capa HTTP; notablemente, las respuestas 302 no son almacenadas por los cachés del navegador por defecto (§4.2.2), que es el comportamiento correcto para nuestro caso de uso - cada petición de redirección alcanza el edge, cada redirección obtiene su propia decisión de enrutamiento, sin destino obsoleto en el caché del navegador.
El margen de 2,6ms es espacio operativo real, no relleno. Bajo el GC de Go, pausas ocasionales de stop-the-world del orden de 0,5-1ms se esperan incluso con configuraciones GOGC ajustadas. La sobrecarga del proxy de Caddy añade un pequeño coste fijo. El margen nos impide romper el presupuesto cuando estos efectos se componen.
Invalidación de caché#
Redis pub/sub es el mecanismo. Cuando un enlace se muta en api-core - destino cambiado, reglas de segmentación actualizadas, enlace archivado - el manejador de mutación publica en un canal link:invalidate con el slug como payload. Cada POP de edge se suscribe a este canal. Al recibirlo, el suscriptor llama a l1.Del(slug) y redis.Del(cacheKey(slug)). La siguiente petición a ese slug repuebla ambos niveles desde el origen.
El TTL de L1 de 60 segundos es el respaldo, no el mecanismo primario. Si el suscriptor pub/sub está caído - digamos, un fallo de Redis o una partición de red entre el POP y la instancia pub/sub - la entrada expira de L1 dentro de como máximo 60 segundos. El TTL de L2 se establece a 300 segundos, por lo que una caída del suscriptor significa hasta 5 minutos de datos potencialmente obsoletos en L2, durante los cuales el TTL de L1 es la única red de seguridad. Alertamos sobre pérdida de suscripción pub/sub dentro de 30 segundos.
Para smart links con reglas de ventana de tiempo, la obsolescencia tiene una implicación específica: si una regla se activa a las 17:00 y el L1 del POP de edge tiene la versión anterior de la regla cacheada con hasta 60 segundos de TTL restante, el tráfico entre las 17:00 y las 17:01 puede ir al destino pre-actualización. El camino pub/sub elimina esto para el caso común; el TTL de 60 segundos atrapa el caso límite. Para campañas donde el límite de tiempo importa precisamente, el patrón recomendado es usar status=disabled en la regla antigua, esperar un ciclo de TTL (60 segundos), luego activar la nueva. Añadimos un endpoint de sondeo en GET /v1/links/{id}/cache-status para que los pipelines puedan confirmar la propagación antes de proceder.
Mediciones reales por región#
Los siguientes números provienen de datos de workspace de demo recopilados durante 90 días terminando el 2026-05-12. Reflejan solo tráfico de cache-hit. Todas las marcas de tiempo están en UTC.
| Región | POP | p50 | p95 | p99 |
|---|---|---|---|---|
| UE (Fráncfort) | FRA · Hetzner | 4,8ms | 12,1ms | 18,4ms |
| Este de EE. UU. (Ashburn) | ASH · Hetzner | 5,2ms | 13,4ms | 20,1ms |
| Sudeste Asia (Singapur) | SGP · OVH | 5,6ms | 14,2ms | 22,8ms |
FRA es el más rápido porque la mayoría de la carga de trabajo es europea, por lo que el RTT mediano es menor. SGP sirve una distribución geográfica más amplia - el tráfico del Sudeste Asiático tiene menor RTT, mientras el tráfico del Sur de Asia y Este de Asia añade a la cola.
Los números p99 exceden el presupuesto de 15ms. Eso es deliberado. El p95 es el presupuesto, no el p99. El p99 está moldeado por condiciones atípicas: handoffs celulares, retransmisiones TCP, el pico ocasional de latencia de Redis. Monitorizamos p99 pero no establecemos SLA contra él. La decisión de ingeniería es que p95 captura la experiencia para "casi todos casi todo el tiempo", y optimizar el último 1% requeriría eliminar fuentes de variabilidad natural de red que no están bajo nuestro control.
El p95 de cold miss es aproximadamente 22ms. Este es el piso que podemos alcanzar dado que gRPC al origen añade un viaje de ida y vuelta dentro del mismo centro de datos (FRA → FRA sobre red privada es aproximadamente 0,3ms) más la búsqueda de Postgres en api-core (típicamente 1-3ms para una búsqueda de slug por clave). La cifra de 22ms está medida, no estimada; está dentro del presupuesto que permitimos para caminos de cold-miss, que se establece en 35ms p95.
Para equipos que evalúan analítica multi-región, estos números de latencia están disponibles como una métrica Prometheus (redirect_duration_seconds con etiquetas region y cache_tier) desde el endpoint de métricas.
Modos de fallo sobre los que no escribimos la primera vez#
Thundering herd en expiración de clave#
Antes de añadir singleflight, un slug expirando de L1 y L2 simultáneamente bajo tráfico moderado generaría una ráfaga de llamadas gRPC concurrentes al origen - cada una haciendo una lectura de Postgres para el mismo slug, todas devolviendo el mismo resultado. Bajo pruebas de carga, esto produjo picos en CPU de api-core que no tenían nada que ver con el volumen de creación de enlaces. El grupo singleflight colapsa los miss concurrentes para el mismo slug en una sola llamada al origen. Las otras goroutines en espera se bloquean en el grupo y obtienen el mismo resultado cuando se resuelve. La implementación es el paquete estándar Go golang.org/x/sync/singleflight.
Nos equivocamos en el primer prototipo. Un thundering herd en expiración de clave es uno de esos modos de fallo que no aparece en pruebas unitarias - solo se muestra bajo concurrencia realista. Lo añado a este artículo porque es una omisión común en los writeups de arquitectura de caché y el arreglo es genuinamente simple.
Respaldo de Redis caído#
Si un POP pierde conectividad con su cluster Redis, el respaldo no es un error - el camino de código se degrada a solo L1 más gRPC directo al origen en miss de L1. El POP sigue sirviendo. La tasa de hit cae porque L2 no está disponible, por lo que el volumen de llamadas al origen sube, pero el camino de redirección permanece funcional. El camino de Redis caído ha sido ejercido dos veces en producción (ambas fueron ventanas de mantenimiento de Hetzner). La tasa pico de llamadas al origen durante el segundo incidente fue aproximadamente 8x la línea base durante la duración de la caída (~4 minutos). api-core lo manejó sin eventos de escalado.
Propagación de DNS durante failover de POP#
El failover anycast es a nivel BGP - sin TTL de DNS que esperar, sin timeout de comprobación de salud a nivel de aplicación en el camino de petición. Un POP yéndose offline desencadena la retirada BGP de la ruta, y el tráfico de red cambia al siguiente POP más cercano dentro de la ventana de convergencia BGP (típicamente 15-90 segundos dependiendo del número de saltos de red al camino afectado). El parámetro operativo relevante es nuestro intervalo de comprobación de salud: ejecutamos comprobaciones de salud TCP cada 10 segundos por POP. Un fallo de comprobación desencadena la retirada. Un intervalo de comprobación de 10 segundos significa que un POP caído puede servir hasta 10 segundos de tráfico fallido antes de la retirada. Hemos probado este límite deliberadamente; el impacto real en los dos incidentes de producción estuvo por debajo del intervalo de comprobación.
Lo que no hacemos en el hot path#
Cada elemento que no está en el hot path es una elección deliberada, no una omisión.
Escrituras de clic síncronas. Los clics son fire-and-forget a Redpanda. El manejador de redirección añade un evento de clic a un tópico Kafka (clicks.raw) con el slug, marca de tiempo, IP truncada y hash del user-agent, luego responde con el 302. La escritura es no bloqueante. Si Redpanda no está disponible, el clic se descarta - no la redirección. Hemos hecho el compromiso consciente de que la pérdida de clic bajo fallo de infraestructura es aceptable y el fallo de redirección no lo es. El consumidor click-ingester procesa el tópico de Redpanda y escribe a ClickHouse. Esta es la razón por la que los datos de analítica para un evento de clic dado están disponibles con un pequeño retraso (típicamente menos de 5 segundos), no instantáneamente.
Desafíos de bot en línea. Un desafío de bot añade 10-50ms de trabajo síncrono como mínimo - los desafíos JavaScript añaden un viaje de ida y vuelta completo. No hacemos ninguno en el camino de redirección. El servicio url-scanner procesa señales de calidad de tráfico de forma asíncrona. Para equipos de solutions/developers construyendo campañas de enlaces, esto significa que la redirección nunca se gatea detrás de un desafío que degrada la experiencia para el tráfico legítimo.
Validación de esquema en tiempo de redirección. La URL de destino y las reglas de segmentación se validan en tiempo de escritura, cuando el enlace se crea o actualiza vía api-core. Para cuando un slug aterriza en el caché, su estructura es conocida-válida. No hay validación de esquema JSON, ni paso de parseo de URL, ni comprobación de sintaxis de regla en tiempo de redirección. El binario del edge confía completamente en la entrada del caché. Esto solo es seguro porque el camino de escritura valida antes de la admisión al caché.
Las partes poco sexys#
Tres cosas sobre las que no escribimos lo suficiente, porque son aburridas de leer e importantes de hacer bien.
Presupuestos de tamaño de caché. ristretto se inicializa con un presupuesto de coste explícito en bytes, no un conteo simple de elementos. Cada enlace cacheado se costea por su tamaño serializado, que varía con el número de reglas de segmentación. Un enlace sin reglas cuesta aproximadamente 200 bytes; un enlace con 6 reglas de segmentación cuesta más cerca de 800 bytes. El presupuesto se establece para consumir como máximo el 10% de la RAM disponible de la instancia, dejando espacio para el runtime de Go, Caddy y los buffers de conexión. Hacer esto mal causa thrashing del caché: un presupuesto demasiado pequeño desaloja entradas antes de que el TTL expire, empujando tráfico hacia L2 y el origen.
Ajuste de GC bajo carga. El recolector de basura de Go está bien ajustado por defecto, pero el GOGC=100 predeterminado desencadena GC al doble del tamaño del heap vivo. Para un manejador de redirección donde el heap vivo es pequeño pero la tasa de asignación es moderada (fasthttp es sin asignación en el hot path, pero hay asignaciones de objeto para eventos de clic y llamadas gRPC), el GC dispara más frecuentemente de lo necesario. Ejecutamos GOGC=400 en producción. El efecto son ciclos GC más largos pero con menor frecuencia - lo que importa para la latencia de cola. Un ciclo GC que toma 2ms y ocurre una vez cada 4 segundos añade una contribución menor al p99 que un ciclo de 1ms cada segundo. Verificamos esto empíricamente con make bench antes de establecerlo en la configuración de despliegue.
La disciplina de make bench. El binario del edge tiene un suite de benchmark (go test -bench=. -benchmem ./... desde dentro de services/edge-redirect). Cada cambio propuesto al hot path - añadir un nuevo encabezado, cambiar el formato de la clave del caché, ajustar el evaluador de reglas - pasa por los benchmarks antes del merge. Un cambio que añade 0,5ms al benchmark de p50 es un cambio que mueve el p95 en producción. El benchmark es la puerta, no una comprobación post-hoc. Nos relajamos con esto una vez, en una refactorización que cambió la lógica de normalización de slug, y enviamos una regresión de 1,2ms que apareció en los paneles de la región dos días después. La regresión fue real y la lección se quedó.
Las decisiones de arquitectura aquí están documentadas con más detalle en /docs/architecture/edge-redirect. Si estás evaluando Elido como una capa de infraestructura de redirección para una campaña de alto volumen o una plataforma para desarrolladores, la página solutions/developers cubre la superficie de la API y las opciones de SDK. Para una mirada a lo que el caché de dos niveles implica para el comportamiento de smart link - particularmente la ventana de propagación para cambios de regla - el artículo de smart links explicado lo cubre en profundidad.
Marius Voß es DevRel y edge infra en Elido. Fue uno de los ingenieros que envió el binario edge-redirect desde prototipo a producción y ha estado mirando sus paneles de latencia desde entonces.
Prueba Elido
Pega una URL, obtén un enlace corto
Sin registro. El enlace vive 30 días. Crea una cuenta para conservarlo.
Gratis, sin registro · 2 por día