Jak zbudowaliśmy potok tracio.ai poniżej 30 ms
Od zbierania sygnałów do identyfikatora odwiedzającego w mniej niż 30 ms: nasza architektura oparta na Go, ClickHouse, Redis i przetwarzaniu rozproszonym.
Kiedy zabieraliśmy się za budowę silnika identyfikacji urządzeń tracio.ai, mieliśmy jeden niepodlegający negocjacjom wymóg: cały potok — od odebrania zaszyfrowanych sygnałów do zwrócenia identyfikatora odwiedzającego — musi zakończyć się w mniej niż 30 milisekund dla 95. percentyla. Ten artykuł to szczegółowe omówienie architektury, którą zbudowaliśmy, aby osiągnąć ten cel.
Przegląd potoku
Potok identyfikacji ma pięć etapów: deszyfrowanie sygnałów, normalizacja sygnałów, obliczanie skrótów, rozpoznawanie tożsamości oraz serializacja odpowiedzi. Każdy etap jest optymalizowany niezależnie, a te, które mogą działać równolegle, tak właśnie działają. Łączny budżet to 30 ms, rozdzielony z grubsza tak: deszyfrowanie 2 ms, normalizacja 3 ms, haszowanie 2 ms, rozpoznawanie tożsamości 20 ms, serializacja 1 ms. Pozostałe 2 ms to zapas.
Deszyfrowanie sygnałów odwraca szyfrowany transport po stronie klienta. Używamy pakietów kryptograficznych Go z akceleracją sprzętową, które deszyfrują typowy ładunek 4 KB w mniej niż 1 ms. Normalizacja parsuje JSON sygnału, waliduje typy i stosuje transformacje specyficzne dla platformy — na przykład normalizuje ciągi user agent, aby usunąć szum związany z konkretną wersją.
Rozproszone rozpoznawanie tożsamości
Rozpoznawanie tożsamości — ustalenie, czy to urządzenie było już widziane — jest etapem najbardziej wrażliwym na opóźnienia. Profile urządzeń przechowujemy w Redisie, rozproszone (shardowane) po klastrze za pomocą warstwy rozproszonego routingu kluczy. Routing rozdziela klucze na podstawie odcisku warstwy sprzętowej (hardware-tier), co zapewnia, że wyszukiwania dla tego samego urządzenia zawsze trafiają do tego samego węzła Redisa.
Nasza implementacja shardingu wykorzystuje wirtualne węzły (150 na węzeł fizyczny), aby zapewnić równomierny rozkład. Gdy węzeł jest dodawany lub usuwany, remapowania wymaga tylko 1/N kluczy, gdzie N to liczba węzłów. Warstwę routingu zaimplementowaliśmy w Go z czasem wyszukiwania O(log n) i zerową liczbą alokacji.
Redis jako magazyn tożsamości
Wybraliśmy Redis zamiast alternatyw (Memcached, ScyllaDB, DynamoDB) ze względu na jego stabilne czasy odpowiedzi poniżej milisekundy oraz obsługę złożonych struktur danych. Każdy profil urządzenia jest przechowywany jako hasz Redisa z polami na skrót każdej warstwy sygnałów, identyfikator odwiedzającego, znacznik czasu ostatniego widzenia oraz metadane pewności.
Zapytanie rozpoznające tożsamość to pojedyncze wywołanie HGETALL, po którym następuje porównanie przychodzących skrótów sygnałów ze skrótami zapisanymi. Jeśli warstwa sprzętowa się zgadza, zwracamy istniejący identyfikator odwiedzającego z wysoką pewnością. Jeśli zgadza się tylko warstwa programowa, wykonujemy porównanie podobieństwa danych na poziomie sygnałów, aby ustalić, czy to samo urządzenie ze zaktualizowaną przeglądarką. Jeśli nic się nie zgadza, generujemy nowy identyfikator odwiedzającego.
ClickHouse do przechowywania zdarzeń
Każde zdarzenie identyfikacji jest zapisywane do ClickHouse asynchronicznie. Używamy buforowanego writera, który wsaduje wstawki — zbiera zdarzenia przez 100 ms lub do momentu nagromadzenia 1000 zdarzeń, w zależności od tego, co nastąpi wcześniej. To wsadowanie jest kluczowe, ponieważ ClickHouse działa najlepiej przy dużych wstawkach (tysiące wierszy naraz) zamiast pojedynczych wierszy.
Nasz schemat ClickHouse jest zoptymalizowany pod dwa najczęstsze wzorce zapytań: wyszukiwanie wszystkich zdarzeń dla konkretnego identyfikatora odwiedzającego oraz agregowanie zdarzeń w okresach czasu. Używamy silnika MergeTree z kluczem głównym (visitor_id, timestamp), który zapewnia szybkie wyszukiwania punktowe i wydajne skanowania zakresów. Widoki zmaterializowane utrzymują wstępnie zagregowane metryki dzienne i godzinowe.
Osiąganie poniżej 30 ms w skali
Trzy decyzje architektoniczne były kluczowe dla osiągnięcia naszego celu opóźnień. Po pierwsze, potok jest w pełni strumieniowy — zaczynamy przetwarzać sygnały, zanim całe ciało żądania HTTP zostanie odebrane. Po drugie, wyszukiwania w Redisie korzystają z puli połączeń z połączeniami trwałymi, co eliminuje narzut uzgadniania TCP. Po trzecie, zapisy do ClickHouse są w pełni asynchroniczne i nigdy nie blokują ścieżki odpowiedzi.
W testach obciążeniowych przy 50 tys. żądań na sekundę nasze opóźnienie p50 wynosi 12 ms, p95 24 ms, a p99 38 ms. p99 sporadycznie przekracza nasz cel 30 ms podczas rebalansowania klastra Redisa, ale p95 pozostaje stale poniżej 30 ms. Dla klientów z bardziej rygorystycznymi wymaganiami dotyczącymi opóźnień oferujemy dedykowane klastry Redisa, które eliminują rywalizację wielodzierżawną.