Elementy budujące aplikacje rozproszone. Drugie podejście

Ogłoszenie

Koleżanki i koledzy, w środku lata planuję opublikować kolejny cykl artykułów na temat projektowania systemów obsługi masowej: „Eksperyment VTrade” – próba stworzenia frameworka dla systemów handlowych. W cyklu omówimy teorię i praktykę budowy giełdy, aukcji i sklepu. Na końcu artykułu proponuję zagłosować na najciekawsze dla Was tematy.

Elementy budujące aplikacje rozproszone. Drugie podejście

To końcowy artykuł cyklu o rozproszonych aplikacjach reaktywnych na Erlang/Elixir. W pierwszym artykule można znaleźć teoretyczne podstawy architektury reaktywnej. Drugi artykuł ilustruje główne wzorce i mechanizmy budowy podobnych systemów.

Dziś poruszymy kwestie rozwoju bazy kodu i projektów jako całości.

Organizacja usług

W rzeczywistości, przy projektowaniu usługi często trzeba łączyć kilka wzorców interakcji w jednym kontrolerze. Na przykład, usługa users, która zajmuje się zarządzaniem profilami użytkowników projektu, powinna odpowiadać na zapytania req-resp i informować o aktualizacjach profili poprzez pub-sub. Ten przypadek jest dość prosty: za messagingiem stoi jeden kontroler, który realizuje logikę usługi i publikuje aktualizacje.

Sytuacja komplikuje się, gdy potrzebujemy wdrożyć odporną na błędy rozproszoną usługę. Załóżmy, że wymagania wobec users się zmieniły:

  1. teraz usługa musi obsługiwać zapytania na 5 węzłach klastra,
  2. mieć możliwość wykonywania zadań w tle,
  3. a także umieć dynamicznie zarządzać listami subskrypcyjnymi na aktualizacje profili.

Uwaga: Kwestia spójnego przechowywania i replikacji danych nie jest przez nas rozpatrywana. Zakładamy, że te kwestie zostały rozwiązane wcześniej i w systemie już istnieje niezawodna i skalowalna warstwa przechowywania, a przetwarzacze mają mechanizmy interakcji z nią.

Formalny opis usługi users stał się bardziej skomplikowany. Z perspektywy programisty, dzięki zastosowaniu messagingu, zmiany są minimalne. Aby spełnić pierwsze wymaganie, musimy skonfigurować balansowanie na punkcie wymiany req-resp.

Wymóg przetwarzania zadań w tle występuje często. W przypadku użytkowników mogą to być weryfikacje dokumentów użytkowników, przetwarzanie przesłanego multimedia lub synchronizacja danych z sieciami społecznościowymi. Te zadania muszą być w jakiś sposób podzielone w ramach klastra i kontrolowane. Dlatego mamy dwa warianty rozwiązania: albo skorzystać ze wzorca przydzielania zadań z poprzedniego artykułu, albo, jeśli się nie nadaje, napisać niestandardowy harmonogram zadań, który będzie w odpowiedni sposób zarządzał pulą przetwarzających.

Punkt 3 wymaga rozszerzenia wzorca pub-sub. Aby to zrealizować, po utworzeniu punktu wymiany pub-sub, musimy dodatkowo uruchomić kontroler tego punktu w naszym serwisie. W ten sposób jakby wyjmujemy logikę przetwarzania subskrypcji i wypisania z warstwy messaging do implementacji użytkowników.

W wyniku tego, dekompozycja zadania pokazała, że aby spełnić wymagania, musimy uruchomić na różnych węzłach 5 egzemplarzy serwisu i stworzyć dodatkową encję – kontroler pub-sub, odpowiedzialny za subskrycję.
Aby uruchomić 5 przetwórców, nie ma potrzeby zmiany kodu serwisu. Jedynym dodatkowym działaniem jest konfiguracja zasad równoważenia na punkcie wymiany, o czym porozmawiamy nieco później.
Pojawiła się także dodatkowa złożoność: kontroler pub-sub i niestandardowy harmonogram zadań muszą działać w pojedynczym egzemplarzu. Znowu, serwis messaging, jako fundament, powinien dostarczać mechanizm wyboru lidera.

Wybór lidera

W systemach rozproszonych wybór lidera to procedura wyznaczania jednego procesu, który odpowiada za planowanie rozproszonego przetwarzania jakiegoś obciążenia.

W systemach, które nie są skłonne do centralizacji, stosowane są uniwersalne algorytmy i algorytmy oparte na konsensie, na przykład Paxos lub Raft.
Ponieważ messaging to broker i centralny element, to wie o wszystkich kontrolerach serwisu – kandydatach na liderów. Messaging może wyznaczać lidera bez przeprowadzania głosowania.

Wszystkie serwisy po starcie i połączeniu z punktem wymiany otrzymują wiadomość systemową #'$leader'{exchange = ?EXCHANGE, pid = LeaderPid, servers = Servers}. Jeśli LeaderPid jest zgodny z pid bieżącego procesu, zostaje on wyznaczony liderem, a lista Servers zawiera wszystkie węzły i ich parametry.
W momencie pojawienia się nowego i wyłączenia działającego węzła klastra, wszystkie kontrolery usług otrzymują #'$slave_up'{exchange = ?EXCHANGE, pid = SlavePid, options = SlaveOpts} i #'$slave_down'{exchange = ?EXCHANGE, pid = SlavePid, options = SlaveOpts} odpowiednio.

Dzięki temu wszystkie komponenty są świadome wszystkich zmian, a w klastrze w każdym momencie czasu jest gwarantowany jeden lider.

Pośrednicy

Aby zrealizować złożone rozproszone procesy przetwarzania oraz w zadaniach optymalizacji już istniejącej architektury, wygodnie jest stosować pośredników.
Aby nie zmieniać kodu usług i rozwiązywać na przykład problemy z dodatkowym przetwarzaniem, trasowaniem lub logowaniem wiadomości, przed usługą można włączyć proxy, które wykona całą dodatkową pracę.

Klasycznym przykładem optymalizacji pub-sub jest rozproszona aplikacja z rdzeniem biznesowym, generującym zdarzenia aktualizacji, na przykład zmianę ceny na rynku, oraz warstwą dostępu — N serwerami, które oferują websocket API dla klientów webowych.
Jeśli podejść do tego "wprost", to obsługa klienta wygląda następująco:

  • klient nawiązuje połączenie z platformą. Po stronie serwera, który kończy ruch, uruchamiany jest proces obsługujący to połączenie.
  • w kontekście procesu obsługującego następuje autoryzacja i subskrypcja aktualizacji. Proces wywołuje metodę subskrybuj dla tematów.
  • po wygenerowaniu zdarzenia w rdzeniu jest ono dostarczane do procesów obsługujących połączenia.

Załóżmy, że mamy 50000 subskrybentów na temat "news". Subskrybenci są równomiernie rozdzieleni na 5 serwerów. W rezultacie każda aktualizacja, docierając do punktu wymiany, będzie replikowana 50000 razy: 10000 razy na każdy serwer, zgodnie z liczbą subskrybentów na nim. Nieco nieefektywna schemat, prawda?
Aby poprawić sytuację, wprowadzimy proxy, które ma tę sama nazwę co punkt wymiany. Rejestrator globalnych nazw powinien być w stanie zwracać najbliższy proces na podstawie nazwy, co jest istotne.

Uruchomimy to proxy na serwerach warstwy dostępu, a wszystkie nasze procesy obsługujące websocket API będą się do niego subskrybować, a nie do oryginalnego punktu wymiany pub-sub w rdzeniu. Proxy subskrybuje rdzeń tylko w przypadku unikalnej subskrypcji i replikowana otrzymaną wiadomość dla wszystkich swoich subskrybentów.
W rezultacie między rdzeniem a serwerami dostępu zostanie przesłanych 5 wiadomości, zamiast 50000.

Trasowanie i balansowanie

Req-Resp

W obecnej realizacji messaging istnieje 7 strategii rozdzielania zapytań:

  • default. Żądanie jest przesyłane do wszystkich kontrolerów.
  • round-robin. Wykonywana jest iteracja i cykliczne rozdzielanie żądań między kontrolerami.
  • konsensus. Kontrolery obsługujące usługę dzielą się na lidera i podwładnych. Żądania są przesyłane tylko do lidera.
  • konsensus & round-robin. W grupie jest lider, ale żądania są rozdzielane między wszystkich członków.
  • sticky. Obliczany jest hash, który jest przypisany do konkretnego przetwarzacza. Kolejne żądania z tym samym sygnaturą trafiają do tego samego przetwarzacza.
  • sticky-fun. Podczas inicjalizacji punktu wymiany dodatkowo przekazywana jest funkcja obliczania hash dla sticky równoważenia.
  • fun. Analogicznie do sticky-fun, ale dodatkowo można przekierować, odrzucić lub wstępnie przetworzyć go.

Strategia rozdzielania jest określana podczas inicjalizacji punktu wymiany.

Oprócz równoważenia, messaging pozwala na oznaczanie encji. Rozważmy rodzaje tagów w systemie:

  • Tag połączenia. Pozwala zrozumieć, przez jakie połączenie przychodzą zdarzenia. Używany, gdy proces kontrolera łączy się z jednym punktem wymiany, ale z różnymi kluczami routingu.
  • Tag usługi. Pozwala dla jednej usługi grupować przetwarzacze i rozszerzać możliwości routingu i równoważenia. Dla wzorca req-resp routing jest liniowy. Wysyłamy żądanie do punktu wymiany, a ten następnie przekazuje je do usługi. Ale jeśli musimy podzielić przetwarzacze na logiczne grupy, podział odbywa się za pomocą tagów. Po wskazaniu taga, żądanie zostanie skierowane do konkretnej grupy kontrolerów.
  • Tag żądania. Pozwala odróżniać odpowiedzi. Ponieważ nasz system jest asynchroniczny, przy przetwarzaniu odpowiedzi usługi trzeba mieć możliwość wskazania RequestTag podczas wysyłania żądania. Na jego podstawie możemy zrozumieć, która odpowiedź na jakie żądanie do nas przyszła.

Pub-sub

Dla pub-sub jest nieco prościej. Mamy punkt wymiany, na który publikowane są wiadomości. Punkt wymiany rozdziela wiadomości między subskrybentów, którzy zapisali się na odpowiednie klucze routingu (można powiedzieć, że to odpowiednik tematów).

Skalowalność i odporność na awarie

Skalowalność systemu w całości zależy od stopnia skalowalności warstw i komponentów systemu:

  • Usługi skalują się poprzez dodawanie dodatkowych węzłów z obsługującymi ten serwis do klastra. W trakcie eksploatacji możliwe jest wybranie optymalnej polityki równoważenia obciążenia.
  • Sam serwis messaging w ramach oddzielnego klastra w ogólnym przypadku skalować się może albo poprzez przeniesienie szczególnie obciążonych punktów wymiany na oddzielne węzły klastra, albo poprzez dodawanie procesów proxy do szczególnie obciążonych stref klastra.
  • Skalowalność całego systemu jako cecha zależy od elastyczności architektury i możliwości scalania poszczególnych klastrów w jedną wspólną jednostkę logiczną.

Sukces projektu często zależy od prostoty i szybkości skalowania. Messaging w obecnym wydaniu rośnie razem z aplikacją. Nawet jeśli brakuje nam klastra z 50-60 maszyn, można zastosować federację. Niestety, temat federacji wykracza poza zakres tego artykułu.

Rezerwa

Podczas omawiania równoważenia obciążenia już rozmawialiśmy o rezerwowaniu kontrolerów usług. Jednak messaging również musi być zarezerwowany. W przypadku awarii węzła lub maszyny, messaging musi automatycznie się odbudować, i to w jak najkrótszym czasie.

W swoich projektach wykorzystuję dodatkowe węzły, które przejmują obciążenie w przypadku awarii. W Erlangu istnieje standardowa implementacja trybu rozproszonego dla aplikacji OTP. Tryb rozproszony dokonuje odbudowy w przypadku awarii poprzez uruchomienie usuniętej aplikacji na innym wcześniej uruchomionym węźle. Proces jest przezroczysty, po awarii aplikacja automatycznie przechodzi na węzeł failover. Można przeczytać o tej funkcjonalności więcej. tutaj.

Wydajność

Spróbujmy chociaż w przybliżeniu porównać wydajność rabbitmq i naszego dostosowanego messaging.
Znalazłem oficjalne wyniki testowania rabbitmq przez zespół openstack.

W punkcie 6.14.1.2.1.2.2 oryginalnego dokumentu przedstawiono wynik RPC CAST:
Elementy budujące aplikacje rozproszone. Drugie podejście

Wstępnie nie będziemy wprowadzać żadnych dodatkowych ustawień do jądra OS lub erlang VM. Warunki do testowania:

  • erl opts: +A1 +sbtu.
  • Test w ramach jednego węzła erlang uruchamiany jest na laptopie ze starym i7 w mobilnej wersji.
  • Testy klastrowe odbywają się na serwerach z siecią 10G.
  • Kod działa w kontenerach docker. Sieć w trybie NAT.

Kod testu:

req_resp_bench(_) ->
  W = perftest:comprehensive(10000,
    fun() ->
      messaging:request(?EXCHANGE, default, ping, self()),
      receive
        #'$msg'{message = pong} -> ok
      after 5000 ->
        throw(timeout)
      end
    end
  ),
  true = lists:any(fun(E) -> E >= 30000 end, W),
  ok.

Scenariusz 1: Test uruchamiany jest na laptopie z nieco starszym procesorem i7 w wersji mobilnej. Test, messaging i serwis działają na jednym węźle w jednym kontenerze dockerowym:

Sekwencyjne 10000 cykli w ~0 sekund (26987 cykli/s)
Sekwencyjne 20000 cykli w ~1 sekund (26915 cykli/s)
Sekwencyjne 100000 cykli w ~4 sekund (26957 cykli/s)
Równoległe 2 100000 cykli w ~2 sekund (44240 cykli/s)
Równoległe 4 100000 cykli w ~2 sekund (53459 cykli/s)
Równoległe 10 100000 cykli w ~2 sekund (52283 cykli/s)
Równoległe 100 100000 cykli w ~3 sekund (49317 cykli/s)

Scenariusz 2: 3 węzły uruchomione na różnych maszynach pod dockerem (NAT).

Sekwencyjne 10000 cykli w ~1 sekund (8684 cykli/s)
Sekwencyjne 20000 cykli w ~2 sekund (8424 cykli/s)
Sekwencyjne 100000 cykli w ~12 sekund (8655 cykli/s)
Równoległe 2 100000 cykli w ~7 sekund (15160 cykli/s)
Równoległe 4 100000 cykli w ~5 sekund (19133 cykli/s)
Równoległe 10 100000 cykli w ~4 sekund (24399 cykli/s)
Równoległe 100 100000 cykli w ~3 sekund (34517 cykli/s)

W każdym przypadku wykorzystanie CPU nie przekraczało 250%

Podsumowanie

Mam nadzieję, że ten cykl nie wygląda jak zrzut myśli i moje doświadczenie przyniesie realne korzyści zarówno badaczom systemów rozproszonych, jak i praktykom, którzy znajdują się na samym początku drogi budowy architektur rozproszonych dla swoich systemów biznesowych i z zainteresowaniem patrzą na Erlang/Elixir, ale wątpią, czy warto...

Zdjęcie @chuttersnap

Tylko zarejestrowani użytkownicy mogą brać udział w ankiecie. Zaloguj się, proszę.

Jakie tematy powinienem szczegółowo omówić w cyklu „Eksperyment VTrade”?

  • Teoria: Rynki, zlecenia i czas ich realizacji: DAY, GTD, GTC, IOC, FOK, MOO, MOC, LOO, LOC

  • Księga zleceń. Teoria i praktyka realizacji księgi z grupowaniami

  • Wizualizacja handlu: Tick, słupki, rozdzielczości. Jak przechowywać i jak łączyć

  • Back-office. Planowanie i rozwój. Kontrola pracowników i badanie incydentów

  • API. Zrozumienie, jakie interfejsy są potrzebne i jak je zrealizować

  • Przechowywanie informacji: PostgreSQL, Timescale, Tarantool w systemach handlowych

  • Reaktywność w systemach handlowych

  • Inne. Napiszę w komentarzach

W głosowaniu wzięło udział 6 użytkowników. 4 użytkowników wstrzymało się.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster