17 min de lectureIngénierie
Pilier

Atteindre un p95 < 15 ms pour les redirections depuis FRA, ASH et SGP

Comment le chemin edge-redirect d'Elido tient un budget p95 de 15 ms sur cache HIT à travers trois régions - architecture, stratégie de cache, mesures réelles par région

Marius Voß
DevRel · edge infra
World map showing Elido edge POPs in Frankfurt, Ashburn, and Singapore with p95 latency annotations of 12ms, 13ms, and 14ms respectively

Une redirection est un blocage synchrone. L'utilisateur clique sur votre lien court, son navigateur cale, et rien d'autre ne se passe jusqu'à ce que le 302 arrive et que sa prochaine charge de page puisse commencer. La redirection n'est pas une tâche d'arrière-plan que vous pouvez déprioriser. Chaque milliseconde que vous ajoutez ici est une milliseconde soustraite à la page qui compte vraiment.

C'est pourquoi nous avons fixé un budget strict avant d'écrire la première ligne de services/edge-redirect : p50 5 ms, p95 15 ms sur cache hit, mesuré au POP, hors handshake TLS complet. Pas aspirationnel. Si quelque chose nous pousse au-delà de la ligne, c'est retiré ou déplacé vers un chemin asynchrone.

Nous faisons tourner trois régions de production - Francfort (FRA), Ashburn (ASH) et Singapour (SGP) - depuis plusieurs mois maintenant. Cet article est un compte rendu complet de comment fonctionne le chemin chaud, pourquoi les chiffres sont ce qu'ils sont, et ce que nous avons mal fait au début.

TL;DR#

  • Le chemin chaud est Go + fasthttp sur Hetzner FRA/ASH et OVH SGP, derrière Caddy avec routage anycast. Pas de scoring de bots synchrone, pas de défi JS sur le chemin de redirection.
  • Cache à deux niveaux : LRU ristretto en process (L1, ~88 % de taux de hit) soutenu par Redis Cluster (L1+L2 combinés ~99,4 %). Origine gRPC vers api-core uniquement sur cold miss (~0,6 % des requêtes).
  • p95 sur 90 jours par région : FRA 12,1 ms, ASH 13,4 ms, SGP 14,2 ms. Le cold miss ajoute ~22 ms en p95, toujours dans le budget.
  • L'invalidation de cache sur mutation de lien est Redis pub/sub, propagation sous la seconde p99. Le TTL L1 est de 60 secondes comme filet de sécurité.

Pourquoi un plafond de 15 ms#

Avant de plonger dans l'architecture : pourquoi 15 ms et pas 50 ms ou 5 ms ?

Le plancher de 5 ms est simple - c'est à peu près ce que coûte le transit réseau physique en médiane pour un visiteur européen touchant un POP de Francfort. Vous ne pouvez pas couper la physique. Le plafond de 50 ms est trop lâche - à 50 ms p95, vous ajoutez un calage perceptible avant chaque vue de page pour une fraction significative de votre trafic. La recherche sur la performance web montre constamment que les délais réseau sous 50 ms commencent à être perceptibles sur les appareils mobiles où la latence radio se cumule avec le temps de traitement, un point que les directives de programmation network-aware d'Apple rendent explicitement.

Le nombre de 15 ms a atterri à partir de quelques contraintes concrètes. Premièrement, les redirections se cumulent. Si une campagne marketing envoie du trafic via un lien raccourci qui redirige ensuite vers une page produit, la latence de la redirection s'ajoute au TTFB de la landing page. Les Core Web Vitals de Google utilisent LCP comme signal principal, et une chaîne de redirection qui ajoute 50 ms en p95 est mesurable. Deuxièmement, nous voulons une marge de budget suffisante pour faire tourner l'évaluation de règles pour les smart links en ligne sur le chemin chaud - les dimensions de routage (pays, appareil, OS, langue, heure, référent) doivent s'exécuter dans la même enveloppe de latence qu'une redirection simple, ou nous devrions retirer le support des smart links du edge. À 15 ms avec un coût d'évaluation de règles de ~0,3 ms, il y a de la place.

Le budget de 15 ms s'applique au trafic cache-hit. Les cold miss sont autorisés à être plus lents - l'appel gRPC origine ajoute de la latence - mais les cold miss par conception sont assez rares pour qu'ils ne déplacent pas significativement le p95.

L'architecture#

Trois POP, chacun avec le même binaire : services/edge-redirect, écrit en Go en utilisant fasthttp. Le débit serveur de fasthttp est environ 8x celui de net/http dans la suite de benchmarks et, plus pratiquement pour nous, son chemin de requête zéro-alloc maintient les pauses GC prévisibles sous charge soutenue. La net/http de la bibliothèque standard convient pour la plupart des services ; pour un handler de redirection qui doit maintenir un temps de traitement sous la milliseconde à haute concurrence, éviter l'allocation de tas par requête vaut l'API moins ergonomique.

Caddy est devant comme terminateur TLS et reverse proxy. Le TLS à la demande pour les domaines personnalisés de tenants (décrit en détail sur la page de fonctionnalité des domaines personnalisés) provisionne les certificats à la première requête. Nous avons évalué HAProxy et nginx comme alternatives - les deux sont rapides, les deux ont des patterns matures de déploiement anycast, mais le TLS à la demande de Caddy est le chemin le plus propre vers un cycle de vie de certificat sans intervention pour un nombre arbitraire de domaines clients, et cela nous importe plus que de gratter une fraction de milliseconde supplémentaire à la couche proxy.

Le routage anycast signifie que lorsqu'un visiteur touche f.elido.me, s.elido.me, ou b.elido.me, le DNS se résout vers un préfixe anycast partagé et le réseau achemine la connexion TCP au POP le plus proche. Il n'y a pas de logique de géo-routage au niveau applicatif : le réseau fait la sélection de POP. La primer anycast de Cloudflare est l'explication publique la plus claire de pourquoi cela compte - la propriété clé est que le basculement est géré à la couche BGP, pas par expiration de TTL DNS. Si FRA perd la connectivité, ASH devient le chemin le plus court pour le trafic européen en quelques secondes, pas en minutes. Les docs de l'infrastructure réseau cloud de Hetzner couvrent la configuration de routage sous-jacente pour leurs régions FRA et ASH.

Important : il n'y a pas de scoring de bots synchrone sur le chemin chaud. Un contrôle de scoring de bots qui prend 10 ms détruirait à lui seul le budget p95. Tous les signaux de qualité de trafic - détection d'anonymiseurs, scoring d'ASN d'hébergement, déduplication de clics - tournent dans url-scanner et click-ingester comme workers asynchrones de chemin froid. La redirection se déclenche et le clic part dans la file Redpanda ; l'adjudication de qualité se fait après coup.

Le cache à deux niveaux#

Le cache est où vit le budget. La logique :

// 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 est ristretto, le cache LRU à contrôle d'admission de Dgraph. Le contrôleur d'admission compte : un LRU naïf sous une charge de scan (un bot frappant des milliers de slugs uniques) évincera les entrées chaudes pour faire de la place aux entrées froides qui ne seront jamais redemandées. La politique d'admission basée sur TinyLFU de ristretto résiste à cela - elle suit les compteurs de fréquence à bon marché et refuse d'admettre une entrée jamais vue auparavant quand le cache est sous pression. L'effet net est que le taux de hit de cache sous trafic de scan adversaire reste proche du taux de hit organique plutôt que de s'effondrer.

L2 est Redis Cluster. Chaque POP a sa propre instance de cluster pour garder le trafic inter-régions hors du chemin chaud. FRA et ASH partagent une instance Redis distincte pour les signaux d'invalidation pub/sub (plus à ce sujet ci-dessous) ; SGP a la sienne. Un seul GET Redis dans le même datacenter est de manière fiable sous 1 ms. Le taux de hit L1+L2 combiné se situe à environ 99,4 % sur les 90 derniers jours - ce qui signifie que les appels d'origine se produisent sur environ 1 requête sur 167.

Pour le cas d'usage solutions/développeurs - équipes utilisant l'API pour frapper des liens à haut volume - l'implication pratique est qu'un lien fraîchement créé subira un cold miss par POP, puis sera chaud pour la durée de son TTL. Les liens qui ne voient aucun trafic expirent des deux caches proprement sans éviction manuelle.

Où vont les 15 ms#

Le diagramme ci-dessous décompose le budget p95 cache-hit par phase :

Horizontal stacked bar showing the 15ms p95 cache-hit budget decomposed into TLS resume 2ms, L1 lookup 0.4ms, header build 1ms, network return 9ms, and margin 2.6ms. Illustrative FRA median values.

Le segment dominant est le retour réseau - environ 9 ms en médiane, ce qui signifie que la distance physique entre le visiteur et le POP représente 60 % du budget. Nous ne pouvons pas compresser cela. Le déploiement multi-régions est le seul levier : ajouter un POP réduit le RTT médian pour les visiteurs dans cette région. La prochaine région sur la roadmap réduit le p95 SGP pour le trafic d'Asie du Sud, où nous routons actuellement 14 ms parce que Singapour est le POP le plus proche.

La reprise de session TLS à 2 ms suppose TLS 1.3 0-RTT avec un ticket de session déjà en main. Pour une première visite d'un appareil donné, un handshake TLS complet ajoute environ 10-15 ms par-dessus - c'est pourquoi le budget de 15 ms est explicitement délimité au trafic cache-hit + session reprise, qui est la grande majorité du trafic de clics en pratique. RFC 7234 régit la sémantique de mise en cache pour la couche HTTP ; notamment, les réponses 302 ne sont pas stockées par les caches navigateur par défaut (§4.2.2), ce qui est le bon comportement pour notre cas d'usage - chaque requête de redirection atteint le edge, chaque redirection reçoit sa propre décision de routage, pas de destination périmée dans le cache du navigateur.

La marge de 2,6 ms est une réelle marge opérationnelle, pas du remplissage. Sous le GC de Go, des pauses stop-the-world occasionnelles de l'ordre de 0,5-1 ms sont attendues même avec des réglages GOGC ajustés. La surcharge de proxy de Caddy ajoute un petit coût fixe. La marge nous empêche de violer le budget quand ces effets se cumulent.

Invalidation de cache#

Redis pub/sub est le mécanisme. Lorsqu'un lien est muté dans api-core - destination changée, règles de ciblage mises à jour, lien archivé - le handler de mutation publie sur un canal link:invalidate avec le slug comme charge utile. Chaque POP edge s'abonne à ce canal. À la réception, l'abonné appelle l1.Del(slug) et redis.Del(cacheKey(slug)). La prochaine requête vers ce slug repeuple les deux niveaux depuis l'origine.

Le TTL L1 de 60 secondes est le repli, pas le mécanisme principal. Si l'abonné pub/sub est en panne - disons, un hoquet Redis ou une partition réseau entre le POP et l'instance pub/sub - l'entrée expire de L1 dans au plus 60 secondes. Le TTL L2 est réglé à 300 secondes, donc une panne d'abonné signifie jusqu'à 5 minutes de données L2 potentiellement périmées, pendant lesquelles le TTL L1 est le seul filet de sécurité. Nous alertons sur la perte d'abonnement pub/sub dans les 30 secondes.

Pour les smart links avec des règles fenêtrées temporellement, l'obsolescence a une implication spécifique : si une règle s'active à 17:00 et que la L1 du POP edge a la version précédente de la règle en cache avec jusqu'à 60 secondes de TTL restant, le trafic entre 17:00 et 17:01 peut aller à la destination pré-mise-à-jour. Le chemin pub/sub élimine cela pour le cas commun ; le TTL de 60 secondes attrape le cas limite. Pour les campagnes où la frontière temporelle compte précisément, le pattern recommandé est d'utiliser status=disabled sur l'ancienne règle, d'attendre un cycle TTL (60 secondes), puis d'activer la nouvelle. Nous avons ajouté un endpoint de polling à GET /v1/links/{id}/cache-status pour que les pipelines puissent confirmer la propagation avant de procéder.

Mesures réelles par région#

Les nombres suivants proviennent de données d'espace de travail de démo collectées sur 90 jours se terminant le 12/05/2026. Ils reflètent le trafic cache-hit uniquement. Tous les horodatages sont UTC.

RégionPOPp50p95p99
UE (Francfort)FRA · Hetzner4,8 ms12,1 ms18,4 ms
US East (Ashburn)ASH · Hetzner5,2 ms13,4 ms20,1 ms
SE Asia (Singapour)SGP · OVH5,6 ms14,2 ms22,8 ms

FRA est le plus rapide parce que la majorité de la charge de travail est européenne, donc le RTT médian est plus bas. SGP sert une dispersion géographique plus large - le trafic d'Asie du Sud-Est a un RTT plus bas, tandis que le trafic d'Asie du Sud et d'Asie de l'Est s'ajoute à la queue.

Les nombres p99 dépassent le budget de 15 ms. C'est délibéré. Le p95 est le budget, pas le p99. Le p99 est façonné par des conditions aberrantes : transferts cellulaires, retransmissions TCP, pic occasionnel de latence Redis. Nous surveillons le p99 mais nous ne nous engageons pas en SLA contre lui. La décision d'ingénierie est que le p95 capture l'expérience pour « presque tout le monde presque tout le temps », et optimiser le dernier 1 % nécessiterait d'éliminer des sources de variabilité réseau naturelle qui ne sont pas sous notre contrôle.

Le p95 sur cold miss est approximativement 22 ms. C'est le plancher que nous pouvons atteindre étant donné que l'origine gRPC ajoute un aller-retour intra-datacenter (FRA → FRA sur réseau privé est approximativement 0,3 ms) plus la recherche Postgres api-core (typiquement 1-3 ms pour une recherche de slug indexée). Le chiffre de 22 ms est mesuré, pas estimé ; il est dans le budget que nous autorisons pour les chemins cold miss, qui est fixé à 35 ms p95.

Pour les équipes évaluant l'analytique multi-régions, ces chiffres de latence sont disponibles comme une métrique Prometheus (redirect_duration_seconds avec les labels region et cache_tier) depuis l'endpoint de métriques.

Modes de défaillance dont nous n'avions pas bloggé la première fois#

Thundering herd à l'expiration de clé#

Avant d'ajouter singleflight, un slug expirant simultanément de L1 et L2 sous trafic modéré générait une rafale d'appels gRPC origine concurrents - chacun faisant une lecture Postgres pour le même slug, tous renvoyant le même résultat. Sous test de charge, cela produisait des pics de CPU dans api-core qui n'avaient rien à voir avec le volume de création de liens. Le groupe singleflight effondre les misses concurrents pour le même slug en un seul appel origine. Les autres goroutines en attente bloquent sur le groupe et obtiennent le même résultat quand il se résout. L'implémentation est le package Go standard golang.org/x/sync/singleflight.

Nous avons fait cela de travers dans le premier prototype. Un thundering herd à l'expiration de clé est l'un de ces modes de défaillance qui n'apparaissent pas dans les tests unitaires - il n'apparaît que sous une concurrence réaliste. Je l'ajoute à cet article parce que c'est une omission courante dans les écrits d'architecture de cache et que le correctif est vraiment simple.

Repli sur hoquet Redis#

Si un POP perd la connectivité à son cluster Redis, le repli n'est pas une erreur - le chemin de code se dégrade vers L1 seul plus gRPC origine direct sur miss L1. Le POP continue de servir. Le taux de hit chute parce que L2 n'est pas disponible, donc le volume d'appels d'origine fait un pic, mais le chemin de redirection reste fonctionnel. Le chemin de hoquet Redis a été exercé deux fois en production (les deux étaient des fenêtres de maintenance Hetzner). Le taux d'appels d'origine de pointe pendant le second incident était d'environ 8x la baseline pour la durée du hoquet (~4 minutes). api-core l'a géré sans événements de scaling.

Propagation DNS pendant le basculement de POP#

Le basculement anycast est au niveau BGP - pas de TTL DNS à attendre, pas de timeout de healthcheck au niveau applicatif dans le chemin de requête. Un POP qui passe hors ligne déclenche un retrait BGP de la route, et le trafic réseau se déplace vers le POP le plus proche suivant dans la fenêtre de convergence BGP (typiquement 15-90 secondes selon le nombre de sauts réseau jusqu'au chemin affecté). Le paramètre opérationnel pertinent est notre intervalle de healthcheck : nous faisons des healthchecks TCP toutes les 10 secondes par POP. Un échec de check déclenche le retrait. Un intervalle de check de 10 secondes signifie qu'un POP crashé peut servir jusqu'à 10 secondes de trafic en échec avant le retrait. Nous avons testé cette frontière délibérément ; l'impact réel dans les deux incidents de production était sous l'intervalle de check.

Ce que nous ne faisons pas sur le chemin chaud#

Chaque élément qui n'est pas sur le chemin chaud est un choix délibéré, pas une omission.

Écritures de clics synchrones. Les clics sont fire-and-forget vers Redpanda. Le handler de redirection ajoute un événement de clic à un topic Kafka (clicks.raw) avec le slug, l'horodatage, l'IP tronquée et le hash du user-agent, puis répond avec le 302. L'écriture est non bloquante. Si Redpanda n'est pas disponible, le clic est abandonné - pas la redirection. Nous avons fait le compromis conscient que la perte de clic en cas de défaillance d'infrastructure est acceptable et que l'échec de redirection ne l'est pas. Le consommateur click-ingester traite le topic Redpanda et écrit dans ClickHouse. C'est pourquoi les données d'analytique pour un événement de clic donné sont disponibles avec un court délai (typiquement sous 5 secondes), pas instantanément.

Défis de bots en ligne. Un défi de bots ajoute 10-50 ms de travail synchrone au minimum - les défis JavaScript ajoutent un aller-retour complet. Nous ne faisons ni l'un ni l'autre sur le chemin de redirection. Le service url-scanner traite les signaux de qualité de trafic de manière asynchrone. Pour les équipes solutions/développeurs construisant des campagnes de liens, cela signifie que la redirection n'est jamais bloquée derrière un défi qui dégrade l'expérience pour le trafic légitime.

Validation de schéma au moment de la redirection. L'URL de destination et les règles de ciblage sont validées au moment de l'écriture, lorsque le lien est créé ou mis à jour via api-core. Au moment où un slug atterrit dans le cache, sa structure est connue-valide. Il n'y a pas de validation de schéma JSON, pas d'étape de parsing d'URL, pas de vérification de syntaxe de règles au moment de la redirection. Le binaire edge fait entièrement confiance à l'entrée de cache. Cela n'est sûr que parce que le chemin d'écriture valide avant l'admission au cache.

Les parties pas sexy#

Trois choses sur lesquelles nous n'écrivons pas assez, parce qu'elles sont ennuyeuses à lire et importantes à bien faire.

Budgets de taille de cache. ristretto est initialisé avec un budget de coût explicite en octets, pas un simple compte d'items. Chaque lien en cache est coûté par sa taille sérialisée, qui varie avec le nombre de règles de ciblage. Un lien sans règles coûte environ 200 octets ; un lien avec 6 règles de ciblage coûte plus près de 800 octets. Le budget est fixé à consommer au plus 10 % de la RAM disponible de l'instance, laissant de la marge pour le runtime Go, Caddy et les buffers de connexion. Mal faire cela provoque du thrashing de cache : un budget trop petit évince les entrées avant l'expiration du TTL, poussant le trafic vers L2 et l'origine.

Réglage GC sous charge. Le garbage collector de Go est bien réglé par défaut, mais le GOGC=100 par défaut déclenche le GC à deux fois la taille du heap vivant. Pour un handler de redirection où le heap vivant est petit mais le taux d'allocation est modéré (fasthttp est zéro-alloc sur le chemin chaud, mais il y a des allocations d'objets pour les événements de clic et les appels gRPC), le GC se déclenche plus fréquemment que nécessaire. Nous faisons tourner GOGC=400 en production. L'effet est des cycles GC plus longs mais une fréquence plus basse - ce qui compte pour la latence de queue. Un cycle GC qui prend 2 ms et arrive une fois toutes les 4 secondes ajoute une contribution plus petite au p99 qu'un cycle de 1 ms chaque seconde. Nous avons vérifié cela empiriquement avec make bench avant de le régler dans la configuration de déploiement.

La discipline make bench. Le binaire edge a une suite de benchmarks (go test -bench=. -benchmem ./... depuis services/edge-redirect). Chaque changement proposé au chemin chaud - ajouter un nouvel en-tête, changer le format de clé de cache, ajuster l'évaluateur de règles - passe par les benchmarks avant le merge. Un changement qui ajoute 0,5 ms au benchmark p50 est un changement qui déplace le p95 en production. Le benchmark est la porte, pas une vérification post-hoc. Nous nous sommes relâchés là-dessus une fois, dans un refactor qui a changé la logique de normalisation des slugs, et avons livré une régression de 1,2 ms qui s'est montrée sur les tableaux de bord régionaux deux jours plus tard. La régression était réelle et la leçon est restée.


Les décisions architecturales ici sont documentées plus en détail à /docs/architecture/edge-redirect. Si vous évaluez Elido comme couche d'infrastructure de redirection pour une campagne à haut volume ou une plateforme développeur, la page solutions/développeurs couvre la surface API et les options SDK. Pour un aperçu de ce que le cache à deux niveaux implique pour le comportement des smart links - en particulier la fenêtre de propagation pour les changements de règles - l'article smart links expliqués couvre cela en profondeur.


Marius Voß est DevRel et edge infra chez Elido. Il a fait partie des ingénieurs qui ont livré le binaire edge-redirect du prototype à la production et fixe ses tableaux de bord de latence depuis.

Essayer Elido

Collez une URL, obtenez un lien court

Sans inscription. Lien actif 30 jours. Inscrivez-vous pour le garder pour toujours.

Gratuit, sans inscription · 2 par jour

Essayer Elido

Raccourcisseur d'URL hébergé en UE : domaines personnalisés, analyses approfondies et API ouverte. Forfait gratuit - sans carte bancaire.

Tags
url shortener performance
edge redirect latency
multi-region url shortener
redirect cache strategy
fasthttp
anycast routing

Lire la suite