9 min czytaniaInżynieria

Ingestia kliknięć typu 'fire-and-forget' z Redpanda

Jak brzegowe punkty POP emitują zdarzenia kliknięć bez blokowania przekierowania, jak worker click-ingester grupuje dane w ClickHouse i co poświęcamy dla zysku w opóźnieniu.

Marius Voß
DevRel · edge infra
Pięcioetapowy diagram rurociągu pokazujący żądanie przekierowania przepływające przez edge-redirect do tematu Redpanda, do workera click-ingester i do ClickHouse, z odpowiedzią 301 odgałęziającą się przed wywołaniem producenta.

Ścieżka przekierowania skracacza URL ma dokładnie jedno zadanie: rozwiązać slug do celu i zwrócić 301 w ciągu kilku milisekund. Wszystko inne to księgowość. Analityka kliknięć, atrybucja, wzbogacanie geo, ocena oszustw, fan-out webhooków — nic z tego nie może znajdować się na ścieżce żądania. Budżet opóźnień na to nie pozwala.

To jest inżynieryjna sztuczka, która pozwala rurociągowi analitycznemu współistnieć z fundamentem p95 przekierowań < 15ms: brzeg (edge) emituje zdarzenie kliknięcia do Redpanda i zapomina o nim. Oddzielny worker — click-ingester — odbiera je później, wzbogaca i zapisuje do ClickHouse w partiach. Proces przekierowania nigdy nie jest blokowany. Rurociąg analityczny nigdy nie dotyka gorącej ścieżki. Kompromisem jest trwałość (durability), i jest to mniejszy kompromis, niż mogłoby się wydawać.

Co właściwie oznacza tutaj "fire and forget"#

Handler edge-redirect, po wybraniu docelowego URL z dwupoziomowej pamięci podręcznej, robi trzy rzeczy przed wysłaniem nagłówka Location:

  1. Buduje w pamięci strukturę click.Event z żądania (slug, ID workspace, user agent, referer, IP, geo z lokalnego mmdb GeoLite2-City, parsowanie urządzenia/przeglądarki, flagi podejrzeń).
  2. Wywołuje producer.Emit(ctx, event) na producencie Kafka franz-go.
  3. Zapisuje HTTP/1.1 301 i nagłówek Location do bufora odpowiedzi.

Wywołanie producenta powraca natychmiast. Nie czeka na ack z żadnego brokera Redpanda. Biblioteka franz-go buforuje rekord w procesie i wysyła go w gorutynie w tle; callback produkcji jest wywoływany później, w puli workerów, która nie jest właścicielem gorutyny żądania. Jeśli produkcja się nie powiedzie, callback loguje błąd, a zdarzenie zostaje porzucone. Przekierowanie zostało już obsłużone.

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

To jest cały interfejs. Żadnej kolejki ponownych prób wewnątrz procesu brzegowego, żadnego synchronicznego oczekiwania na ack, żadnego spoolowania na dysk. Kontrakt z resztą systemu jest prosty: emitowanie best-effort, logowanie błędów, nigdy nie blokuj.

Strażnik nil-receiver pozwala na lokalne programowanie bez brokera Kafka. Bez niego każdy kontrybutor potrzebowałby kontenera Redpanda działającego tylko po to, by przetestować ścieżkę przekierowania względem handlerów fasthttp.

Dlaczego nie wybraliśmy zapisu synchronicznego#

Oczywistą alternatywą jest zapisywanie każdego kliknięcia bezpośrednio do ClickHouse z poziomu edge. Rozważaliśmy to. Odrzuciliśmy to z trzech powodów, które się kumulują.

Opóźnienie. Round-trip ClickHouse INSERT z POP we Frankfurcie do klastra ClickHouse w tym samym regionie wynosi 3-6ms p50 na spokojnej sieci, 12-20ms p95 pod obciążeniem. To jest cały budżet przekierowania. Dodanie go do ścieżki odpowiedzi wypchnęłoby p95 poza SLO 15ms, zanim cokolwiek innego poszłoby nie tak. Wpis o strategii cache wyjaśnia, jak napięty jest ten budżet w praktyce.

Backpressure. ClickHouse chętnie przyjmuje partie od 1000 do 10000 wierszy na jeden INSERT. Jest niezadowolony z przyjmowania pojedynczych wierszy w ciasnych pętlach — silnik MergeTree zapisuje plik części dla każdego insertu, a proces w tle scala te części. Wzorzec bezpośredniego zapisu z wieloregionowej floty brzegowej stworzyłby miliony malutkich części, a kolejka scalania nigdy by nie nadążyła. Dokumentacja ClickHouse mówi wyraźnie: wstawiaj w partiach co najmniej 1000 wierszy, nie częściej niż raz na sekundę.

Izolacja awarii. Restart klastra ClickHouse, zakłócenie sieci lub wolne zapytanie blokujące replikę propagowałyby się bezpośrednio do błędów przekierowań. Proces brzegowy zacząłby albo przekraczać limit czasu (pogarszając p95), albo porzucać kliknięcia (pogarszając jakość danych). Umieszczenie szyny komunikatów między nimi pozwala obu stronom zawodzić niezależnie — edge kontynuuje przekierowywanie, nawet gdy ClickHouse jest zdegradowany, a ClickHouse kontynuuje ingestie, nawet gdy jeden POP jest offline.

Redpanda absorbuje wszystkie trzy naciski. Jest kompatybilna z protokołem Kafka, więc franz-go rozmawia z nią transparentnie. Ma postać pojedynczego pliku binarnego bez JVM. Buforuje na dysku, więc wielogodzinna awaria ClickHouse nie powoduje utraty zdarzeń, dopóki okno retencji tematu trzyma.

Worker click-ingester#

click-ingester to usługa Go działająca jako grupa konsumentów (consumer group) na temacie zdarzeń kliknięć. Jedna replika na region, trzy regiony, bez shardingu według sluga lub workspace — grupa konsumentów rebalansuje się, jeśli replika zostanie zrestartowana, a partycje są przypisywane przez Redpanda. Zadanie konsumenta jest małe:

  • Poll pobiera dane z tematu.
  • Dekodowanie JSON każdego rekordu do typowanego Event.
  • Wpychanie zdarzenia do bufora w pamięci writera.
  • Czasami: wyzwalanie webhooków, przekazywanie do Klaviyo / Mixpanel / GA4 MP, publikowanie w strumieniu kliknięć na żywo w aplikacji.

Writer grupuje według liczby lub czasu, zależnie od tego, co nastąpi pierwsze. Domyślnie: 1000 zdarzeń na partię, 5-sekundowy interwał flushowania. Partia jest budowana w wywołanie PrepareBatch INSERT INTO click_events przeciwko ClickHouse i zatwierdzana jako jeden append po stronie serwera. Po sukcesie writer oznacza offsety rekordów Kafka jako zatwierdzone (committed); w przypadku niepowodzenia nic nie jest zatwierdzane, a konsument ponownie pobiera dane od ostatniego udanego offsetu przy następnym pollu.

Kontrakt offset-po-flushu jest gwarancją trwałości. Konsument nigdy nie mówi Redpanda "przetworzyłem ten rekord", dopóki rekord nie wyląduje w ClickHouse jako część udanej partii. Awaria między konsumpcją a flushem oznacza, że grupa konsumentów rebalansuje się, nowy właściciel wykonuje poll od ostatniego zatwierdzonego offsetu, a zdarzenia są ponownie przetwarzane. Ponowne przetwarzanie jest bezpieczne, ponieważ tabela click_events to ReplacingMergeTree z kluczem opartym na syntetycznym ID zdarzenia — duplikaty zapadają się podczas scalania.

Wadliwe wiadomości nie są ponawiane. Błąd dekodowania JSON jest natychmiast oznaczany jako zatwierdzony, aby konsument nie utknął na trującym rekordzie. Jest to małe, ale realne źródło utraty danych; wskaźnik wynosi pojedyncze zdarzenia dziennie w całej flocie, a zdarzenia te pojawiają się w liczniku Prometheus decode_error_total konsumenta.

Kompromis trwałości w liczbach#

Fire-and-forget rezygnuje z niektórych zdarzeń. Pytanie brzmi: z ilu i czy ma to znaczenie dla danego przypadku użycia.

Zmierzyliśmy wskaźnik strat produkcyjnych w 90-dniowym oknie. Liczba ta wynosi około 0,04% wyemitowanych zdarzeń — około czterech utraconych kliknięć na dziesięć tysięcy. Podział:

  • Restart procesu edge z buforem w locie. franz-go buforuje do kilkuset milisekund rekordów przed flushem do brokera. SIGTERM podczas wdrożenia może porzucić to, co znajduje się w buforze. Skrypt wdrożeniowy wydaje polecenie czystego zamknięcia, które opróżnia bufor z 2-sekundowym limitem czasu, co wyłapuje większość przypadków, ale nie wszystkie.
  • Niedostępność brokera Redpanda poza oknem ponownych prób producenta. franz-go ponawia próby produkcji, ale budżet ponowień jest ograniczony. Jeśli klaster Redpanda w regionie jest niezdrowy przez ponad około 30 sekund, bufor przepełnia się i nowe rekordy są porzucane na brzegach producenta.
  • Partycja sieciowa między POP edge a regionalnym klastrem Redpanda. Ten sam efekt co powyżej. Producent loguje ostrzeżenia i porzuca zdarzenia do czasu powrotu łączności.

Dla obciążenia skracacza URL strata 0,04% jest akceptowalna. Kliknięcia to sygnał statystyczny, a nie transakcje finansowe. Analityka kohortowa, atrybucja konwersji i dystrybucja geograficzna dobrze agregują się w próbie z takim wskaźnikiem braków. Przypadki użycia, które by tego nie tolerowały — branże regulowane z wymogami audytu, zliczenia kliknięć powiązane z rozliczeniami — nie są tym, co bezpośrednio obsługuje warstwa przekierowań.

Dla workspace'ów potrzebujących wyższej trwałości oferujemy oddzielny tryb audit-log, który zapisuje każde kliknięcie synchronicznie do Postgres dodatkowo do ścieżki fire-and-forget. Synchroniczny zapis dodaje 3-5ms p95 do przekierowania, jest opcjonalny, domyślnie wyłączony. Przewodnik po eksporcie do ClickHouse dokumentuje kształt audit-log dla zespołów compliance, które muszą uzgadniać liczniki.

Strategia powtórek, gdy ClickHouse nie działa#

Producent działa w trybie fire-and-forget, ale strona konsumenta ma realną historię powtórek (replay).

Gdy ClickHouse jest niedostępny, wywołania flusha writera kończą się niepowodzeniem. Konsument kontynuuje poll — pętla pollowania franz-go jest niezależna od pętli flushowania writera — ale offsety nie są zatwierdzane, ponieważ flush się nie powiódł. Retencja Redpanda jest ustawiona na 72 godziny, co jest maksymalną tolerowaną awarią, zanim zdarzenia zaczną się starzeć.

Podczas rzeczywistej awarii (mieliśmy trzy o znaczącym czasie trwania w ciągu 18 miesięcy), sekwencja odzyskiwania to:

  1. ClickHouse wraca do trybu online.
  2. Kolejna próba flusha kończy się sukcesem i zatwierdza offsety.
  3. Konsument nadrabia zaległości, opróżniając backlog z skonfigurowaną prędkością partii. Przy partii 1000 zdarzeń i 5-sekundowym flushu, konsument może opróżnić około 200 zdarzeń na sekundę na replikę; trzy repliki oznaczają około 36 tys. zdarzeń na minutę.
  4. Dashboard Grafana dla tabeli click_events pokazuje krzywą nadrabiania — wskaźnik wstawiania wierszy pozostaje podwyższony do czasu wyczyszczenia backlogu.

72-godzinna retencja jest dobrana tak, aby zaabsorbować wielodniową przebudowę ClickHouse bez utraty danych. Nigdy nie użyliśmy więcej niż 4 godzin w produkcji. Kosztem jest dysk na brokerach Redpanda, co jest małą ceną w porównaniu z utratą danych analitycznych.

Możliwy jest również replay-from-archive. Redpanda posiada warstwową pamięć masową (tiered storage) wysyłającą zamknięte segmenty do pamięci obiektowej kompatybilnej z S3. Mamy to skonfigurowane, ale nie było nam potrzebne — gorący replay pokrywa każdy incydent, który widzieliśmy.

Co jeszcze robi konsument#

Ingestia kliknięć to nie tylko zapisy do ClickHouse. Konsument jest centralnym punktem fan-out dla każdego systemu niższego szczebla, który dba o kliknięcia.

  • Webhook dispatcher. Konfigurowane przez klienta webhooki są wyzwalane z konsumenta, a nie z edge. Konsument kolejkuje zadanie webhooka na kliknięcie pasujące do skonfigurowanego filtra. Ponowne próby, podpisywanie i dostarczanie odbywają się w webhook-dispatcher.
  • Server-side event forwarding. Klaviyo, Mixpanel, GA4 Measurement Protocol, Meta CAPI. Konsument przechowuje cache konfiguracji dla każdego workspace i wysyła odpowiedni POST dla każdego kliknięcia, które workspace ma podpięte. Forwardery działają w trybie best-effort z małym ponawianiem w pamięci; trwałe błędy lądują w tabeli dead-letter.
  • Live click stream. Widok w aplikacji "oglądaj spadek kampanii na żywo" subskrybuje kanał Redis pub/sub. Konsument publikuje zdarzenie o minimalnym kształcie dla każdego kliknięcia, które pasuje do aktywnej sesji na żywo. Jest to jedyna część rurociągu, która wydaje się synchroniczna, i działa w trybie best-effort — porzuca zdarzenia, gdy kanał jest zatkany.
  • Pixel firing. Pixele konwersji (retargeting i konwersja offline) są wyzwalane z konsumenta na podstawie konfiguracji poszczególnych linków. Wyzwalanie pixeli to oddzielna domena błędów; niepowodzenia są logowane, ale nie blokują writera ClickHouse.

Wszystko to odbywa się po zatwierdzeniu offsetu, ale przed następnym pollem. Wolny punkt końcowy pixela może spowolnić efektywną przepustowość konsumenta. Limit czasu dla każdego forwardera (twardy limit 1 sekundy) i limit współbieżności na partię (16 w locie) zapobiegają dominacji wolnej ścieżki.

Dlaczego taki kształt, a nie Kinesis lub kolejka#

Kilka ocenionych i niewybranych alternatywnych kształtów szyny zdarzeń.

SQS lub RabbitMQ jako kolejka. Żadne z nich nie oferuje przepustowości na brokera, jaką Redpanda zapewnia przy wolumenie zdarzeń kliknięć. SQS rozlicza się za żądanie, co czyni strumienie o wysokim wolumenie drogimi; RabbitMQ stawia opór przy gęstych tematach.

AWS Kinesis. Rozsądne, gdybyśmy rezydowali w AWS. Nie robimy tego — Hetzner FRA, Hetzner ASH, OVH SGP. Własny Kafka lub Redpanda to właściwy kształt dla wdrożenia typu EU-first.

Zwykły Kafka. Działa. Wybraliśmy Redpanda ze względu na profil operacyjny — pojedynczy plik binarny, brak Zookeeper, brak tuningu JVM. Protokół komunikacyjny jest identyczny i franz-go nie widzi różnicy. Samodzielne wdrożenie Elido może zamienić go na Apache Kafka bez zmian w kodzie.

Usługi zarządzane, takie jak Confluent Cloud. Nie są rezydentami UE w sposób, w jaki tego chcemy. Warstwa przekierowań potrzebuje opóźnienia szyny komunikatów w tym samym regionie.

Decyzja została opisana bardziej szczegółowo na stronie architektury /docs/architecture/edge-redirect, która jest źródłem prawdy dla wyborów konfiguracyjnych warstwy przekierowań.

Co zrobilibyśmy inaczej następnym razem#

Wzorzec fire-and-forget jest poprawny. Implementacja ma niedociągnięcia, na które warto zwrócić uwagę każdemu, kto kopiuje ten projekt.

Shutdown drain. 2-sekundowy limit czasu opróżniania franz-go tracił zdarzenia podczas wdrożeń, gdy bufor był zajęty. Rozwiązaniem jest hook SIGTERM, który wykonuje flush synchronicznie przed zamknięciem procesu, z dłuższym limitem czasu i twardym zamknięciem, jeśli broker jest nieosiągalny.

Ścieżka dead-letter dla błędów dekodowania. Oznaczanie trujących rekordów jako zatwierdzone i pójście dalej jest dobre dla przepustowości, ale tracimy obserwowalność. Przyszła iteracja zapisze surowe bajty wraz z błędem dekodowania do tabeli click_events_decode_failures, aby zespół mógł audytować, co się pojawia.

Współbieżność forwarderów na workspace. Dziś forwardery każdego workspace'u dzielą globalną pulę konsumenta. Głośny workspace z wolnym punktem końcowym Mixpanel może zagłodzić innych. Ograniczenie na workspace jest oczywistym rozwiązaniem; jeszcze go nie zbudowaliśmy.

Żadne z powyższych nie spowodowało incydentu produkcyjnego. Są to rzeczy, które loguje się w backlogu ADR i sukcesywnie poprawia.

Powiązane lektury#

Wypróbuj Elido

Skracarka URL hostowana w UE: własne domeny, głęboka analityka i otwarte API. Darmowy plan — bez karty kredytowej.

Tagi
ingestia kliknięć fire-and-forget
zdarzenia kliknięć redpanda
wstawianie wsadowe clickhouse
rurociąg analityczny skracacza url
kafka edge redirect
producent franz-go
trwałość zdarzeń kliknięć

Czytaj dalej