Skalowanie LunaDb, naszego wewnętrznego systemu deklaratywnego ładowania danych

Obraz współautora – zespół AsanyArvind Vijayakumar
5 marca 2026
13 min czytania
facebookx-twitterlinkedin
Gemini said Two female professionals collaborating at a desk in a bright, modern office. One woman sits while the other leans in, smiling and using the computer mouse, with a male colleague working in the background.

W Asanie stworzyliśmy system ładowania danych o nazwie LunaDb, który stanowi podstawę naszej aplikacji internetowej. Pomimo nazwy nie jest to baza danych. Jest to raczej system podobny do GraphQL służący do deklaratywnego pobierania danych – w zasadzie sposób na załadowanie najnowszej wersji danych i wszystkich przyszłych aktualizacji.

Początkowo uruchomiliśmy LunaDb w 2015 roku jako radykalnie zmienioną wersję naszej infrastruktury zaplecza¹. Centralnym elementem tego nowego systemu był serwer synchronizacji, monolit, który wykonuje wszystko, od synchronizacji klienta po ładowanie danych i kontrolę dostępu. Bez wprowadzania istotnych zmian ta początkowa architektura rozwinęła się znacznie ponad poziom, którego początkowo oczekiwano, aż do milionów aktywnych użytkowników tygodniowo i miliardów zapytań dziennie. 

Chociaż wydajność pozostała wysoka, w miarę wzrostu ruchu i złożoności funkcji obsługa i ulepszanie LunaDb stawało się coraz trudniejsze ze względu na ograniczenia serwera synchronizacji.

Przegląd naszej infrastruktury ładowania danych

przegląd naszej infrastruktury ładowania danych

Dlaczego obsługa była trudna?

Przesuwanie ruchu jest kosztowne

Serwer synchronizacji bezpośrednio zarządzał trwałymi połączeniami WebSocket z klientami. Każdy WebSocket był wspierany przez sesję klienta ze stanem. Gdy połączenie zostało przerwane, cały ten stan został odrzucony, a klient ponownie subskrybował wszystkie dane, na których mu zależało. Kiedy dzieje się tak w przypadku wielu sesji, szybko staje się to kosztowne. Musieliśmy więc zachować ostrożność podczas przenoszenia tych połączeń, biorąc pod uwagę duży wzrost obciążenia spowodowany ponownymi połączeniami.

przesuwanie ruchu

przesuwanie ruchu

Wdrożenie serwerów synchronizacji oznacza przesunięcie ruchu

Oczywiście nie można na stałe zapobiegać przesuwaniu ruchu. Za każdym razem, gdy chcesz przesłać nowy kod lub zwiększyć/zmniejszyć skalę, musisz wyłączyć serwery synchronizacji, a to wymaga przeniesienia całego ruchu z instancji końcowych.

ciągłe aktualizacje

ciągłe aktualizacje

Serwery synchronizacji nie działają wydajnie podczas uruchamiania

Jednocześnie serwery synchronizacji osiągają wydajność dopiero po niemałym okresie rozgrzewania procesu. Zarządzanie obydwoma tymi problemami wymagało w przeszłości dość delikatnej równowagi i szeroko zakrojonych prac inżynieryjnych.

Serwery synchronizacji są złożone i trudne do monitorowania

Wreszcie, ponieważ serwery synchronizacji uruchamiały wiele dowolnych fragmentów kodu produktu (za pośrednictwem niestandardowych funkcji po stronie serwera), były one bardzo podatne na regresje wydajności oparte na „hałaśliwych sąsiadach”, które były trudne do przypisania². 

problem z hałaśliwym sąsiadem

problem z hałaśliwym sąsiadem


Uzasadnione pytanie brzmi: „Dlaczego serwery synchronizacji są złożone i nie działają prawidłowo podczas uruchamiania?”

Częstą przyczyną tych problemów jest nasz kod produktu po stronie serwera. Główne fragmenty kodu serwera synchronizacji są napisane w języku Scala.  Pomimo pewnych zawiłości związanych z zarządzaniem stanem sesji i różnymi aspektami struktury Luna, ten kod struktury/platformy w większości zachowuje się zgodnie z oczekiwaniami (występuje stosunkowo niewiele problemów operacyjnych i związanych z wydajnością). 

Z drugiej strony te wartości obliczane przez serwer produktu (nazywamy je SCV, ale można je traktować jako niestandardowe resolvery) są napisane w Typescript. Oba zestawy kodu działają razem w GraalVM, poliglotycznej maszynie wirtualnej, która umożliwia używanie wielu języków za pośrednictwem swojej struktury Truffle. Ponieważ są napisane w Typescript, SCV są zasadniczo interpretowane przy uruchomieniu, co przewidywalnie skutkuje niedopuszczalną wydajnością i obciążeniem procesora. GraalVM spróbuje przeprowadzić kompilację just-in-time na wywołanych SCV. To dobrze! GraalVM/Truffle może znacznie zoptymalizować ich wydajność, ale nie jest to bezpłatne. Kompilacja SCV może być dość kosztowna (pod względem procesora, pamięci podręcznej kodu itp.). 

nasza konfiguracja wielojęzycznej maszyny wirtualnej

nasza konfiguracja wielojęzycznej maszyny wirtualnej

Dlaczego te dwa języki?

Nasz pierwszy projekt dla SCV był w całości w języku Scala. Z drugiej strony nasze systemy mutacji i zadań asynchronicznych są napisane w Javascript/Typescript. Chociaż SCV oparte na Scali działały, powielanie logiki biznesowej między naszymi systemami mutacji i zadań asynchronicznych a LunaDb, wraz z nieznajomością Scali przez inżynierów produktu, stało się sporym hamulcem dla szybkości produktu. 

Dlaczego GraalVM?

Buforujemy wiele danych w trakcie przetwarzania, aby przyspieszyć obliczanie (i ponowne obliczanie) wyników subskrypcji. Korzystanie z GraalVM daje nam prosty sposób na udostępnianie tych pamięci podręcznych w różnych językach bez obaw o poprawność lub wydajność, które mogłyby wyniknąć z podziału części Scala i TypeScript na oddzielne kontenery.


Dlaczego trudno było wprowadzić ulepszenia?

Ponieważ serwer wykonywał tak wiele zadań i był stosunkowo niestabilny w działaniu, staraliśmy się unikać większych zmian. Nie tylko ze względu na złożoność kodu, ale także ze względu na wysokie koszty bezpiecznego wdrażania nowych zmian.

Jak rozwiązujemy problemy?

Tak, lubię zadawać pytania

Biorąc pod uwagę trudności z obsługą i ulepszaniem serwera synchronizacji, podjęliśmy trudną decyzję o zmianie naszej architektury. Przede wszystkim zdecydowaliśmy się zastąpić monolityczny serwer synchronizacji dwoma mniejszymi rodzajami komponentów:

  • broker sesji, który zarządza połączeniami klienta i rozpoznawaniem stanu

  • synchronizowalny moduł ładujący odpowiedzialny za ładowanie danych, tj. realizację zapytań od brokerów sesji

brokerzy sesji i moduły ładujące z synchronizacją

brokerzy sesji i moduły ładujące z synchronizacją

Dlaczego to pomaga?

Ta nowa architektura natychmiast oddziela przesuwanie ruchu WebSocket (tj. wdrażanie brokerów sesji) od rozgrzewania nowych procesów (tj. wdrażania synchronizowanych modułów ładujących). W rezultacie możemy zminimalizować zakłócenia, wdrażając ładowarki synchronizacyjne oddzielnie od brokerów sesji.

Każdy z tych nowych komponentów jest prostszy.

  • Brokerzy sesji są znacznie lżejsze, nie wymagają rozgrzewania procesów i nie uruchamiają żadnego kodu produktu. W rezultacie nie musimy ich wdrażać tak często – a kiedy to robimy, jest to dość proste.

  • Ładowarki synchronizowalne mają prostszy interfejs (żądania subskrypcji bezstanowe), który łatwiej jest dostosować do standardowego poziomego autoskalowania podów w Kubernetes. Dzięki temu ich rozgrzewanie jest również szybsze i prostsze – możemy po prostu użyć dublowania ruchu dla żądań otoczenia przepływających między brokerami sesji a ładowarkami Syncable

Nowa architektura pozwala nam znacznie uprościć proces rozwoju nowego produktu. Od samego początku niezależne harmonogramy wdrożenia kodu produktu po stronie serwera i wywoływania kodu produktu po stronie klienta spowalniały prędkość realizacji i były źródłem problemów operacyjnych (z powodu niezgodności wersji). Ponieważ moduły ładujące z synchronizacją są teraz jedynym pozostałym procesem, który uruchamia kod produktu, a ich wdrażanie nie powoduje już zakłóceń, możemy je ponownie wdrażać za każdym razem, gdy wprowadzamy nowy kod produktu.

Ta nowa architektura pozwala nam lepiej skalować nowe funkcje poprzez wdrażanie różnych pul modułów ładujących z możliwością synchronizacji dla różnych typów obciążeń pracą (takich jak różne odrębne funkcje, np. Skrzynka odbiorcza, Zadanie, Cele itp.). Broker sesji działa jako brama usług, która może bezpośrednio kontrolować sposób kierowania zapytań o dane do różnych upstreamowych modułów ładujących z możliwością synchronizacji.

Jakie są kluczowe wyzwania związane z projektowaniem?

Świetnie! Ta nowa architektura brzmi znacznie lepiej, ale jak udało nam się ją zrealizować? Serwer synchronizacji jest w zasadzie monolitem, ponieważ obejmuje wiele funkcji – a rozbicie monolitu jest prawie zawsze trudne. W naszym przypadku musieliśmy pokonać kilka kluczowych przeszkód projektowych. 

Rozdzielenie PubSub

PubSub, nasz system do implementacji reaktywności, został zaprojektowany wokół jednego procesu (serwera synchronizacji), który był odpowiedzialny za ładowanie nowych danych i wysyłanie ich do klienta. Musieliśmy przeprojektować PubSub w taki sposób, aby zagwarantować poprawność działania tych dwóch, obecnie niezależnych typów procesów (modułów ładujących z synchronizacją i brokerów sesji). 

Przyjrzyjmy się pokrótce, jak jest to zaimplementowane na serwerach synchronizacji. Uwaga: warto przeczytać nasz poprzedni post na temat potoku unieważniania, ale przedstawimy system w wersji, która nie wymaga wcześniejszej wiedzy.

Na serwerze synchronizacji śledzimy subskrypcje dla każdej sesji. Używamy potoku unieważniania do ciągłego monitorowania subskrypcji pod kątem aktualizacji. Przy każdym nowym komunikacie unieważnienia serwer synchronizacji ponownie załaduje wszystkie subskrypcje, których dotyczy zmiana. 

Serwery synchronizacji intensywnie buforują wyniki obiektów/zapytań bazy danych, wyniki niestandardowych resolverów i wyniki poprzednich subskrypcji, aby zoptymalizować ponowne ładowanie subskrypcji (tj. przy użyciu wzorca odczytu). Artefakty w pamięci podręcznej są pasywnie unieważniane przez ten sam potok unieważniania, który jest używany do subskrypcji. Za każdym razem, gdy próbujemy użyć danych z pamięci podręcznej, sprawdzamy ich ważność i w razie potrzeby przechodzimy do ponownego obliczania.

Możemy zaobserwować wyraźną zależność między ponownym ładowaniem subskrypcji a unieważnianiem danych w pamięci podręcznej. Po otrzymaniu unieważnienia, jeśli ponownie załadujemy subskrypcję, zanim dane w pamięci podręcznej zostaną unieważnione, potencjalnie obliczymy nieaktualny wynik. Gdy zarówno ładowanie danych, jak i zarządzanie subskrypcjami odbywa się w ramach tego samego procesu, zagwarantowanie tej zależności jest proste – wystarczy unieważnić pamięć podręczną przed ponownym załadowaniem. 

Warunek wyścigu może spowodować, że dane będą nieaktualne po aktualizacji

Warunek wyścigu może spowodować, że dane będą zdezaktualizowane po aktualizacji

W proponowanej przez nas nowej architekturze brokery sesji i synchronizowalne moduły ładujące są niezależnymi konsumentami procesu unieważniania. W jaki sposób możemy więc wymusić unieważnienie pamięci podręcznych przed ponownym załadowaniem subskrypcji?

Wersjonowanie żądań i odpowiedzi

Moglibyśmy rozwiązać ten problem, sprawiając, że potok unieważniania dostarczałby wiadomości w jednym rytmie. Albo mogliśmy stworzyć mechanizm egzekwujący gwarancję kolejności, że żadne komunikaty z potoku unieważniania nie dotrą do brokera sesji przed ładowarką z synchronizacją. Oba te rozwiązania miały jednak nieidealne kompromisy – co najważniejsze, zwiększały sprzężenie między brokerami sesji a ładowarkami synchronizacyjnymi.

Zamiast tego rozwiązaliśmy ten problem, rozszerzając nasz protokół ładowania danych o wersje żądań i odpowiedzi na podstawie ich względnego postępu w strumieniach unieważniania. Ponieważ strumień reprezentuje całkowitą kolejność aktualizacji baz danych, postęp naszego strumienia może być używany jako globalny licznik wersji.

wersje zlecenia i odpowiedzi

wersje zlecenia i odpowiedzi

Ponowne ładowanie unieważnień

Serwery synchronizacji ładują mnóstwo danych – większość wszystkich operacji odczytu dla witryny. Nasza nowa architektura wymaga, aby brokery sesji i moduły ładujące Syncable wymieniały dużą ilość danych przez sieć. W przypadku nowych subskrypcji to obciążenie sieci jest stosunkowo nieznaczne. Jest to jednak szczególnie nieefektywne w przypadku ponownego ładowania po unieważnieniu, ponieważ często nie musimy zwracać pełnej odpowiedzi – tylko zaktualizowane dane³. Niektóre przypadki są tutaj szczególnie niekorzystne. Wyobraź sobie sytuację, w której jeden użytkownik wyświetlił 10 000 zadań w projekcie, a inny użytkownik stale zmienia opisy zadań w tym projekcie: wszystkie zadania musiałyby zostać wysłane przy każdym unieważnieniu! Oczywiście idealnym rozwiązaniem jest odsyłanie tylko zaktualizowanych danych, ale jak to skutecznie wdrożyć?

ponowne ładowanie unieważnienia

przeładowania unieważnienia

Oznaczanie

Aby moduł ładujący z synchronizacją mógł obliczyć różnicę zaktualizowanych danych, musi wiedzieć, jakie dane już posiada osoba wysyłająca żądanie. Ale przekazanie najnowszych danych wraz z żądaniem byłoby tak samo kosztowne, jak zwrócenie pełnego wyniku.  Musimy przedstawić dane w sposób bardziej efektywny pod względem wykorzystania miejsca.

Hashowanie to świetny sposób na zaoszczędzenie miejsca. Każdy bit danych szczegółowych, który nas interesuje, nazywamy elementem synchronizowalnym. Możemy obliczyć 128-bitowy hash Murmur dla każdego serializowanego elementu typu syncable, aby użyć go jako odcisku palca ⁴. Mówiąc dokładniej, ten odcisk palca jest identyfikatorem tej wersji elementu do synchronizacji.

Wszędzie tam, gdzie śledzimy dane syncable, możemy zamiast tego użyć ich fingerprintów. Teraz, gdy chcemy śledzić pełną odpowiedź dotyczącą subskrypcji, możemy po prostu użyć zestawu „odcisków palców” bez konieczności przesyłania pełnych danych!


Uwaga na marginesie: Czym jest element do synchronizacji i jak jest powiązany z subskrypcjami?

Elementy do synchronizacji to zawartość wyniku subskrypcji. Gdy ładujemy subskrypcję, wyniki są zwracane jako zestaw elementów do synchronizacji. Mówiąc dokładniej, element do synchronizacji może być obiektem, zapytaniem lub wynikiem SCV.

możliwość synchronizacji z mapowaniem subskrypcji

możliwość synchronizacji z mapowaniem subskrypcji

Oczywiście każda subskrypcja jest mapowana do wielu elementów do synchronizacji. Jednak elementy synchronizowalne mogą być współdzielone przez wiele subskrypcji (gdy ładują one nakładające się dane). Dlatego w rzeczywistości istnieje mapowanie typu „wiele do wielu” między subskrypcjami a elementami do synchronizacji.


Przekazujemy zestaw tych odcisków palców z każdym żądaniem od brokera sesji. W ładowarce elementów do synchronizacji obliczamy pełną odpowiedź, obliczamy jej zestaw odcisków palców, wykluczamy wszelkie dane, które pokrywają się z żądaniem, i zwracamy deltę.

Jak to osiągnąć?

Biorąc pod uwagę rozmiar i krytyczność zmiany, podzieliliśmy wdrożenie na około 4 etapy. 

Etap 1 – Refaktoryzacja monolitu

  • Rozdzielenie naszego ściśle powiązanego kodu zarządzania sesjami i ładowania danych na niezależne komponenty

Etap 2 – Lokalny moduł ładujący z synchronizacją

  • Użycie nowego komponentu ładowania danych do utworzenia lokalnego serwera gRPC i przeniesienie ładowania danych

etapy 1-2

Etap 3 – Zdalny syncable-loader

  • Utworzenie nowego pliku binarnego syncable-loader i wdrożenie

  • Przenieś całe ładowanie danych z sync-server do naszego nowego wdrożenia syncable-loader

Etap 4 – Oddzielny plik binarny session-broker

  • Utwórz nowy plik binarny session-broker i wdrożenie

  • Przenieś cały ruch z serwerów synchronizacji do brokerów sesji

etapy 3-4

Jakie napotkaliśmy trudności?

Tak wiele. Od czego zacząć?

Duże odpowiedzi

Jednym z problemów, na który szybko natknęliśmy się, były duże rozmiary odpowiedzi. Ponieważ ładowanie danych na serwerach synchronizacji odbywało się w ramach tego samego procesu, do tej pory nie stanowiło to dużego problemu⁵. Jednak kiedy zaczęliśmy ładować dane przez lokalną granicę gRPC, zaczęliśmy napotykać wiele problemów. 

Przez cały czas podejrzewaliśmy, że niektóre odpowiedzi mogą być duże, ale kiedy zaczęliśmy to badać, odkryliśmy naprawdę zaskakujące wyniki. Mieliśmy tysiące ładowań dziennie, regularnie przekraczających 100 MiB! Fizycznie nie mogliśmy zwracać tak dużych odpowiedzi za pomocą unijnej metody gRPC (zaczyna się osiągać maksymalny rozmiar ramki http2). Co zrobić?

Rozważaliśmy kilka sposobów na systematyczne rozwiązanie tego problemu, ale ostatecznie doszliśmy do wniosku, że musimy zająć się przyczynami źródłowymi. Mogliśmy wdrożyć strumieniowanie gRPC po stronie serwera, ale wysokie koszty serializacji i zwiększona konkurencja o gniazda miałyby dość negatywny wpływ na opóźnienia i przepustowość. Moglibyśmy po prostu odrzucić te odpowiedzi, ale wskaźnik występowania był zbyt wysoki, aby to było akceptowalne. 

Zdecydowaliśmy się na podejście trójfazowe, w ramach którego oznaczamy wszystkie duże odpowiedzi, analizujemy i eliminujemy każdy problematyczny przypadek, a następnie egzekwujemy ścisłą górną granicę rozmiaru odpowiedzi.

Oznaczyliśmy wszystkie ładunki większe niż 1 MB i zarejestrowaliśmy szczegółowe zdarzenia dotyczące źródła, wykorzystania i podziału danych. Wystąpiło kilka różnych kosztownych przypadków użycia, ale najbardziej problematyczne były prawdopodobnie bloby miniatur załączników. Okazało się, że były one kodowane jako ciągi base64 i uwzględniane w serializowanych odpowiedziach. W niewielkiej liczbie były do zaakceptowania, ale szybko stawały się ogromne, gdy były ładowane masowo – na przykład podczas ładowania widoku siatkowego, który renderował miniatury załączników dla każdego zadania.

Udało nam się stopniowo rozwiązywać takie problemy, ograniczając miniatury w przypadku bardzo dużych odpowiedzi, używając mniejszych miniatur i ostatecznie usuwając dane binarne z odpowiedzi. Po wprowadzeniu takich prostych środków zaradczych liczba ogromnych odpowiedzi zniknęła, a następnie mogliśmy wprowadzić ograniczenia wielkości odpowiedzi na poziomie frameworka⁶.

Kolizje tematów

Kolejnym dziwnym problemem, z którym się zetknęliśmy, były kolizje tematów PubSub. Okazało się, że mieliśmy niezgodne zastosowania struktury, które generowały ten sam temat subskrypcji niezależnie od domeny. Gdy pubsub występował tylko w jednym typie procesu, skutki były stosunkowo łagodne. Zazwyczaj pojedynczy temat pubsub odpowiada danym z jednej domeny. Jednak teraz, gdy PubSub jest podzielony między brokery sesji i moduły ładujące synchronizację, możliwe było, że te dwa typy procesów nie zgadzały się co do domeny konkretnego tematu. Kiedy tak się działo, obserwowaliśmy stabilny, podwyższony wskaźnik ponownego ładowania unieważnień z powodu tej „niedopasowanej domeny”. Na szczęście naprawa była dość prosta, ale ciekawe jest to, jak ten błąd przetrwał w naszej strukturze tak długo bez wykrycia. 

Ponowne dostosowanie obciążeń pracą

Brokerzy sesji i moduły ładujące z synchronizacją mają obciążenia, które znacznie różnią się od obciążeń oryginalnych serwerów synchronizacji. Brokerzy sesji są odpowiedzialni tylko za zarządzanie sesjami, a moduły ładujące z możliwością synchronizacji są odpowiedzialne tylko za ładowanie danych. 

Nie byliśmy do końca pewni, jak wpłynie to na ich wymagania dotyczące zasobów, więc zaczęliśmy od podobnych żądań zasobów (cpu/mem) i ustawień poziomego autoskalera podów (HPA) dla obu. 

session-brokers

Z naszych obserwacji jasno wynikało, że brokerzy sesji są znacznie lżejszymi rozwiązaniami. Działały niezawodnie przy bardzo niskim obciążeniu procesora (jeśli już, to znacznie bardziej ograniczała je pamięć⁷). Kilka replik wydawało się wystarczające do obsługi ruchu całej komórki infrastruktury. Jednak kiedy faktycznie zmniejszyliśmy minReplicas w HPA, zauważyliśmy, że ponowne ładowanie danych i opóźnienie ponownego ładowania gwałtownie wzrosły. Co się działo?

Krótko mówiąc, zapomnieliśmy wziąć pod uwagę wszystkie nasze wspólne ustawienia dotyczące rozmiaru pamięci podręcznej i ograniczników. Przy zaledwie kilku replikach każdy broker sesji obsługiwał znacznie więcej sesji na pod (około 3,5 raza więcej) niż typowy serwer synchronizacji. Ponieważ każdy z nich widział znacznie więcej danych, całkowicie zapełniał swoje pamięci podręczne tematów pubsub ⇔ subskrypcji, a każde usunięcie wyzwalało ponowne ładowanie (ze względów bezpieczeństwa). Odpowiednie zwiększenie tego progu o około 6 razy rozwiązało problem podwyższonych wskaźników usuwania z pamięci podręcznej i ponownego ładowania. Podobnie odkryliśmy, że nasze hierarchiczne ograniczniki ponownego ładowania były źle skonfigurowane pod kątem nowych wskaźników ruchu. Odpowiednie dostosowanie tych ustawień ograniczników również doprowadziło do radykalnego zmniejszenia opóźnienia przeładowania i opóźnienia reaktywności typu end-to-end (tj. czasu, jaki zajmuje aplikacji internetowej zobaczenie własnego zapisu) o około 5–10 razy.

syncable-loaders

Z drugiej strony, moduły ładujące z synchronizacją były znacznie cięższe niż oczekiwano. Każdy serwer ładowałby więcej subskrypcji na sekundę (około 1,5 raza więcej) niż serwer synchronizacji o równoważnych zasobach. W przeciwieństwie do brokerów sesji były one znacznie bardziej zależne od procesora ⁸.

Co ciekawe, niemała część obciążenia procesora została przypisana do wzrostu deoptymalizacji Truffle związanych z naszym kodem TS SCV. Najprawdopodobniej było to spowodowane tym, że każdy ładowacz z możliwością synchronizacji uzyskiwał dostęp do większej ilości naszego kodu SCV. Niezależnie od tego wymagało to niewielkiego zwiększenia rozmiaru naszej pamięci podręcznej kodu⁹. 

Rozgrzewanie procesu

Rozgrzewanie procesów do ładowania danych było w przeszłości wyzwaniem. Na szczęście w naszej nowej architekturze jest to nieco prostsze. Głównym interfejsem naszych syncable-loaderów są bezstanowe zapytania o dane – możemy je więc rozgrzać po prostu odtwarzając lub odzwierciedlając istniejący ruch sieciowy między session-brokerami a syncable-loaderami.

Z drugiej strony nadal mamy do czynienia z wieloma wyzwaniami, takimi jak w przypadku rozgrzewania serwerów synchronizacji. Przede wszystkim rozgrzanie procesów wymaga dużego obciążenia procesora, co powoduje różnego rodzaju problemy z hałaśliwymi sąsiadami (w naszym przypadku odpowiednim wskaźnikiem jest częściowe zatrzymanie, ponieważ nie osiągamy limitów dławienia k8s) podczas uruchamiania. Przyjemnym ulepszeniem, które wprowadziliśmy w tym miejscu, było zastosowanie zmiany rozmiaru poda na miejscu, aby ograniczyć zasoby syncable-loadera przy uruchomieniu, ale pozwolić mu na zwiększenie wydajności po uruchomieniu. 

Mimo to rozgrzanie każdego poda nadal zajmuje kilka minut. Patrząc na profile JFR, uważamy, że głównym wąskim gardłem jest niewystarczająca kompilacja kodu TS SCV podczas rozgrzewania i sądzimy, że jest tam więcej rezerw. Aktywnie poszukujemy sposobów na dokładniejsze rozgrzewanie odpowiednich ścieżek za pomocą bardziej ukierunkowanych metod i przekształcanie naszych interfejsów TS¹⁰ w celu lepszej kompilacji.

Nasze brokery sesji nie są odpowiedzialne za żadne ładowanie danych i w praktyce nigdy nie wymagały żadnego rozgrzewania.

Jak to pomogło?

Nasza nowa architektura znacznie zmniejszyła złożoność operacyjną, przyspieszyła wdrożenia i szybkość kodu oraz otworzyła przyszłe możliwości w zakresie szybkości i skalowania. 

Nasza nowa architektura po prostu automatycznie dostosowuje się do zmian w całkowitym ruchu odczytu bez konieczności skomplikowanego zarządzania ruchem. Każdy rodzaj operacji przesunięcia ruchu jest sprawnie obsługiwany poprzez proste ponowne uruchomienie podów. Chcesz uruchomić wszystkie sesje? Przełącz wszystkie brokery sesji. Chcesz wyczyścić nasze pamięci podręczne? Przełącz wszystkie moduły ładujące z możliwością synchronizacji. Obie operacje są bezpieczne pod względem czasu sprawności.

W przeszłości szybkość tworzenia produktów była ograniczona głównie przez wąskie gardła związane z wdrażaniem serwerów synchronizacji. W naszym nowym świecie wystarczy wdrożyć moduły ładujące synchronizację, aby wdrożyć kod produktu. Zrobiliśmy to, przenosząc moduły ładujące synchronizację do ich własnej komórki wdrożeniowej, którą staramy się wdrażać częściej (i ostatecznie wraz z kodem produktu). Wdrażanie modułów ładujących synchronizację jest już o około 40% szybsze (około 20 minut szybciej) niż wdrażanie serwerów synchronizacji (możemy bezpiecznie znacznie zwiększyć tempo), a w przyszłości planujemy jeszcze większy wzrost.

Warto zauważyć, że poprawa wydajności nie była celem tej pracy, ale warto o tym wspomnieć, biorąc pod uwagę, jak duży wpływ miała na projektowanie i wdrażanie. Opóźnienie obliczania wyników zapytań faktycznie uległo poprawie w naszym nowym systemie (prawdopodobnie dzięki lepszemu równoważeniu obciążenia / dostrajaniu / automatycznemu skalowaniu). Odpowiednio, opóźnienie początkowej subskrypcji jest zauważalnie lepsze. Z drugiej strony opóźnienie odzwierciedlania mutacji typu end-to-end (tj. czas potrzebny na rozprowadzenie zmiany do innych sesji) jest mniej więcej takie samo. Śledzenie pokazuje, że jest to prawdopodobnie spowodowane asymetrią w korzystaniu ze strumienia PubSub między brokerami sesji a modułami syncable-loader (moduły syncable-loader nie mogą obsłużyć żądania, dopóki nie będą aktualne w stosunku do wersji żądania).

Co dalej?

Nasz bieżący system jest znacznie ulepszony, ale nadal ogranicza szybkość produktu, wymagając uwzględnienia kompatybilności wstecznej / przyszłej przy każdym wydaniu. Jednak dzięki wszystkim naszym ulepszeniom w zakresie szybkości wdrożenia możliwe jest teraz wdrożenie wszystkich zmian w modelu danych jednocześnie, co eliminuje potrzebę rozważania kompatybilności wstecznej / przyszłej. Aktywnie pracujemy nad tym, aby wprowadzić to rozwiązanie w najbliższej przyszłości.

Jedną z głównych przyczyn, dla których podjęliśmy się tej pracy, były regresje wydajności, których przyczyny są trudne do ustalenia. Warto zauważyć, że jest to jeden z obszarów, w którym nie dokonaliśmy znaczących ulepszeń w wyniku tej pracy. Z dobrej strony jest to obecnie jeden z głównych problemów, którymi będziemy się zajmować w przyszłości. W przeciwieństwie do większości omawianych tutaj prac, jest to problem znacznie bardziej międzyfunkcyjny, który obejmuje domenę produktu, model danych, strukturę i kwestie infrastruktury. 

pule pracowników modułu ładującego z możliwością synchronizacji według funkcji

pule pracowników modułu ładującego z możliwością synchronizacji według funkcji

Z radością będziemy badać rozwiązania dotyczące infrastruktury platformy (np. pule procesów roboczych według funkcji), struktur platformy i narzędzi platformy (np. testowanie metodą czarnej skrzynki / białej skrzynki).


Biografia autora

Arvind Vijayakumar jest inżynierem w zespole LunaDb, gdzie pomaga budować i skalować kluczową platformę Asany do ładowania danych – krytyczną infrastrukturę, która zapewnia użytkownikom zawsze dokładne, reaktywne i błyskawiczne aktualizacje w naszych aplikacjach internetowych i API.

Podziękowania dla zespołu

Prace nad skalowaniem LunaDb to długotrwały nakład pracy zespołu, który trwał przez ostatnie kilka lat i w którym uczestniczyło wielu obecnych i byłych członków zespołu LunaDb: Brandon Zhang, Alex Matevish, Sean Wentzel, Eric Walton, Spencer Yu, Sophia Yao, George Ong, Tyler Prete, Koushik Ghosh, Natan Dubitski, Vinodh Chandra Murthy i inni


Przypisy

  1. Co istotne, stworzyliśmy go, zanim Facebook udostępnił GraphQL jako oprogramowanie open source

  2. Więcej szczegółów na temat rozgrzewania procesów można znaleźć w poprzednim poście, ale w skrócie: polegamy na umożliwieniu podom synchronizacji dowolnego przyspieszenia jako sposobu na szybkie skalowanie do lokalnych skoków ruchu

  3. Przeprowadzamy również aktualizacje z zachowaniem szczegółowości na poziomie obiektu, a nie pola (z przyczyn historycznych). Zwiększa to ilość danych związanych z ponownym ładowaniem w przypadku unieważnienia.

  4. Używamy 128-bitowego hasha Murmur, aby uniknąć kolizji.

  5. Warto zauważyć, że od bardzo dawna używamy kompresji WebSocket, co prawdopodobnie łagodzi skutki odczuwane przez klienta.

  6. Warto zauważyć, że początkowo mieliśmy również pewne problemy z kosztami ogólnymi przeskoku sieciowego. Po przeprowadzeniu analizy stwierdziliśmy, że większość kosztów ogólnych była w rzeczywistości spowodowana przez naszą sieć usług (Istio/Envoy), i wprowadziliśmy pewne ukierunkowane poprawki, aby poprawić wydajność w tym zakresie.

  7. Istotnym czynnikiem wpływającym na zachowaną pamięć było przepisanie naszego magazynu synchronizowanego po stronie serwera tak, aby zachowywał tylko hasze danych. Nie był to prosty problem i być może opublikujemy na ten temat kolejny post! Bez tego przechowywane dane klienta sprawiały, że brokery sesji zużywały znacznie więcej pamięci.

  8. Wcześniej wszystkie serwery synchronizacji działały na instancjach zoptymalizowanych pod kątem pamięci. Z drugiej strony, moduły ładujące z możliwością synchronizacji korzystają z typów węzłów o większej mocy obliczeniowej procesora, takich jak instancje mieszane.

  9. Godną uwagi cechą uruchamiania TS na GraalVM w ten sposób jest to, że wykorzystuje on nieco więcej pamięci podręcznej kodu, niż jest to typowe w bardziej standardowej aplikacji Scala/Java JVM.

  10. O ile nam wiadomo, wysoki polimorfizm sprawia, że kompilacja TS jest droższa. Rozważamy przejście na interfejsy monomorficzne i wykorzystanie raportowania specjalizacji polimorficznych