Elido
12 min di letturaIngegneria

Ingestione dei click fire-and-forget con Redpanda

Come i POP edge emettono eventi di click senza bloccare il reindirizzamento, come il worker click-ingester esegue il batch in ClickHouse e a cosa rinunciamo per il vantaggio in termini di latenza

Marius Voß
DevRel · edge infra
Diagramma della pipeline in cinque passaggi che mostra una richiesta di reindirizzamento che fluisce attraverso edge-redirect verso il topic Redpanda, al worker click-ingester, fino a ClickHouse, con la risposta 301 che si dirama prima della chiamata al producer

Il percorso di reindirizzamento di un abbreviatore di URL ha esattamente un compito: risolvere uno slug verso una destinazione e restituire un 301 in pochi millisecondi. Tutto il resto è contabilità. Analytics dei click, attribuzione, arricchimento geo, punteggio frodi, fan-out dei webhook — niente di tutto ciò può trovarsi sul percorso della richiesta. Il budget di latenza non lo consente.

Questo è il trucco ingegneristico che consente alla pipeline di analytics di coesistere con il cornerstone redirect p95 < 15ms: l'edge lancia un evento di click in Redpanda e lo dimentica. Un worker separato — click-ingester — lo recupera più tardi, lo arricchisce e lo scrive in ClickHouse in batch. Il processo di reindirizzamento non si blocca mai. La pipeline di analytics non tocca mai il percorso critico. Il compromesso è la durabilità, ed è un compromesso minore di quanto sembri a prima vista.

Cosa significa effettivamente "fire and forget" in questo contesto#

L'handler edge-redirect, dopo aver prelevato l'URL di destinazione dalla cache a due livelli, esegue tre operazioni prima che l'header Location venga inviato:

  1. Costruisce una struct click.Event in memoria dalla richiesta (slug, ID workspace, user agent, referer, IP, geo dal file GeoLite2-City mmdb locale, analisi device/browser, flag di sospetto).
  2. Chiama producer.Emit(ctx, event) sul producer Kafka franz-go.
  3. Scrive HTTP/1.1 301 e l'header Location nel buffer di risposta.

La chiamata al producer ritorna immediatamente. Non attende un ack da alcun broker Redpanda. La libreria franz-go memorizza il record in un buffer interno al processo e lo invia su una goroutine in background; la callback di produzione viene invocata più tardi, su un pool di worker che non possiede la goroutine della richiesta. Se la produzione fallisce, la callback registra l'errore e l'evento viene scartato. Il reindirizzamento è già stato servito.

func (p *Producer) Emit(ctx context.Context, e Event) {
    if p == nil {
        return
    }
    b, err := json.Marshal(e)
    if err != nil {
        p.log.Warn("click marshal", zap.Error(err))
        return
    }
    rec := &kgo.Record{Topic: p.topic, Value: b}
    p.client.Produce(ctx, rec, func(_ *kgo.Record, err error) {
        if err != nil && p.log != nil {
            p.log.Warn("click produce", zap.Error(err))
        }
    })
}

Questa è l'intera interfaccia. Nessuna coda di retry all'interno del processo edge, nessuna attesa sincrona di ack, nessuno spool su disco. Il contratto con il resto del sistema è semplice: invio best-effort, log dei fallimenti, mai bloccare.

Una guardia nil-receiver consente allo sviluppo locale di girare senza un broker Kafka. Senza di essa, ogni collaboratore avrebbe bisogno di un container Redpanda in esecuzione solo per testare il percorso di reindirizzamento contro gli handler fasthttp.

Perché non abbiamo scelto una scrittura sincrona#

L'alternativa ovvia è scrivere ogni click direttamente in ClickHouse dall'edge. L'abbiamo considerata. L'abbiamo rifiutata per tre ragioni che si sommano.

Latenza. Il round-trip di un INSERT in ClickHouse dal POP di Francoforte a un cluster ClickHouse nella stessa regione si attesta su 3-6ms p50 su una rete scarica, 12-20ms p95 sotto carico. Questo è l'intero budget di reindirizzamento. Aggiungerlo al percorso di risposta spingerebbe il p95 oltre lo SLO di 15ms prima ancora che qualsiasi altra cosa vada storta. Il post sulla strategia di cache spiega quanto sia stretto il budget in pratica.

Contropressione (Backpressure). ClickHouse è felice di ingerire batch di 1000-10000 righe per ogni INSERT. È infelice nell'ingerire singole righe in cicli stretti — il motore MergeTree scrive un file part per ogni inserimento e un processo in background unisce le parti. Un pattern di scrittura diretta da una flotta edge multi-regione creerebbe milioni di piccole parti e la coda di merge non riuscirebbe mai a recuperare. La documentazione di ClickHouse è esplicita: inserire in batch di almeno 1000 righe, non più di una volta al secondo.

Isolamento dei guasti. Un riavvio del cluster ClickHouse, un problema di rete o una query lenta che blocca una replica si propagherebbero direttamente in fallimenti di reindirizzamento. Il processo edge inizierebbe ad andare in timeout (peggiorando il p95) o inizierebbe a perdere click (peggiorando la qualità dei dati). Inserire un message bus tra i due permette a ciascuna parte di fallire indipendentemente — l'edge continua a reindirizzare anche quando ClickHouse è degradato, e ClickHouse continua l'ingestione anche quando un POP è offline.

Redpanda assorbe tutte e tre le pressioni. È compatibile con il protocollo Kafka, quindi franz-go comunica con esso in modo trasparente. Ha un'impronta a singolo binario senza JVM. Memorizza su disco, quindi un'interruzione di ClickHouse di diverse ore non causa la perdita di eventi finché la finestra di retention del topic regge.

Il worker click-ingester#

click-ingester è un servizio Go che gira come consumer group sul topic degli eventi click. Una replica per regione, tre regioni, nessuno sharding per slug o workspace — il consumer group si ribilancia se una replica si riavvia e le partizioni vengono assegnate da Redpanda. Il compito del consumer è ridotto:

  • Effettua il poll dei fetch dal topic.
  • Decodifica il JSON di ogni record in un Event tipizzato.
  • Spinge l'evento nel buffer in memoria di un writer.
  • A volte: lancia webhook, inoltra a Klaviyo / Mixpanel / GA4 MP, pubblica sullo stream di click live in-app.

Il writer esegue il batch per conteggio o per tempo, a seconda di quale dei due si verifica per primo. Valori predefiniti: 1000 eventi per batch, intervallo di flush di 5 secondi. Un batch viene costruito in una chiamata INSERT INTO click_events PrepareBatch contro ClickHouse e confermato come un singolo append lato server. In caso di successo, il writer contrassegna gli offset dei record Kafka sottostanti come confermati; in caso di fallimento, non viene confermato nulla e il consumer effettua nuovamente il poll dall'ultimo offset riuscito nel suo ciclo successivo.

Il contratto offset-dopo-il-flush è la garanzia di durabilità. Il consumer non comunica mai a Redpanda "ho elaborato questo record" finché il record non è atterrato in ClickHouse come parte di un batch riuscito. Un crash tra la consumazione e il flush significa che il consumer group si ribilancia, il nuovo proprietario effettua il poll dall'ultimo offset confermato e gli eventi vengono rielaborati. La rielaborazione è sicura perché la tabella click_events è una ReplacingMergeTree con chiave su un ID evento sintetico — gli inserimenti duplicati collassano durante il merge.

I messaggi errati non vengono riprovati. Un fallimento nella decodifica JSON viene contrassegnato immediatamente come confermato, in modo che il consumer non rimanga bloccato su un record tossico. Questa è una piccola ma reale fonte di perdita di dati; il tasso si attesta su singoli eventi al giorno in tutta la flotta, e gli eventi interessati appaiono nel contatore Prometheus decode_error_total del consumer.

Il compromesso della durabilità in numeri#

Il fire-and-forget rinuncia ad alcuni eventi. La domanda è quanti, e se questo sia rilevante per il caso d'uso.

Abbiamo misurato il tasso di perdita in produzione su una finestra di 90 giorni. Il numero è circa lo 0,04% degli eventi emessi — circa quattro click persi ogni diecimila. La suddivisione:

  • Riavvio del processo edge con buffer in transito. franz-go memorizza nel buffer fino a poche centinaia di millisecondi di record prima di inviarli a un broker. Un SIGTERM durante un deploy può far perdere tutto ciò che si trova nel buffer. Lo script di deploy emette uno spegnimento pulito che svuota il buffer con un timeout di 2 secondi, il che copre la maggior parte dei casi ma non tutti.
  • Indisponibilità del broker Redpanda oltre la finestra di retry del producer. franz-go riprova i fallimenti di produzione, ma il budget di retry è limitato. Se un cluster Redpanda regionale non è integro per più di circa 30 secondi, il buffer trabocca e i nuovi record vengono scartati all'edge del producer.
  • Partizione di rete tra il POP edge e il cluster Redpanda regionale. Lo stesso effetto descritto sopra. Il producer registra avvisi e scarta eventi finché la connettività non ritorna.

Per il carico di lavoro di un abbreviatore di URL, una perdita dello 0,04% è accettabile. I click sono segnali statistici, non transazioni finanziarie. Le analisi di coorte, l'attribuzione delle conversioni e la distribuzione geo si aggregano bene su un campione con quel tasso di perdita. I casi d'uso che non lo tollerebbero — settori regolamentati con requisiti di audit, conteggi di click legati alla fatturazione — non sono ciò che il tier di reindirizzamento serve direttamente.

Per i workspace che necessitano di una maggiore durabilità, offriamo una modalità di audit-log separata che scrive ogni click in modo sincrono in Postgres oltre al percorso fire-and-forget. La scrittura sincrona aggiunge 3-5ms p95 al reindirizzamento, è opt-in e disattivata per impostazione predefinita. La guida all'esportazione verso ClickHouse documenta la forma dell'audit-log per i team di compliance che devono riconciliare i conteggi.

Strategia di replay quando ClickHouse è offline#

Il producer è fire-and-forget, ma il lato consumer ha una reale strategia di replay.

Quando ClickHouse non è disponibile, le chiamate di flush del writer falliscono. Il consumer continua a effettuare il poll — il ciclo di poll di franz-go è indipendente dal ciclo di flush del writer — ma gli offset non vengono confermati perché il flush non è andato a buon fine. La retention di Redpanda è impostata a 72 ore, che è l'interruzione massima tollerabile prima che gli eventi inizino a scadere.

Durante un'interruzione reale (ne abbiamo avute tre di durata significativa in 18 mesi), la sequenza di ripristino è:

  1. ClickHouse torna online.
  2. Il tentativo di flush successivo ha successo e conferma gli offset.
  3. Il consumer recupera svuotando il backlog al tasso di batch configurato. Con un batch di 1000 eventi e un flush ogni 5 secondi, il consumer può smaltire circa 200 eventi al secondo per replica; tre repliche significano circa 36k eventi al minuto.
  4. La dashboard Grafana per la tabella click_events mostra la curva di recupero — il tasso di inserimento delle righe rimane elevato finché il backlog non viene smaltito.

La retention di 72 ore è dimensionata per assorbire una ricostruzione di ClickHouse di più giorni senza perdita di dati. Non ne abbiamo mai usate più di 4 ore in produzione. Il costo è lo spazio su disco sui broker Redpanda, ed è esiguo rispetto alla perdita dei dati di analytics.

È possibile anche un replay-from-archive. Redpanda dispone di tiered storage che invia segmenti chiusi a uno storage a oggetti compatibile con S3. Lo abbiamo configurato ma non ne abbiamo avuto bisogno — il replay a caldo copre ogni incidente che abbiamo riscontrato.

Cos'altro fa il consumer#

L'ingestione dei click non riguarda solo le scritture in ClickHouse. Il consumer è il punto centrale di fan-out per ogni sistema a valle che si occupa dei click.

  • Dispatcher di webhook. I webhook configurati dai clienti vengono lanciati dal consumer, non dall'edge. Il consumer mette in coda un job di webhook per ogni click che corrisponde a un filtro configurato. Retry, firma e consegna avvengono in webhook-dispatcher.
  • Inoltro degli eventi lato server. Klaviyo, Mixpanel, GA4 Measurement Protocol, Meta CAPI. Il consumer mantiene una cache di configurazione per workspace e lancia la POST appropriata per ogni click che il workspace ha collegato. I forwarder sono best-effort con un piccolo retry in memoria; i fallimenti persistenti atterrano in una tabella dead-letter.
  • Stream di click live. La vista in-app "guarda una campagna in tempo reale" si iscrive a un canale pub/sub di Redis. Il consumer pubblica un evento di forma minima per ogni click che corrisponde a una sessione live attiva. Questa è l'unica parte della pipeline che sembra sincrona, ed è best-effort — scarta gli eventi quando il canale è congestionato.
  • Attivazione dei pixel (Pixel firing). I pixel di conversione (retargeting e conversione offline) vengono attivati dal consumer in base alla configurazione del singolo link. L'attivazione dei pixel è un dominio di errore a sé stante; i fallimenti vengono registrati ma non creano contropressione sul writer di ClickHouse.

Tutti questi vengono eseguiti dopo la conferma dell'offset ma prima del poll successivo. Un endpoint pixel lento può rallentare la velocità effettiva del consumer. Un timeout per forwarder (cap di 1 secondo) e un limite di concorrenza per batch (16 in transito) impediscono al percorso lento di dominare.

Perché questa forma e non Kinesis o una coda#

Alcune forme alternative di event-bus valutate e non scelte.

SQS o RabbitMQ come coda. Nessuno dei due ha il throughput per broker che Redpanda offre al volume degli eventi click. SQS fattura per richiesta, il che rende costosi gli stream ad alto volume; RabbitMQ spinge indietro sui topic densi.

AWS Kinesis. Ragionevole se fossimo residenti in AWS. Non lo siamo — Hetzner FRA, Hetzner ASH, OVH SGP. Kafka o Redpanda self-hosted è la forma giusta per un deployment focalizzato sull'UE.

Kafka liscio. Funziona. Abbiamo scelto Redpanda per il profilo operativo — singolo binario, niente Zookeeper, niente tuning della JVM. Il protocollo wire è identico e franz-go non nota la differenza. Un deployment Elido self-hosted può passare ad Apache Kafka senza modifiche al codice.

Servizi gestiti come Confluent Cloud. Non residenti nell'UE nel modo in cui desideriamo. Il tier di reindirizzamento necessita di una latenza del message-bus nella stessa regione.

La decisione è documentata in modo più dettagliato nella pagina dell'architettura edge-redirect, che è la fonte di verità per le scelte di configurazione del tier di reindirizzamento.

Cosa faremmo diversamente la prossima volta#

Il pattern fire-and-forget è corretto. L'implementazione presenta alcuni spigoli da segnalare a chiunque voglia copiare il design.

Drenaggio allo spegnimento. Il timeout di drenaggio di 2 secondi di franz-go ha fatto perdere eventi durante i deploy quando il buffer è occupato. La soluzione è un hook SIGTERM che esegue il flush in modo sincrono prima che il processo esca, con un timeout più lungo e un kill forzato se il broker non è raggiungibile.

Percorso dead-letter per fallimenti di decodifica. Contrassegnare i record tossici come confermati e andare avanti va bene per il throughput, ma fa perdere osservabilità. Un'iterazione futura scriverà i byte grezzi più l'errore di decodifica in una tabella click_events_decode_failures in modo che il team possa controllare cosa succede.

Concorrenza del forwarder per workspace. Oggi i forwarder di ogni workspace condividono il pool globale del consumer. Un workspace rumoroso con un endpoint Mixpanel lento può affamare gli altri. Un limite per workspace è la soluzione ovvia; non l'abbiamo ancora costruito.

Nessuno di questi ha causato un incidente in produzione. Sono il tipo di cose che si registrano nel backlog ADR e si risolvono un po' alla volta.

Letture correlate#

Prova Elido

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

Tag
ingestione click fire and forget
eventi click redpanda
inserimento batch clickhouse
pipeline analytics abbreviatore url
kafka reindirizzamento edge
producer franz-go
durabilità eventi click

Continua a leggere