Elido
14 min di letturaIngegneria

Strategia di cache per i redirect URL: L1 LRU e L2 Redis

Come la cache a due livelli davanti all'origine dell'URL shortener mantiene la latenza p95 sotto i 15ms: policy di eviction, riscaldamento e failure mode.

Marius Voß
DevRel · edge infra
Diagramma di flusso a tre livelli con frecce dalla richiesta a L1 LRU (in-process), L2 Redis cluster e gRPC di origine, con hit ratio del 98%, 1,8% e 0,2%

Il tier di redirect di un URL shortener è uno dei pochi sistemi di produzione in cui la strategia di cache è l'architettura. Non c'è altro lavoro significativo sul hot path: ogni richiesta risolve una chiave (lo slug breve), legge un URL di destinazione ed emette un 301 o 302. Tutto il resto è observability e bookkeeping. La cache è ciò che determina se la richiesta mediana richiede 800 microsecondi o 12 millisecondi.

Questo post documenta la strategia di cache alla base del servizio edge-redirect di Elido. Due livelli, una policy di eviction scelta per ottimizzare la latenza di coda piuttosto che l'hit rate, una strategia di riscaldamento più semplice di quanto sembri e i failure mode che abbiamo riscontrato in 18 mesi di produzione. Il pilar sulla latenza p95 < 15ms per i redirect copre l'intero budget di latenza; questo è un approfondimento specifico sulla cache.

Perché due livelli#

La più semplice architettura di cache per un servizio di redirect è a livello singolo: un Redis cluster tra il processo di redirect e il database di origine. Ogni richiesta che non colpisce il database colpisce Redis; ogni richiesta che non colpisce Redis colpisce il database. L'hop su Redis aggiunge circa 1ms quando Redis è nella stessa regione.

Le cache a due livelli aggiungono uno strato in-process davanti a Redis. Il primo livello — chiamato L1 — vive all'interno dello spazio di indirizzamento del processo di redirect. Un hit in L1 restituisce l'URL di destinazione in poche centinaia di nanosecondi, senza richiedere un round-trip di rete. Un miss in L1 ricade su Redis (L2), che serve con una latenza inferiore al millisecondo. Un miss in L2 ricade sulla chiamata gRPC di origine verso il database Postgres canonico.

La scelta tra uno o due livelli è essenzialmente una questione di quanto deve essere piatta la latenza di coda. Redis è veloce ma non è gratuito. Un p50 di 1ms verso Redis diventa un p99 di 4-6ms sotto carico, e il p99.9 può superare i 20ms in caso di congestione della rete. Per un SLO che punta a un p95 < 15ms, ogni hit su Redis consuma una frazione significativa del budget. Per un p99.9 < 50ms, la coda di Redis è il contributo dominante.

Una LRU in-process assorbe le chiavi a più alta frequenza, quelle che generano oltre l'80% del traffico. Con la distribuzione del traffico di Elido, i primi 1000 link brevi per volume di richieste rappresentano oltre il 70% delle richieste di redirect. Queste chiavi sono facili da servire in-process; la coda lunga può ricadere su Redis senza degradare il p95.

L1: una LRU per processo#

La cache L1 utilizza Ristretto, la stessa LRU con admission policy utilizzata da Caddy e Dgraph. L'abbiamo scelta per tre motivi:

  • Le letture concorrenti scalano linearmente con i core CPU. Una cache sync.Map più semplice si blocca a circa 4M di operazioni al secondo su una tipica macchina POP edge; Ristretto ne sostiene oltre 30M nei nostri benchmark.
  • La admission policy TinyLFU impedisce ai workload di scansione one-shot di espellere le chiavi calde. Un crawl di un bot che tocca 10.000 slug unici una volta ciascuno non sposta i link realmente popolari dalla cache.
  • Memoria limitata piuttosto che conteggio limitato delle chiavi. Possiamo impostare "usa fino a 256MB" invece di "memorizza fino a 100.000 voci", che è la configurazione cruciale per la pianificazione della capacità.

La configurazione che utilizziamo è:

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

NumCounters è la dimensione della tabella di tracciamento della frequenza TinyLFU; la regola empirica nella documentazione di Ristretto è 10 volte il numero previsto di elementi. Con un budget di 256MB e un record di link medio di 200 byte, la cache contiene circa 1.3M di voci quando è piena.

Il TTL sulle voci L1 è di 60 secondi. È deliberatamente breve. Un redirect può vedere la sua destinazione cambiata nella dashboard in qualsiasi momento, e la cache L1 è lo strato più lento da invalidare (Redis può essere invalidato tramite publish; L1 vive in ogni processo e richiede un percorso di invalidazione coordinato).

Un TTL di 60 secondi significa che l'obsolescenza nel peggiore dei casi è di 60 secondi dopo un aggiornamento della destinazione. Per la maggior parte dei casi d'uso questo è accettabile; per i casi in cui non lo è (cambi immediati di destinazione durante una campagna live), il pulsante di invalidazione della dashboard emette un fanout che svuota tutte le cache L1 della flotta. Il fanout utilizza il pub/sub di Redis su un canale a cui ogni processo edge si iscrive all'avvio.

L2: Redis cluster con read replica#

L2 è un Redis cluster, distribuito in ogni regione (Francoforte, Ashburn, Singapore). Le letture vanno alle replica locali; le scritture vanno alla primaria regionale e si replicano all'interno del modello asincrono standard di Redis.

Il formato dei dati è ridotto. Un record di redirect in L2 appare così:

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

Tre campi: URL di destinazione, flag (filtro bot abilitato, password richiesta, ecc., impacchettati in un uint16) e versione. La versione è la versione della riga di Postgres; ci permette di rilevare voci di cache obsolete durante la lettura.

Il TTL in L2 è di 24 ore. È molto più lungo di L1 perché L2 ha un percorso di invalidazione funzionante: quando un link viene creato o aggiornato nel database di origine, l'API pubblica un messaggio pub/sub di Redis sul canale di invalidazione regionale, i processi di edge-redirect espellono le loro voci L1 e la voce L2 viene sovrascritta direttamente dal livello API.

L'invalidazione pub/sub ha una proprietà sottile: è lossy. Se un processo di redirect si sta riavviando quando viene pubblicato il messaggio di invalidazione, non vede il messaggio e la sua cache L1 potrebbe servire il valore obsoleto fino a 60 secondi. Accettiamo questo rischio perché il TTL è il backstop: l'obsolescenza è limitata.

La dimensione del Redis cluster in ogni POP è ridotta. Francoforte gestisce tre nodi primari più tre replica; l'intero dataset occupa circa 4GB. Al nostro hit rate (98% L1, 1,8% L2, 0,2% origine in condizioni normali), il requisito di throughput su Redis è moderato — solitamente 5-15k operazioni al secondo al picco per POP, ampiamente entro la capacità di un singolo nodo primario se dovessimo consolidare.

La scelta della policy di eviction#

La admission policy TinyLFU di Ristretto è la scelta che conta di più per la latenza di coda.

Una LRU naïve espelle la chiave utilizzata meno di recente ogni volta che deve fare spazio. Questo va bene quando il pattern di accesso è ragionevolmente uniforme: le chiavi usate più recentemente sono quelle che più probabilmente verranno usate di nuovo. Il sistema crolla sotto due pattern specifici:

  • Workload di scansione. Un crawl di un bot che colpisce 50.000 slug unici in rapida successione, con una LRU naïve, espellerà ogni chiave calda sostituendola con chiavi di crawl che non verranno mai più utilizzate. L'hit rate della cache crolla, l'origine vede un picco di carico e il p95 salta perché la maggior parte delle richieste ora colpisce il percorso lento.
  • Hot key improvvise. Un link normalmente freddo che riceve improvvisamente 100k richieste in 30 secondi (un post social virale, un lancio di una campagna TV) deve essere memorizzato nella cache velocemente. Con una LRU naïve, scalzerà una delle chiavi calde esistenti.

TinyLFU gestisce entrambi i casi. La admission policy traccia le frequenze delle chiavi e ammette una nuova chiave nella cache solo se è più frequente della candidata all'espulsione. Un crawl di un bot one-shot non sposta le chiavi calde perché le chiavi di crawl hanno un conteggio di frequenza pari a 1. Una hot key improvvisa entra nella cache, ma solo dopo che la sua frequenza supera quella della candidata all'espulsione — il che accade entro poche centinaia di richieste.

Il costo è che le prime 100-500 richieste per un link appena diventato popolare sono lente (ricadono su L2 o sull'origine) finché la admission policy non decide di memorizzarlo. Per la maggior parte dei casi d'uso questo è il giusto compromesso; per le campagne in cui sappiamo in anticipo che un link avrà un picco, disponiamo di un endpoint di pre-riscaldamento descritto di seguito.

Riscaldamento della cache#

La cache L2 parte a freddo quando un nuovo Redis cluster entra in funzione. Non lo riscaldiamo da uno snapshot; i primi 5 minuti dopo un riavvio del cluster vedono un traffico elevato verso l'origine finché la cache non si riempie naturalmente.

La cache L1 parte a freddo quando un processo di redirect si riavvia (deploy, kill per OOM, scale-up). I primi 30 secondi dopo il riavvio di un processo vedono la maggior parte delle richieste ricadere su L2; i successivi 60 secondi vedono L1 riempirsi con il suo working set di chiavi calde. Il contributo totale della partenza a freddo al carico dell'origine è minimo (la maggior parte dei processi edge si riavvia molto meno spesso del TTL della cache).

L'eccezione: quando un campaign manager pre-pubblica un link che sa che avrà un picco — un URL per uno spot TV, un URL per un comunicato stampa, un annuncio di lancio — la dashboard offre un'opzione di "pre-warm". Attivandola, viene emesso un redirect no-op verso il servizio edge-redirect in ogni POP, che popola L1 in anticipo. È una procedura poco affascinante e raramente necessaria; l'autoscaler gestisce adeguatamente i picchi di traffico imprevisti. Il pre-warm è la risposta ai picchi previsti dove i primi 60 secondi di latenza a cache fredda sarebbero visibili.

Cosa succede alla capacità di L1#

Una cache L1 da 256MB si riempie in meno di un minuto in un tipico POP edge. Una volta piena, ogni nuova chiave richiede che la admission policy TinyLFU decida se debba espellere una chiave esistente.

L'osservazione interessante: con la nostra distribuzione, l'hit rate di L1 si stabilizza intorno al 98% una volta a regime. Il 2% di miss rate è la coda lunga — quel ~30% di link che rappresentano meno del 30% del traffico e quindi non superano la soglia di frequenza di TinyLFU. Questi mancano L1 e colpiscono L2, dove l'hit rate è di circa il 99%. Il restante 0,2% del totale delle richieste ricade sull'origine.

Abbiamo misurato questa distribuzione con tre tipologie di workload — traffico bot intenso, picco virale, stato stazionario — e l'hit rate di L1 oscilla tra il 95% e il 99%. L'hit rate di L2 è più stabile al 98-99,5%. Il carico totale sull'origine dal tier di redirect è quindi limitato a circa lo 0,5% del volume delle richieste in entrata, che è il numero che conta per la pianificazione della capacità di origine.

Invalidazione della cache nel dettaglio#

Il flusso di invalidazione è la parte più spesso fraintesa da chi legge l'architettura dall'esterno. Il dettaglio:

Quando l'API riceve una PATCH /v1/links/{id} che cambia l'URL di destinazione, accadono tre cose in ordine:

  1. Postgres conferma la modifica con la nuova versione della riga (UPDATE links SET destination = ?, version = version + 1 WHERE id = ?).
  2. Redis viene scritto direttamente con il nuovo valore in ogni Redis cluster regionale. La scrittura si propaga dall'API a ogni Redis regionale attraverso un livello write-through.
  3. L'invalidazione pub/sub viene pubblicata su ogni canale regionale invalidate:redirect. I processi di edge-redirect si iscrivono a questo canale all'avvio ed espellono la voce L1 per la chiave interessata.

L'ordine conta. Postgres-first garantisce che lo store canonico abbia il nuovo valore. Redis-write-through-before-publish garantisce che qualsiasi processo che perda la pubblicazione ma legga da Redis veda il nuovo valore. La pubblicazione è l'ottimizzazione che mantiene L1 sincronizzato; il TTL è il backstop se una pubblicazione viene persa.

La race condition nota: un processo di redirect che legge da Redis (a causa di un miss in L1) e una pubblicazione di invalidazione concorrente. La lettura può restituire il nuovo valore (la pubblicazione è avvenuta poco prima della lettura) o quello vecchio (la pubblicazione è avvenuta poco dopo). Se viene restituito il vecchio valore e memorizzato in L1, i successivi 60 secondi potrebbero servire il vecchio valore per quel processo. Questo è accettabile; l'alternativa — un lock sincrono intorno alla race condizione lettura-pubblicazione — aggiungerebbe latenza a ogni richiesta per evitare un caso limite che colpisce meno dello 0,01% delle invalidazioni.

Per i casi d'uso in cui la finestra di obsolescenza non è accettabile (un URL di destinazione rimosso per motivi legali, una destinazione improvvisamente dannosa), l'azione "purge cache" della dashboard emette un'invalidazione aggressiva: mette in pausa tutte le letture L1 per 100ms in tutta la flotta, espelle la chiave da ogni L1, quindi riprende. Questa funzione viene usata raramente ed è vincolata a un rate limit al secondo.

Failure mode che abbiamo visto realmente#

Tre guasti nei 18 mesi di storia produttiva che vale la pena documentare perché hanno plasmato la configurazione attuale.

Failover della primaria Redis con replica obsolete. Al quarto mese di produzione, un nodo primario nel cluster di Francoforte ha avuto un guasto. La replica è stata promossa entro 30 secondi (failover gestito da Sentinel). Le replica erano circa 200ms indietro rispetto alla primaria al momento del guasto, il che ha significato che le prime centinaia di invalidazioni pubblicate appena prima del failover non hanno raggiunto la replica promossa. Risultato: una breve finestra in cui circa lo 0,3% dei redirect ha servito destinazioni obsolete. Risoluzione: ora eseguiamo le replica con min-replicas-to-write 1 e min-replicas-max-lag 10, sacrificando un po' di disponibilità in scrittura per una garanzia di replication lag più stretta.

Cache thrashing L1 durante una scansione di monitoraggio sintetico. Al nono mese, un servizio di monitoraggio dell'uptime di terze parti è stato configurato erroneamente per sondare ogni link breve nell'area di lavoro di un cliente una volta al minuto. Il cliente aveva 18.000 link brevi. Il pattern di sonda era una scansione completa ogni 60 secondi. Effetto: l'hit rate della cache L1 è sceso dal 98% al 71% su tre POP edge perché il pattern di scansione ammetteva ogni chiave sondata nella cache. Risoluzione: abbiamo aggiunto un filtraggio basato sullo User-Agent prima del livello di admission della cache — gli User-Agent di monitoraggio noti saltano la cache e servono direttamente da L2. Si è trattato di un caso limite di TinyLFU: le chiavi di scansione sembravano abbastanza frequenti da scalzare le chiavi realmente calde.

Disconnessione pub/sub durante un deploy prolungato. Al tredicesimo mese, un deploy che ha richiesto più tempo del previsto (circa 4 minuti) ha fatto sì che diversi processi edge rimanessero connessi al vecchio canale pub/sub dopo che la primaria Redis aveva subito un failover. Le invalidazioni pubblicate sulla nuova primaria non hanno raggiunto quei processi; le loro cache L1 hanno servito valori obsoleti per la durata del deploy. Risoluzione: heartbeat di connessione pub/sub con riconnessione automatica in caso di heartbeat mancati e uno svuotamento preventivo di L1 durante il deploy.

Cosa abbiamo considerato e scartato#

Alcune alternative valutate e non scelte:

Una singola cache in-process, senza Redis. Testato. Il miss-to-origin rate su ogni singolo processo è troppo alto senza un L2; il database di origine richiederebbe una capacità 3-5 volte superiore. Il costo marginale di Redis è piccolo rispetto ai risparmi di capacità dell'origine.

Un CDN come Cloudflare o Fastly per il caching dei redirect. Testato in staging. La latenza regionale di 1-2ms di un CDN su un hit di cache è all'incirca la stessa di Redis, ma la gestione dell'invalidazione è materialmente peggiore (i purge dei CDN hanno latenze nell'ordine dei minuti e costi di purge per URL). Il CDN aggiungeva complessità senza migliorare la latenza o l'hit rate.

Una cache L1 più grande. Il budget di 256MB è dimensionato in base all'envelope di memoria per processo; raddoppiarlo non raddoppia l'hit rate perché il working set caldo ci sta già. I rendimenti decrescenti iniziano a circa 128MB sulla nostra distribuzione; 256MB ha margine per la crescita del traffico.

Osservabilità#

Le metriche che tracciamo per ogni processo edge:

  • cache_l1_hit_total, cache_l1_miss_total — hit rate derivato per processo.
  • cache_l2_hit_total, cache_l2_miss_total — hit rate derivato per regione.
  • cache_origin_request_total — volume di richieste all'origine; il target SLO è < 1% delle richieste totali.
  • cache_invalidation_total{source="pubsub|ttl|purge"} — conteggi di invalidazione per meccanismo.
  • cache_l1_memory_bytes — memoria effettiva utilizzata dalla cache L1; allerta al 90% del budget configurato.

Tutte le metriche vengono raccolte da Prometheus e visualizzate nel set di dashboard della guida all'osservabilità. Le dashboard di Grafana a livello regionale mostrano l'hit rate della cache regionale nel tempo; le dashboard per processo (usate durante gli incidenti) mostrano l'hit rate L1 per processo e l'utilizzo della memoria.

Quando usare questa strategia e quando no#

Una cache a due livelli ha senso quando:

  • Il workload è read-heavy con una distribuzione delle chiavi a coda lunga.
  • Il working set caldo entra nella memoria per processo (poche centinaia di megabyte).
  • I miss di cache sono abbastanza costosi che il secondo livello risparmia carico sul database.
  • Il budget di obsolescenza è così stretto che il solo TTL di L1 non è accettabile.

Non ha senso quando:

  • Il working set caldo non entra nella memoria del processo. In questo caso i miss L1 ricadono su L2 così frequentemente che L1 contribuisce poco.
  • Le scritture sono frequenti rispetto alle letture. Il costo di invalidazione domina.
  • I dati sono unici per richiesta (nessun vantaggio dal caching).

Per il workload dell'URL shortener, tutte e quattro le condizioni "sì" sono soddisfatte e la configurazione sopra descritta ha retto alla crescita della produzione in 18 mesi. Per altri workload, il numero di livelli e la policy di eviction necessitano di una rivalutazione.

Letture correlate#

Prova Elido

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

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

Continua a leggere