Wie wir die Sub-30-ms-Pipeline von tracio.ai aufgebaut haben
Von der Signalerfassung bis zur Besucher-ID in unter 30 ms: unsere Architektur mit Go, ClickHouse, Redis und verteilter Verarbeitung.
Als wir uns daran machten, die Device-Identification-Engine von tracio.ai zu bauen, hatten wir eine nicht verhandelbare Anforderung: Die gesamte Pipeline — vom Empfang verschlüsselter Signale bis zur Rückgabe einer Besucher-ID — muss im 95. Perzentil in unter 30 Millisekunden abgeschlossen sein. Dieser Artikel ist eine ausführliche Durchsprache der Architektur, die wir gebaut haben, um dieses Ziel zu erreichen.
Überblick über die Pipeline
Die Identifikations-Pipeline hat fünf Stufen: Signalentschlüsselung, Signalnormalisierung, Hash-Berechnung, Identitätsauflösung und Antwortserialisierung. Jede Stufe wird unabhängig optimiert, und Stufen, die parallel laufen können, tun dies auch. Das Gesamtbudget beträgt 30 ms, grob aufgeteilt als: Entschlüsselung 2 ms, Normalisierung 3 ms, Hashing 2 ms, Identitätsauflösung 20 ms, Serialisierung 1 ms. Die verbleibenden 2 ms sind Puffer.
Die Signalentschlüsselung kehrt den clientseitig verschlüsselten Transport um. Wir verwenden Gos Krypto-Pakete mit Hardwarebeschleunigung, die die Entschlüsselung einer typischen 4-KB-Payload in unter 1 ms abschließen. Die Normalisierung parst das Signal-JSON, validiert Typen und wendet plattformspezifische Transformationen an — zum Beispiel die Normalisierung von User-Agent-Strings, um versionsspezifisches Rauschen zu entfernen.
Verteilte Identitätsauflösung
Die Identitätsauflösung — die Bestimmung, ob dieses Gerät schon einmal gesehen wurde — ist die latenzkritischste Stufe. Wir speichern Geräteprofile in Redis, über einen Cluster hinweg mithilfe einer Schicht für verteiltes Key-Routing gesharded. Das Routing verteilt die Keys auf Basis des Hardware-Tier-Fingerprints, wodurch sichergestellt wird, dass Lookups für dasselbe Gerät stets denselben Redis-Knoten treffen.
Unsere Sharding-Implementierung verwendet virtuelle Knoten (150 pro physischem Knoten), um eine gleichmäßige Verteilung sicherzustellen. Wenn ein Knoten hinzugefügt oder entfernt wird, müssen nur 1/N der Keys neu zugeordnet werden, wobei N die Anzahl der Knoten ist. Wir haben die Routing-Schicht in Go mit O(log n)-Lookup-Zeit und ohne Allokationen implementiert.
Redis als Identitätsspeicher
Wir haben uns für Redis anstelle von Alternativen (Memcached, ScyllaDB, DynamoDB) entschieden, wegen seiner konsistenten Antwortzeiten im Submillisekundenbereich und der Unterstützung komplexer Datenstrukturen. Jedes Geräteprofil wird als Redis-Hash gespeichert, mit Feldern für den Hash jedes Signal-Tiers, die Besucher-ID, den Last-Seen-Zeitstempel und Konfidenz-Metadaten.
Die Abfrage zur Identitätsauflösung ist ein einzelner HGETALL-Aufruf, gefolgt von einem Vergleich der eingehenden Signal-Hashes mit den gespeicherten Hashes. Wenn das Hardware-Tier übereinstimmt, geben wir die bestehende Besucher-ID mit hoher Konfidenz zurück. Wenn nur das Software-Tier übereinstimmt, führen wir einen Ähnlichkeitsvergleich der Daten auf Signalebene durch, um zu bestimmen, ob es sich um dasselbe Gerät mit einem aktualisierten Browser handelt. Wenn nichts übereinstimmt, generieren wir eine neue Besucher-ID.
ClickHouse für die Ereignisspeicherung
Jedes Identifikationsereignis wird asynchron in ClickHouse geschrieben. Wir verwenden einen gepufferten Writer, der Inserts bündelt — er sammelt Ereignisse 100 ms lang oder bis sich 1.000 Ereignisse angesammelt haben, je nachdem, was zuerst eintritt. Diese Bündelung ist entscheidend, weil ClickHouse mit großen Inserts (Tausende Zeilen auf einmal) am besten performt und nicht mit einzelnen Zeilen-Inserts.
Unser ClickHouse-Schema ist für die zwei häufigsten Abfragemuster optimiert: das Nachschlagen aller Ereignisse für eine bestimmte Besucher-ID und das Aggregieren von Ereignissen über Zeiträume hinweg. Wir verwenden eine MergeTree-Engine mit einem Primärschlüssel aus (visitor_id, timestamp), der schnelle Punkt-Lookups und effiziente Bereichs-Scans ermöglicht. Materialized Views pflegen voraggregierte tägliche und stündliche Metriken.
Sub-30 ms im großen Maßstab erreichen
Drei architektonische Entscheidungen waren entscheidend, um unser Latenzziel zu erreichen. Erstens ist die Pipeline vollständig streamend — wir beginnen mit der Verarbeitung der Signale, bevor der gesamte HTTP-Request-Body empfangen wurde. Zweitens verwenden Redis-Lookups Connection Pooling mit persistenten Verbindungen, wodurch der Overhead des TCP-Handshakes entfällt. Drittens sind ClickHouse-Schreibvorgänge vollständig asynchron und blockieren nie den Antwortpfad.
Unter Lasttests mit 50.000 Requests pro Sekunde liegt unsere p50-Latenz bei 12 ms, p95 bei 24 ms und p99 bei 38 ms. Die p99 überschreitet gelegentlich unser 30-ms-Ziel während des Rebalancings des Redis-Clusters, aber die p95 bleibt konsistent unter 30 ms. Für Kunden mit strengeren Latenzanforderungen bieten wir dedizierte Redis-Cluster an, die Multi-Tenant-Contention eliminieren.