Kontekst
Fintech z branży cyberbezpieczeństwa musiał wiedzieć, kiedy czyjeś dane osobowe pojawiają się w otwartym internecie: na dowolnej stronie albo w dowolnym pliku, który otwiera się bez logowania. Istniała wcześniejsza wersja systemu. Dostałem zadanie przebudowania go i zrobiłem to jako jedyny inżynier, od projektu po prowadzenie go na produkcji.
Ta strona opisuje ogólne wzorce, które za nim stoją, a nie system klienta: nazwy, liczby i szczegóły są celowo pominięte.
Problem
Wcześniejsza wersja uruchamiała wszystkie swoje części jednocześnie, cały czas. Trudno było ją debugować, trudno testować, a wykonywała dużo pracy, która nigdy niczego nie znalazła.
Samo zadanie jest drogie na dwa sposoby. Każde wyszukiwanie i każde pobranie strony kosztuje. A JavaScript działa na jednym wątku na proces: czekanie na sieć jest w Node.js tanie, ale parsowanie PDF-a albo arkusza kalkulacyjnego blokuje ten wątek i wszystko inne na nim czeka.
Wzorce
Etapy, uruchamiane po kolei
Praca w naturalny sposób dzieli się na etapy: znajdź strony-kandydatów, odrzuć te, które nie mogą mieć znaczenia, otwórz resztę i sprawdź ją. Uruchamianie etapów jeden po drugim, zamiast wszystkich naraz, sprawiło, że system łatwiej zrozumieć, debugować i testować, kosztem tego, że partia trwa dłużej od początku do końca.
Filtruj przed pobraniem
Otwarcie i sparsowanie strony to najdroższy krok, więc najtańsza strona to ta, której nigdy się nie otwiera. Wszystko, co da się wykluczyć na podstawie samego adresu, jest wykluczane, zanim zacznie się pobieranie. Kompromis jest realny: strona błędnie wykluczona to strona nigdy niesprawdzona.
Na wiele maszyn i na wiele wątków w każdej z nich
Praca jest dzielona dwa razy. Między maszyny: kilka instancji uruchamia ten sam kod na tej samej bazie PostgreSQL. Wewnątrz każdej maszyny: pula worker threads (biblioteka workerpool), każdy z własną pętlą zdarzeń, więc wolny plik w jednym workerze nie wstrzymuje pozostałych. Każdy etap ma własny rozmiar puli, więc ciężkie etapy dostają więcej workerów, a te, które wołają API z limitem zapytań, niewiele.
System działa więc jako N instancji × M workerów na instancję, a przepustowość rośnie z oboma: więcej instancji albo więcej workerów to więcej przetworzonych URL-i dziennie, bez zmiany w kodzie.
01 · Tabela pracy
Każdy etap trzyma swoje elementy pracy w tabeli. Baza danych to jedyne, co maszyny i workery współdzielą.
- 01 · Tabela pracy Każdy etap trzyma swoje elementy pracy w tabeli. Baza danych to jedyne, co maszyny i workery współdzielą.
- 02 · Instancje Kilka instancji uruchamia ten sam kod na tej samej bazie. Dodanie kolejnej to zmiana konfiguracji.
- 03 · Pula workerów Każda instancja uruchamia pulę worker threads na etap, o rozmiarze ustawianym w konfiguracji (min / max na etap).
- 04 · Worker thread Parsowanie ciężkiego pliku blokuje tylko ten worker, nie pozostałe. Jeśli pula zawiedzie, jest restartowana po opóźnieniu, ograniczoną liczbę razy.
- 05 · Wyniki Worker zapisuje swój wynik z powrotem; następny etap podejmuje go stamtąd.
PostgreSQL jako kolejka pracy
Wiele workerów na wielu maszynach potrzebuje jednej gwarancji: dwa z nich nigdy nie przetwarzają tego samego elementu. Każdy etap trzyma swoją pracę w tabeli, a worker zajmuje partię jednym zapytaniem:
UPDATE work_itemsSET status = 'taken', taken_at = now()WHERE id IN ( SELECT id FROM work_items WHERE status = 'new' FOR UPDATE SKIP LOCKED LIMIT 100)RETURNING id, payload;- Wiersze zablokowane przez jeden worker są pomijane przez pozostałe, więc nikt nie czeka i nic nie zostaje wzięte dwa razy.
Workery komunikują się tylko przez bazę danych, więc dodanie maszyny albo workera nie wymaga żadnej innej zmiany, a przy restarcie maszyny nic nie ginie: element jest albo zrobiony, albo wciąż w tabeli.
Decyzje i kompromisy
-
Orkiestracja etapów zamiast uruchamiania wszystkiego naraz
Łatwiej utrzymać, debugować i testować niż części, które wszystkie działają cały czas.
KOMPROMIS Każdy etap czeka na poprzedni, więc partia trwa dłużej od początku do końca
-
Filtruj przed pobraniem
Najdroższy krok to ten, którego warto unikać w pierwszej kolejności.
KOMPROMIS Wykluczona strona nigdy nie zostaje otwarta, więc błędna reguła oznacza przeoczenie
-
Wiele maszyn, wiele workerów, jedna baza
Wolumen wymagał pracy równoległej;
FOR UPDATE SKIP LOCKEDuczyniło ją bezpieczną bez osobnej usługi kolejki.KOMPROMIS Więcej ruchomych części niż jeden proces
-
Optymalizacja algorytmiczna tam, gdzie wolumen ją mnoży
Najpierw przefiltruj wejście, potem zmierz, gdzie ucieka czas, potem usuń obliczenia, które się powtarzają. W tej skali wykonanie dwóch operacji zamiast dziesięciu to decyzja kosztowa, a nie kwestia stylu.
KOMPROMIS Szybszy kod jest często trudniejszy do czytania niż wersja oczywista
W tej samej firmie pracowałem też nad długimi zapytaniami Athena na Parquet w S3; techniki, dzięki którym się kończyły, opisuję w osobnym wpisie.
Stack
- Node.js
- TypeScript
- PostgreSQL
- Worker threads
- Playwright
- AWS