Elido
16 min di letturaIngegneria
Pilastro

Raggiungere p95 < 15ms per i redirect da FRA, ASH e SGP

Come il percorso edge-redirect di Elido mantiene un budget p95 di 15ms su cache HIT in tre regioni - architettura, strategia di cache, misurazioni reali per regione

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

Un redirect è un blocco sincrono. L'utente clicca il tuo link abbreviato, il suo browser si ferma, e non succede nulla finché non arriva il 302 e il caricamento della pagina successiva può iniziare. Il redirect non è un'attività in background che puoi deprioritizzare. Ogni millisecondo che aggiungi qui è un millisecondo sottratto alla pagina che conta davvero.

Ecco perché abbiamo fissato un budget rigido prima di scrivere la prima riga di services/edge-redirect: p50 5ms, p95 15ms su un cache hit, misurato al POP, escluso il full handshake TLS. Non aspirazionale. Se qualcosa ci spinge oltre la soglia, viene rimosso o spostato su un percorso asincrono.

Abbiamo operato tre regioni di produzione - Frankfurt (FRA), Ashburn (ASH) e Singapore (SGP) - per diversi mesi ormai. Questo post è un resoconto completo di come funziona il percorso hot, del perché i numeri sono come sono e di cosa abbiamo sbagliato la prima volta.

TL;DR#

  • Il percorso hot è Go + fasthttp su Hetzner FRA/ASH e OVH SGP, dietro Caddy con anycast routing. Nessuno scoring bot sincrono, nessuna JS challenge sul percorso di redirect.
  • Cache a due livelli: LRU ristretto in-process (L1, ~88% di hit rate) supportato da Redis Cluster (L1+L2 combinati ~99,4%). gRPC all'origin verso api-core solo su cold miss (~0,6% delle richieste).
  • p95 su 90 giorni per regione: FRA 12,1ms, ASH 13,4ms, SGP 14,2ms. Il cold miss aggiunge ~22ms al p95, ancora entro il budget.
  • L'invalidazione della cache sulle mutazioni dei link avviene tramite Redis pub/sub, propagazione sub-secondo al p99. Il TTL L1 è di 60 secondi come rete di sicurezza.

Perché un tetto di 15ms#

Prima di entrare nell'architettura: perché 15ms e non 50ms o 5ms?

Il pavimento di 5ms è semplice - è grosso modo il costo del transito di rete fisico alla mediana per un visitatore europeo che colpisce un POP di Francoforte. Non puoi scendere sotto la fisica. Il tetto di 50ms è troppo largo - a 50ms al p95, stai aggiungendo uno stallo percettibile prima di ogni pageview per una frazione significativa del tuo traffico. La ricerca sulle performance web mostra costantemente che i ritardi di rete sub-50ms iniziano a diventare percettibili sui dispositivi mobile dove la latenza radio si somma al tempo di elaborazione, un punto che le linee guida di programmazione network-aware di Apple esplicitano chiaramente.

Il numero di 15ms è emerso da alcuni vincoli concreti. Primo, i redirect si sommano. Se una campagna di marketing invia traffico attraverso un link abbreviato che poi reindirizza a una pagina prodotto, la latenza del redirect si aggiunge al TTFB della landing page. I Core Web Vitals di Google usano LCP come segnale primario, e una catena di redirect che aggiunge 50ms al p95 è misurabile. Secondo, vogliamo abbastanza margine di budget per eseguire la valutazione delle regole per i smart link in linea sul percorso hot - le dimensioni di routing (paese, dispositivo, OS, lingua, ora, referrer) devono essere eseguite entro la stessa busta di latenza di un redirect semplice, altrimenti dovremmo eliminare il supporto ai smart link dall'edge. A 15ms con un costo di valutazione delle regole di ~0,3ms, c'è margine.

Il budget di 15ms si applica al traffico con cache hit. I cold miss possono essere più lenti - la chiamata gRPC all'origin aggiunge latenza - ma i cold miss per design sono abbastanza rari da non spostare significativamente il p95.

L'architettura#

Tre POP, ciascuno con lo stesso binario: services/edge-redirect, scritto in Go usando fasthttp. Il throughput del server di fasthttp è circa 8x quello di net/http nella suite di benchmark e, più praticamente per noi, il suo percorso di richiesta zero-alloc mantiene le pause GC prevedibili sotto carico sostenuto. La net/http della libreria standard va bene per la maggior parte dei servizi; per un redirect handler che deve mantenere un tempo di elaborazione sub-millisecondo ad alta concorrenza, evitare l'allocazione heap per richiesta vale l'API meno ergonomica.

Caddy si trova davanti come terminatore TLS e reverse proxy. Il TLS on-demand per i domini personalizzati dei tenant (descritto in dettaglio nella pagina delle funzionalità dei domini personalizzati) fornisce certificati alla prima richiesta. Abbiamo valutato HAProxy e nginx come alternative - entrambi sono veloci, entrambi hanno pattern di deployment anycast maturi, ma il TLS on-demand di Caddy è il percorso più pulito verso un ciclo di vita dei certificati zero-touch per un numero arbitrario di domini dei clienti, e questo conta più di strappare un'altra frazione di millisecondo al layer proxy.

L'anycast routing significa che quando un visitatore colpisce f.elido.me, s.elido.me o b.elido.me, il DNS si risolve a un prefisso anycast condiviso e la rete instrada la connessione TCP al POP più vicino. Non c'è logica di geo-routing a livello applicativo: è la rete a fare la selezione del POP. Il primer anycast di Cloudflare è la spiegazione pubblica più chiara del perché questo sia importante - la proprietà chiave è che il failover è gestito a livello BGP, non dalla scadenza del TTL DNS. Se FRA perde la connettività, ASH diventa il percorso più breve per il traffico europeo in pochi secondi, non minuti. La documentazione dell'infrastruttura cloud di Hetzner copre la configurazione di routing sottostante per le loro regioni FRA e ASH.

Importante: non c'è scoring bot sincrono sul percorso hot. Un controllo di scoring bot che richiede 10ms distruggerebbe da solo il budget p95. Tutti i segnali di qualità del traffico - rilevamento degli anonymizer, scoring ASN dell'hosting, deduplicazione dei clic - vengono eseguiti in url-scanner e click-ingester come worker asincroni sul cold path. Il redirect si attiva e il clic va in coda su Redpanda; l'adjudication della qualità avviene dopo il fatto.

La cache a due livelli#

La cache è il posto in cui vive il budget. La logica:

// Cache lookup semplificata: L1 → L2 → origin, con deduplicazione singleflight
func (h *RedirectHandler) resolve(ctx *fasthttp.RequestCtx, slug string) (*Link, error) {
    // L1: LRU ristretto in-process - sub-microsecondo su hit
    if link, ok := h.l1.Get(slug); ok {
        return link.(*Link), nil
    }

    // L2 + origin condividono un gruppo singleflight per prevenire il thundering herd
    // su cold miss concorrenti per lo stesso slug
    val, err, _ := h.sf.Do(slug, func() (interface{}, error) {
        // L2: Redis Cluster - singolo RTT, tipicamente 0,3–0,8ms all'interno del 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 verso api-core - cold miss, ~20ms in più
        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 è ristretto, la cache LRU a controllo di ammissione di Dgraph. Il controller di ammissione conta: un LRU naive sotto un workload di scansione (un bot che colpisce migliaia di slug unici) espellerà le voci hot per far posto a quelle cold che non verranno mai richieste di nuovo. La politica di ammissione basata su TinyLFU di ristretto resiste a questo - tiene traccia dei counter di frequenza in modo economico e si rifiuta di ammettere una voce che non è mai stata vista prima quando la cache è sotto pressione. L'effetto netto è che il tasso di hit della cache sotto traffico di scansione avversariale rimane vicino al tasso di hit organico invece di collassare.

L2 è Redis Cluster. Ogni POP ha la propria istanza cluster per tenere il traffico cross-regione fuori dal percorso hot. FRA e ASH condividono un'istanza Redis separata per i segnali di invalidazione pub/sub (ne parleremo più avanti); SGP ha la sua. Un singolo GET Redis all'interno dello stesso datacenter è affidabilmente sotto 1ms. Il tasso di hit L1+L2 combinato si attesta a circa il 99,4% negli ultimi 90 giorni - il che significa che le chiamate all'origin avvengono circa 1 ogni 167 richieste.

Per il caso d'uso solutions/developers - team che usano l'API per coniare link ad alto volume - l'implicazione pratica è che un link appena creato sperimenterà un cold miss per POP, poi sarà warm per la durata del suo TTL. I link che non ricevono traffico scadono da entrambe le cache in modo pulito senza eviction manuale.

Dove vanno i 15ms#

Il diagramma seguente scompone il budget p95 per cache hit per fase:

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.

Il segmento dominante è il ritorno di rete - circa 9ms alla mediana, il che significa che la distanza fisica tra il visitatore e il POP rappresenta il 60% del budget. Non possiamo comprimere questo. Il deployment multi-regione è la sola leva: aggiungere un POP riduce il RTT mediano per i visitatori in quella regione. La prossima regione nella roadmap riduce il p95 SGP per il traffico dell'Asia del Sud, dove attualmente stiamo routing a 14ms perché Singapore è il POP più vicino.

La ripresa della sessione TLS a 2ms presuppone TLS 1.3 0-RTT con un ticket di sessione già in mano. Per una prima visita da un dispositivo specifico, un full TLS handshake aggiunge circa 10-15ms in più - ecco perché il budget di 15ms scoping esplicitamente il traffico cache-hit + sessione-ripresa, che è la grande maggioranza del traffico di clic in pratica. La RFC 7234 governa la semantica di caching per il layer HTTP; in particolare, le risposte 302 non vengono memorizzate nella cache del browser per default (§4.2.2), che è il comportamento corretto per il nostro caso d'uso - ogni richiesta di redirect raggiunge l'edge, ogni redirect riceve la propria decisione di routing, nessuna destinazione obsoleta nella cache del browser.

Il margine di 2,6ms è reale headroom operativo, non padding. Sotto il GC di Go, le pause stop-the-world occasionali dell'ordine di 0,5-1ms sono attese anche con impostazioni GOGC ottimizzate. L'overhead del proxy Caddy aggiunge un piccolo costo fisso. Il margine ci impedisce di superare il budget quando questi effetti si sommano.

Invalidazione della cache#

Redis pub/sub è il meccanismo. Quando un link viene mutato in api-core - destinazione cambiata, regole di targeting aggiornate, link archiviato - il gestore della mutazione pubblica sul canale link:invalidate con lo slug come payload. Ogni POP edge si abbona a questo canale. Alla ricezione, il subscriber chiama l1.Del(slug) e redis.Del(cacheKey(slug)). La prossima richiesta per quello slug ripopola entrambi i livelli dall'origin.

Il TTL L1 di 60 secondi è il fallback, non il meccanismo principale. Se il subscriber pub/sub è down - diciamo un blip Redis o una partizione di rete tra il POP e l'istanza pub/sub - la voce scade da L1 entro al massimo 60 secondi. Il TTL L2 è impostato a 300 secondi, quindi un'interruzione del subscriber significa fino a 5 minuti di dati L2 potenzialmente obsoleti, durante i quali il TTL L1 è l'unica rete di sicurezza. Alertiamo sulla perdita della sottoscrizione pub/sub entro 30 secondi.

Per gli smart link con regole a finestra temporale, la staleness ha un'implicazione specifica: se una regola si attiva alle 17:00 e l'L1 del POP edge ha la versione precedente della regola in cache con fino a 60 secondi di TTL residuo, il traffico tra 17:00 e 17:01 potrebbe andare alla destinazione pre-aggiornamento. Il percorso pub/sub elimina questo per il caso comune; il TTL di 60 secondi gestisce il caso limite. Per le campagne in cui il limite di tempo conta precisamente, il pattern consigliato è usare status=disabled sulla vecchia regola, attendere un ciclo TTL (60 secondi), poi attivare quella nuova. Abbiamo aggiunto un endpoint di polling su GET /v1/links/{id}/cache-status in modo che le pipeline possano confermare la propagazione prima di procedere.

Misurazioni reali per regione#

I seguenti numeri provengono da dati del workspace demo raccolti nel corso di 90 giorni che terminano il 2026-05-12. Riflettono solo il traffico con cache hit. Tutti i timestamp sono in UTC.

RegionePOPp50p95p99
EU (Frankfurt)FRA · Hetzner4,8ms12,1ms18,4ms
US East (Ashburn)ASH · Hetzner5,2ms13,4ms20,1ms
SE Asia (Singapore)SGP · OVH5,6ms14,2ms22,8ms

FRA è il più veloce perché la maggior parte del workload è europea, quindi il RTT mediano è inferiore. SGP serve una distribuzione geografica più ampia - il traffico del Sud-Est asiatico ha un RTT inferiore, mentre quello dell'Asia meridionale e orientale aggiunge alla coda.

I numeri p99 superano il budget di 15ms. È deliberato. Il p95 è il budget, non il p99. Il p99 è modellato da condizioni anomale: trasferimenti cellulari, ritrasmissioni TCP, l'occasionale picco di latenza Redis. Monitoriamo il p99 ma non lo abbiamo in SLA. La decisione ingegneristica è che il p95 cattura l'esperienza per "quasi tutti quasi sempre", e ottimizzare l'ultimo 1% richiederebbe di eliminare fonti di variabilità naturale della rete che non sono sotto il nostro controllo.

Il p95 per cold miss è circa 22ms. Questo è il pavimento che possiamo raggiungere dato che il gRPC all'origin aggiunge un round-trip nello stesso datacenter (FRA → FRA sulla rete privata è circa 0,3ms) più la ricerca Postgres di api-core (tipicamente 1-3ms per una ricerca slug con chiave). Il numero di 22ms è misurato, non stimato; è nel budget che consentamo per i percorsi cold-miss, impostato a 35ms p95.

Per i team che valutano l'analytics multi-regione, questi numeri di latenza sono disponibili come metrica Prometheus (redirect_duration_seconds con etichette region e cache_tier) dall'endpoint delle metriche.

Modi di fallimento di cui non abbiamo scritto la prima volta#

Thundering herd alla scadenza della chiave#

Prima di aggiungere singleflight, uno slug che scadeva da L1 e L2 simultaneamente sotto traffico moderato generava un burst di chiamate gRPC concorrenti all'origin - ognuna che faceva una lettura Postgres per lo stesso slug, tutte restituendo lo stesso risultato. Sotto test di carico, questo produceva picchi di CPU su api-core che non avevano nulla a che fare con il volume di creazione dei link. Il gruppo singleflight collassa i cold miss concorrenti per lo stesso slug in una singola chiamata all'origin. Le altre goroutine in attesa si bloccano sul gruppo e ottengono lo stesso risultato quando si risolve. L'implementazione è il pacchetto Go standard golang.org/x/sync/singleflight.

Lo abbiamo sbagliato nel primo prototipo. Un thundering herd alla scadenza della chiave è uno di quei modi di fallimento che non appare negli unit test - si manifesta solo sotto concorrenza realistica. Lo aggiungo a questo post perché è un'omissione comune nelle descrizioni dell'architettura della cache e la correzione è genuinamente semplice.

Fallback al blip Redis#

Se un POP perde la connettività al suo cluster Redis, il fallback non è un errore - il percorso del codice si degrada a solo L1 più gRPC diretto all'origin sul miss L1. Il POP continua a servire. Il tasso di hit diminuisce perché L2 non è disponibile, quindi il volume delle chiamate all'origin aumenta, ma il percorso del redirect rimane funzionale. Il percorso del blip Redis è stato esercitato due volte in produzione (entrambe erano finestre di manutenzione Hetzner). Il tasso massimo di chiamate all'origin durante il secondo incidente era circa 8x la baseline per la durata del blip (~4 minuti). api-core lo ha gestito senza eventi di scaling.

Propagazione DNS durante il failover del POP#

Il failover anycast è a livello BGP - nessun TTL DNS da aspettare, nessun timeout del controllo di salute a livello applicativo nel percorso della richiesta. Un POP che va offline attiva il ritiro BGP della route, e il traffico di rete si sposta al POP più vicino successivo entro la finestra di convergenza BGP (tipicamente 15-90 secondi a seconda del numero di hop di rete verso il percorso interessato). Il parametro operativo rilevante è il nostro intervallo di health-check: eseguiamo controlli TCP ogni 10 secondi per POP. Un fallimento del controllo attiva il ritiro. Un intervallo di controllo di 10 secondi significa che un POP in crash può servire fino a 10 secondi di traffico fallito prima del ritiro. Abbiamo testato questo limite deliberatamente; l'impatto reale nei due incidenti di produzione era al di sotto dell'intervallo di controllo.

Cosa non facciamo sul percorso hot#

Ogni elemento che non è sul percorso hot è una scelta deliberata, non un'omissione.

Scritture sincrone dei clic. I clic sono fire-and-forget verso Redpanda. Il redirect handler aggiunge un evento di clic a un topic Kafka (clicks.raw) con lo slug, il timestamp, l'IP troncato e l'hash dello user-agent, poi risponde con il 302. La scrittura è non-bloccante. Se Redpanda non è disponibile, il clic viene scartato - non il redirect. Abbiamo fatto la scelta consapevole che la perdita dei clic in caso di fallimento dell'infrastruttura è accettabile e il fallimento del redirect non lo è. Il consumer click-ingester elabora il topic Redpanda e scrive su ClickHouse. Ecco perché i dati di analytics per un determinato evento di clic sono disponibili con un breve ritardo (tipicamente meno di 5 secondi), non istantaneamente.

Challenge bot inline. Una challenge bot aggiunge almeno 10-50ms di lavoro sincrono - le JavaScript challenge aggiungono un intero round-trip. Non ne facciamo nessuna sul percorso di redirect. Il servizio url-scanner elabora i segnali di qualità del traffico in modo asincrono. Per i team solutions/developers che costruiscono campagne di link, questo significa che il redirect non è mai bloccato da una challenge che degrada l'esperienza per il traffico legittimo.

Validazione dello schema al momento del redirect. L'URL di destinazione e le regole di targeting vengono convalidati al momento della scrittura, quando il link viene creato o aggiornato tramite api-core. Nel momento in cui uno slug arriva nella cache, la sua struttura è nota-valida. Non c'è validazione JSON schema, nessun passaggio URL-parse, nessun controllo della sintassi delle regole al momento del redirect. Il binario edge si fida completamente della voce della cache. Questo è sicuro solo perché il percorso di scrittura valida prima dell'ammissione alla cache.

Le parti poco spettacolari#

Tre cose su cui non scriviamo abbastanza, perché sono noiose da leggere e importanti da fare bene.

Budget delle dimensioni della cache. ristretto è inizializzato con un budget di costo esplicito in byte, non con un semplice conteggio degli elementi. Ogni link in cache ha un costo in base alla sua dimensione serializzata, che varia con il numero di regole di targeting. Un link senza regole costa circa 200 byte; un link con 6 regole di targeting costa circa 800 byte. Il budget è impostato per consumare al massimo il 10% della RAM disponibile dell'istanza, lasciando headroom per il runtime Go, Caddy e i buffer di connessione. Sbagliare questo causa il thrashing della cache: un budget troppo piccolo espelle le voci prima della scadenza del TTL, spingendo il traffico verso L2 e l'origin.

Tuning GC sotto carico. Il garbage collector di Go è ben ottimizzato per default, ma il GOGC=100 predefinito attiva il GC al doppio delle dimensioni dell'heap live. Per un redirect handler in cui l'heap live è piccolo ma il tasso di allocazione è moderato (fasthttp è zero-alloc sul percorso hot, ma ci sono allocazioni di oggetti per gli eventi di clic e le chiamate gRPC), il GC si attiva più frequentemente del necessario. Eseguiamo GOGC=400 in produzione. L'effetto sono cicli GC più lunghi ma a frequenza inferiore - che conta per la latenza alla coda. Un ciclo GC che richiede 2ms e avviene una volta ogni 4 secondi aggiunge un contributo minore al p99 rispetto a un ciclo da 1ms ogni secondo. Lo abbiamo verificato empiricamente con make bench prima di impostarlo nella configurazione del deployment.

La disciplina del make bench. Il binario edge ha una suite di benchmark (go test -bench=. -benchmem ./... dall'interno di services/edge-redirect). Ogni modifica proposta al percorso hot - aggiunta di un nuovo header, modifica del formato della chiave cache, aggiustamento del valutatore delle regole - viene eseguita attraverso i benchmark prima del merge. Una modifica che aggiunge 0,5ms al benchmark p50 è una modifica che sposta il p95 in produzione. Il benchmark è il gate, non un controllo ex-post. Una volta siamo stati negligenti al riguardo, in un refactoring che ha cambiato la logica di normalizzazione degli slug, e abbiamo rilasciato in produzione una regressione di 1,2ms che è apparsa nei dashboard regionali due giorni dopo. La regressione era reale e la lezione è rimasta.


Le decisioni architetturali qui sono documentate in maggiore dettaglio su /docs/architecture/edge-redirect. Se stai valutando Elido come layer di infrastruttura di redirect per una campagna ad alto volume o una piattaforma per sviluppatori, la pagina solutions/developers copre la superficie API e le opzioni SDK. Per uno sguardo a cosa implica la cache a due livelli per il comportamento degli smart link - in particolare la finestra di propagazione per le modifiche alle regole - il post smart links explained copre questo in profondità.


Marius Voß è DevRel e infrastruttura edge in Elido. È stato uno degli ingegneri che ha portato il binario edge-redirect dal prototipo alla produzione e da allora non ha smesso di fissarne i dashboard di latenza.

Prova Elido

Incolla un URL, ottieni un link breve

Senza registrazione. Il link vive 30 giorni. Iscriviti per conservarlo.

Gratis, nessuna registrazione richiesta · 2 al giorno

Prova Elido

Accorciatore di URL ospitato nell'UE: domini personalizzati, analisi approfondite e API aperta. Piano gratuito - senza carta di credito.

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

Continua a leggere