Elido
10 Min. LesezeitEngineering

Fire-and-Forget Click-Ingestion mit Redpanda

Wie Edge-POPs Click-Events senden, ohne den Redirect zu blockieren, wie der click-ingester-Worker Batches in ClickHouse schreibt und was wir für den Latenz-Vorteil opfern

Marius Voß
DevRel · edge infra
Fünfstufiges Pipeline-Diagramm, das einen Redirect-Request zeigt, der über den edge-redirect zum Redpanda-Topic und dann zum click-ingester-Worker bis nach ClickHouse fließt, wobei die 301-Antwort vor dem Producer-Aufruf abzweigt

Der Redirect-Pfad eines URL-Shorteners hat genau eine Aufgabe: einen Slug zu einem Ziel aufzulösen und einen 301-Statuscode in einstelligen Millisekunden zurückzugeben. Alles andere ist Buchhaltung. Click-Analytics, Attribution, Geo-Enrichment, Fraud-Scoring, Webhook-Fan-out — nichts davon darf auf dem Request-Pfad liegen. Das Latenz-Budget erlaubt es nicht.

Dies ist der technische Trick, der es der Analytics-Pipeline ermöglicht, neben dem Redirect p95 < 15ms Grundpfeiler zu existieren: Die Edge sendet ein Click-Event an Redpanda und vergisst es. Ein separater Worker — click-ingester — greift es später auf, reichert es an und schreibt es in Batches in ClickHouse. Der Redirect-Prozess blockiert nie. Die Analytics-Pipeline berührt nie den Hot Path. Der Kompromiss ist die Beständigkeit (Durability), und dieser Kompromiss ist kleiner, als es zunächst aussieht.

Was „Fire and Forget“ hier konkret bedeutet#

Nachdem der edge-redirect-Handler die Ziel-URL aus dem Zweistufen-Cache ausgewählt hat, erledigt er drei Dinge, bevor der Location-Header gesendet wird:

  1. Erstellt eine In-Memory click.Event-Struktur aus dem Request (Slug, Workspace-ID, User-Agent, Referer, IP, Geo-Daten aus der lokalen GeoLite2-City mmdb, Device/Browser-Parsing, Suspicion-Flags).
  2. Ruft producer.Emit(ctx, event) auf dem franz-go Kafka-Producer auf.
  3. Schreibt HTTP/1.1 301 und den Location-Header in den Response-Buffer.

Der Producer-Aufruf kehrt sofort zurück. Er wartet nicht auf ein Ack von einem Redpanda-Broker. Die franz-go-Library puffert den Datensatz prozessintern und versendet ihn in einer Hintergrund-Goroutine; der Production-Callback wird später auf einem Worker-Pool aufgerufen, der nicht der Request-Goroutine gehört. Wenn der Produce-Vorgang fehlschlägt, loggt der Callback den Fehler und das Event wird verworfen. Der Redirect wurde bereits ausgeliefert.

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))
        }
    })
}

Das ist das gesamte Interface. Keine Retry-Queue innerhalb des Edge-Prozesses, kein synchrones Warten auf Acks, kein Disk-Spooling. Der Vertrag mit dem Rest des Systems ist einfach: Best-Effort-Emission, Fehler loggen, niemals blockieren.

Ein Nil-Receiver-Guard ermöglicht es, die lokale Entwicklung ohne Kafka-Broker durchzuführen. Ohne diesen Guard müsste jeder Mitwirkende einen Redpanda-Container laufen lassen, nur um den Redirect-Pfad gegen fasthttp-Handler zu testen.

Warum wir uns gegen einen synchronen Schreibvorgang entschieden haben#

Die offensichtliche Alternative ist, jeden Click direkt von der Edge aus in ClickHouse zu schreiben. Wir haben das in Betracht gezogen. Wir haben es aus drei Gründen abgelehnt, die sich gegenseitig verstärken.

Latenz. Ein ClickHouse INSERT-Roundtrip vom Frankfurt-POP zu einem ClickHouse-Cluster in derselben Region liegt bei 3-6ms p50 in einem ruhigen Netzwerk und 12-20ms p95 unter Last. Das ist das gesamte Redirect-Budget. Würde man dies zum Response-Pfad hinzufügen, würde p95 die 15ms-SLO überschreiten, bevor überhaupt etwas anderes schiefgeht. Der Post zur Cache-Strategie erklärt, wie knapp das Budget in der Praxis ist.

Backpressure. ClickHouse verarbeitet Batches von 1000-10000 Zeilen pro INSERT problemlos. Es ist jedoch ineffizient bei Single-Row-Inserts in engen Schleifen — die MergeTree-Engine schreibt pro Insert eine Part-Datei, und ein Hintergrundprozess führt diese Parts zusammen. Ein Direct-Write-Muster von einer Multi-Region Edge-Fleet aus würde Millionen winziger Parts erzeugen, und die Merge-Queue würde niemals hinterherkommen. Die ClickHouse-Dokumentation ist explizit: In Batches von mindestens 1000 Zeilen einfugen, nicht öfter als einmal pro Sekunde.

Isolierung von Fehlern. Ein ClickHouse-Cluster-Neustart, ein Netzwerkfehler oder eine langsame Abfrage, die ein Replikat sperrt, würde sich direkt auf Redirect-Fehler auswirken. Der Edge-Prozess würde entweder Timeouts verursachen (was p95 verschlechtert) oder Clicks verwerfen (was die Datenqualität verschlechtert). Ein Message-Bus zwischen beiden Seiten ermöglicht es, dass jede Seite unabhängig ausfallen kann — die Edge leitet weiter, auch wenn ClickHouse beeinträchtigt ist, und ClickHouse nimmt Daten auf, auch wenn ein POP offline ist.

Redpanda absorbiert alle drei Belastungen. Es ist Kafka-Protokoll-kompatibel, sodass franz-go transparent damit kommuniziert. Es hat einen Single-Binary-Footprint ohne JVM. Es puffert auf der Festplatte, sodass ein mehrstündiger ClickHouse-Ausfall keine Events verliert, solange das Retention-Window des Topics ausreicht.

Der click-ingester-Worker#

click-ingester ist ein Go-Service, der als Consumer-Group auf dem Click-Events-Topic läuft. Ein Replikat pro Region, drei Regionen, kein Sharding nach Slug oder Workspace — die Consumer-Group balanciert sich neu aus, wenn ein Replikat neu startet, und Partitionen werden von Redpanda zugewiesen. Die Aufgabe des Consumers ist überschaubar:

  • Pollt Fetches vom Topic.
  • Dekodiert den JSON-Datensatz in ein typisiertes Event.
  • Schiebt das Event in den In-Memory-Puffer eines Writers.
  • Manchmal: Löst Webhooks aus, leitet an Klaviyo / Mixpanel / GA4 MP weiter, veröffentlicht im In-App-Live-Click-Stream.

Der Writer bündelt nach Anzahl oder Zeit, je nachdem, was zuerst eintritt. Standardwerte: 1000 Events pro Batch, 5 Sekunden Flush-Intervall. Ein Batch wird in einen INSERT INTO click_events PrepareBatch-Aufruf gegen ClickHouse umgewandelt und als ein serverseitiger Append committet. Bei Erfolg markiert der Writer die zugrunde liegenden Kafka-Record-Offsets als committet; bei Fehlern wird nichts committet, und der Consumer pollt beim nächsten Mal ab dem letzten erfolgreichen Offset.

Der Offset-after-Flush-Vertrag ist die Garantie für Beständigkeit. Der Consumer meldet Redpanda erst dann „Ich habe diesen Datensatz verarbeitet“, wenn der Datensatz als Teil eines erfolgreichen Batches in ClickHouse gelandet ist. Ein Absturz zwischen Consume und Flush bedeutet, dass die Consumer-Group neu balanciert wird, der neue Besitzer ab dem letzten committeten Offset pollt und die Events erneut verarbeitet werden. Die erneute Verarbeitung ist sicher, da die Tabelle click_events eine ReplacingMergeTree ist, die auf einer synthetischen Event-ID basiert — doppelte Inserts werden beim Merge zusammengeführt.

Fehlerhafte Nachrichten werden nicht erneut versucht. Ein JSON-Dekodierungsfehler wird sofort als committet markiert, damit der Consumer nicht an einem Poison-Record hängen bleibt. Dies ist eine kleine, aber reale Quelle für Datenverlust; die Rate liegt bei einzelnen Events pro Tag über die gesamte Flotte hinweg, und die betroffenen Events erscheinen im decode_error_total Prometheus-Counter des Consumers.

Der Kompromiss bei der Beständigkeit in Zahlen#

Fire-and-Forget opfert einige Events. Die Frage ist, wie viele, und ob das für den Anwendungsfall eine Rolle spielt.

Wir haben die Verlustrate in der Produktion über einen Zeitraum von 90 Tagen gemessen. Die Zahl liegt bei ca. 0,04 % der gesendeten Events — etwa vier verlorene Clicks pro zehntausend. Die Aufschlüsselung:

  • Edge-Prozess-Neustart mit In-Flight-Puffer. franz-go puffert Records für einige hundert Millisekunden, bevor sie an einen Broker gesendet werden. Ein SIGTERM während eines Deployments kann den Pufferinhalt löschen. Das Deployment-Skript löst einen sauberen Shutdown aus, der den Puffer mit einem 2-Sekunden-Timeout leert, was die meisten Fälle abfängt, aber nicht alle.
  • Nichtverfügbarkeit des Redpanda-Brokers über das Retry-Window des Producers hinaus. franz-go versucht Produce-Fehler erneut, aber das Retry-Budget ist begrenzt. Wenn ein Redpanda-Cluster einer Region länger als etwa 30 Sekunden instabil ist, läuft der Puffer über und neue Datensätze werden am Rand des Producers verworfen.
  • Netzwerkpartition zwischen dem Edge-POP und dem regionalen Redpanda-Cluster. Derselbe Effekt wie oben. Der Producer loggt Warnungen und verwirft Events, bis die Konnektivität wiederhergestellt ist.

Für die Workload eines URL-Shorteners sind 0,04 % Verlust akzeptabel. Clicks sind statistische Signale, keine Finanztransaktionen. Kohorten-Analysen, Conversion-Attribution und Geo-Verteilung lassen sich über eine Stichprobe mit dieser Fehlrate gut aggregieren. Anwendungsfälle, die dies nicht tolerieren würden — regulierte Branchen mit Audit-Anforderungen, an Klickzahlen gebundene Abrechnungen — sind nicht das, was die Redirect-Ebene direkt bedient.

Für Workspaces, die eine höhere Beständigkeit benötigen, bieten wir einen separaten Audit-Log-Modus an, der jeden Click zusätzlich zum Fire-and-Forget-Pfad synchron in Postgres schreibt. Der synchrone Schreibvorgang fügt dem Redirect 3-5ms p95 hinzu (Opt-in, standardmäßig deaktiviert). Der Leitfaden zum ClickHouse-Export dokumentiert die Struktur des Audit-Logs für Compliance-Teams, die Zahlen abgleichen müssen.

Replay-Strategie bei ClickHouse-Ausfall#

Der Producer arbeitet nach dem Fire-and-Forget-Prinzip, aber die Consumer-Seite hat eine echte Replay-Lösung.

Wenn ClickHouse nicht verfügbar ist, schlagen die Flush-Aufrufe des Writers fehl. Der Consumer pollt weiter — der Poll-Loop von franz-go ist unabhängig vom Flush-Loop des Writers —, aber Offsets werden nicht committet, da der Flush nicht erfolgreich war. Die Retention von Redpanda ist auf 72 Stunden eingestellt, was die maximal tolerierbare Ausfallzeit ist, bevor Events veralten.

Während eines realen Ausfalls (wir hatten drei von nennenswerter Dauer in 18 Monaten) sieht die Recovery-Sequenz wie folgt aus:

  1. ClickHouse geht wieder online.
  2. Der nächste Flush-Versuch ist erfolgreich und committet Offsets.
  3. Der Consumer holt auf, indem er den Rückstau mit der konfigurierten Batch-Rate abarbeitet. Mit einem 1000-Event-Batch und einem 5-Sekunden-Flush kann der Consumer etwa 200 Events pro Sekunde pro Replikat verarbeiten; drei Replikate bedeuten etwa 36k Events pro Minute.
  4. Das Grafana-Dashboard für die Tabelle click_events zeigt die Catch-up-Kurve — die Rate der Zeilen-Inserts bleibt erhöht, bis der Rückstau abgearbeitet ist.

Die 72-Stunden-Retention ist so dimensioniert, dass sie einen mehrtägigen ClickHouse-Wiederaufbau ohne Datenverlust abfangen kann. Wir haben in der Produktion nie mehr als 4 Stunden davon benötigt. Die Kosten dafür liegen im Festplattenspeicher auf den Redpanda-Brokern, was im Vergleich zum Verlust von Analytics-Daten gering ist.

Ein Replay aus dem Archiv ist ebenfalls möglich. Redpanda verfügt über Tiered Storage, das abgeschlossene Segmente an S3-kompatiblen Objektspeicher sendet. Wir haben dies konfiguriert, aber bisher nicht benötigt — Hot Replay deckt jeden Vorfall ab, den wir bisher gesehen haben.

Was der Consumer sonst noch macht#

Die Click-Ingestion besteht nicht nur aus ClickHouse-Schreibvorgängen. Der Consumer ist der zentrale Fan-out-Punkt für jedes nachgelagerte System, das sich für Clicks interessiert.

  • Webhook-Dispatcher. Vom Kunden konfigurierte Webhooks werden vom Consumer ausgelöst, nicht von der Edge. Der Consumer stellt pro Click, der einem konfigurierten Filter entspricht, einen Webhook-Job in die Warteschlange. Retries, Signierung und Zustellung erfolgen im webhook-dispatcher.
  • Serverseitige Event-Weiterleitung. Klaviyo, Mixpanel, GA4 Measurement Protocol, Meta CAPI. Der Consumer hält einen Konfigurations-Cache pro Workspace bereit und sendet den entsprechenden POST für jeden Click, den der Workspace konfiguriert hat. Die Weiterleitungen erfolgen nach dem Best-Effort-Prinzip mit einem kleinen In-Memory-Retry; dauerhafte Fehler landen in einer Dead-Letter-Tabelle.
  • Live-Click-Stream. Die In-App-Ansicht „Kampagnen-Start live verfolgen“ abonniert einen Redis-Pub/Sub-Kanal. Der Consumer veröffentlicht ein minimal strukturiertes Event für jeden Click, der einer aktiven Live-Sitzung entspricht. Dies ist der einzige Teil der Pipeline, der sich synchron anfühlt, und er erfolgt nach dem Best-Effort-Prinzip — Events werden verworfen, wenn der Kanal überlastet ist.
  • Pixel-Firing. Conversion-Pixel (Retargeting und Offline-Conversion) werden vom Consumer basierend auf der Pro-Link-Konfiguration ausgelöst. Das Auslösen von Pixeln ist ein eigener Fehlerbereich; Fehler werden protokolliert, üben aber keinen Rückstau auf den ClickHouse-Writer aus.

All dies geschieht nach dem Offset-Commit, aber vor dem nächsten Poll. Ein langsamer Pixel-Endpunkt kann den effektiven Consumer-Durchsatz verringern. Ein Timeout pro Forwarder (Hard-Cap von 1 Sekunde) und ein Limit für die Gleichzeitigkeit pro Batch (16 in flight) verhindern, dass der langsame Pfad dominiert.

Warum diese Architektur und nicht Kinesis oder eine Queue#

Einige alternative Event-Bus-Architekturen wurden evaluiert und nicht ausgewählt.

SQS oder RabbitMQ als Queue. Keines von beiden bietet den Durchsatz pro Broker, den Redpanda bei Click-Event-Volumen bietet. SQS rechnet pro Request ab, was High-Volume-Streams teuer macht; RabbitMQ stößt bei dichten Topics an seine Grenzen.

AWS Kinesis. Sinnvoll, wenn wir AWS-resident wären. Sind wir aber nicht — Hetzner FRA, Hetzner ASH, OVH SGP. Selbstgehostetes Kafka oder Redpanda ist die richtige Form für ein EU-first Deployment.

Reines Kafka. Funktioniert. Wir haben uns für Redpanda wegen des Betriebsprofils entschieden — Single Binary, kein Zookeeper, kein JVM-Tuning. Das Wire-Protokoll ist identisch, und franz-go bemerkt den Unterschied nicht. Ein selbstgehostetes Elido-Deployment kann Apache Kafka ohne Code-Änderungen einsetzen.

Managed Services wie Confluent Cloud. Nicht in der Weise in der EU ansässig, wie wir es wünschen. Die Redirect-Ebene benötigt eine Message-Bus-Latenz innerhalb derselben Region.

Die Entscheidung ist detaillierter auf der edge-redirect Architekturseite dokumentiert, die als Source-of-Truth für die Konfigurationsentscheidungen der Redirect-Ebene dient.

Was wir beim nächsten Mal anders machen würden#

Das Fire-and-Forget-Muster ist korrekt. Die Implementierung hat jedoch Ecken und Kanten, die für jeden, der das Design kopiert, erwähnenswert sind.

Shutdown-Drain. Das 2-Sekunden-Drain-Timeout von franz-go hat bei Deployments zu Event-Verlusten geführt, wenn der Puffer ausgelastet war. Die Lösung ist ein SIGTERM-Hook, der synchron flusht, bevor der Prozess beendet wird, mit einem längeren Timeout und einem Hard-Kill, falls der Broker nicht erreichbar ist.

Dead-Letter-Pfad für Dekodierungsfehler. Das Markieren von Poison-Records als committet ist gut für den Durchsatz, verschlechtert aber die Observability. Eine zukünftige Iteration schreibt die Rohdaten zusammen mit dem Dekodierungsfehler in eine Tabelle click_events_decode_failures, damit das Team prüfen kann, was dort landet.

Forwarder-Gleichzeitigkeit pro Workspace. Heute teilen sich die Forwarder jedes Workspaces den globalen Pool des Consumers. Ein aktiver Workspace mit einem langsamen Mixpanel-Endpunkt kann andere aushungern. Ein Limit pro Workspace ist die offensichtliche Lösung; wir haben sie noch nicht implementiert.

Nichts davon hat bisher zu einem Produktionsvorfall geführt. Es sind Dinge, die man im ADR-Backlog notiert und nach und nach abarbeitet.

Weiterführende Lektüre#

Elido testen

URL-Shortener mit EU-Hosting: eigene Domains, tiefe Analytik und eine offene API. Kostenloser Tarif — keine Kreditkarte nötig.

Tags
Fire-and-Forget Click-Ingestion
Redpanda Click-Events
ClickHouse Batch-Insert
URL Shortener Analytics Pipeline
Edge Redirect Kafka
franz-go Producer
Click-Event Beständigkeit

Weiterlesen