Wygodne wzory architektoniczne

Hej Habra!

W świetle bieżących wydarzeń związanych z koronawirusem wiele usług internetowych zaczęło być obciążonych wzmożonym obciążeniem. Na przykład, Jedna z brytyjskich sieci handlowych po prostu zawiesiła swoją stronę do składania zamówień online., bo nie było wystarczającej pojemności. Nie zawsze możliwe jest przyspieszenie serwera poprzez proste dodanie mocniejszego sprzętu, ale żądania klientów muszą zostać przetworzone (w przeciwnym razie trafią do konkurencji).

W tym artykule krótko opowiem o popularnych praktykach, które pozwolą Ci stworzyć szybką i odporną na awarie usługę. Jednak z możliwych schematów rozwoju wybrałem tylko te, które są obecnie łatwy w użyciu. Dla każdego elementu masz albo gotowe biblioteki, albo masz możliwość rozwiązania problemu za pomocą platformy chmurowej.

Skalowanie poziome

Najprostszy i najbardziej znany punkt. Konwencjonalnie najpopularniejszymi dwoma schematami rozkładu obciążenia są skalowanie poziome i pionowe. W pierwszym przypadku pozwalasz usługom działać równolegle, rozkładając w ten sposób obciążenie między nimi. W drugim zamawiasz mocniejsze serwery lub optymalizujesz kod.

Na przykład wezmę abstrakcyjne przechowywanie plików w chmurze, czyli jakiś analog OwnCloud, OneDrive i tak dalej.

Standardowy obraz takiego obwodu znajduje się poniżej, ale pokazuje on jedynie złożoność układu. W końcu musimy jakoś zsynchronizować usługi. Co się stanie, jeśli użytkownik zapisze plik na tablecie, a następnie będzie chciał go wyświetlić na telefonie?

Wygodne wzory architektoniczne
Różnica między podejściami: w skalowaniu pionowym jesteśmy gotowi zwiększyć moc węzłów, a w skalowaniu poziomym jesteśmy gotowi dodać nowe węzły w celu rozłożenia obciążenia.

CQRS

Podział odpowiedzialności za zapytania dotyczące poleceń Dość ważny wzorzec, ponieważ pozwala różnym klientom nie tylko łączyć się z różnymi usługami, ale także odbierać te same strumienie zdarzeń. Jego zalety nie są tak oczywiste w przypadku prostej aplikacji, ale są niezwykle ważne (i proste) w przypadku obciążonej usługi. Jej istota: przepływy danych przychodzących i wychodzących nie powinny się przecinać. Oznacza to, że nie możesz wysłać żądania i oczekiwać odpowiedzi; zamiast tego wysyłasz żądanie do usługi A, ale otrzymujesz odpowiedź z usługi B.

Pierwszą zaletą tego podejścia jest możliwość przerwania połączenia (w szerokim tego słowa znaczeniu) podczas wykonywania długiego żądania. Weźmy na przykład mniej więcej standardową sekwencję:

  1. Klient wysłał żądanie do serwera.
  2. Serwer rozpoczął długi czas przetwarzania.
  3. Serwer odpowiedział klientowi z wynikiem.

Wyobraźmy sobie, że w punkcie 2 połączenie zostało zerwane (lub sieć została ponownie połączona lub użytkownik przeszedł na inną stronę, zrywając połączenie). W takim przypadku serwer będzie miał trudności z przesłaniem użytkownikowi odpowiedzi z informacją o tym, co dokładnie zostało przetworzone. Używając CQRS, sekwencja będzie nieco inna:

  1. Klient zasubskrybował aktualizacje.
  2. Klient wysłał żądanie do serwera.
  3. Serwer odpowiedział „żądanie zaakceptowane”.
  4. Serwer odpowiedział wynikiem poprzez kanał z punktu „1”.

Wygodne wzory architektoniczne

Jak widać schemat jest nieco bardziej skomplikowany. Co więcej, brakuje tu intuicyjnego podejścia typu żądanie-odpowiedź. Jak jednak widać, przerwa w połączeniu podczas przetwarzania żądania nie spowoduje błędu. Co więcej, jeśli faktycznie użytkownik łączy się z usługą z kilku urządzeń (np. z telefonu komórkowego i tabletu), możesz mieć pewność, że odpowiedź przyjdzie na oba urządzenia.

Co ciekawe, kod przetwarzania wiadomości przychodzących staje się taki sam (nie w 100%) zarówno dla zdarzeń, na które miał wpływ sam klient, jak i dla innych zdarzeń, w tym od innych klientów.

Jednak w rzeczywistości otrzymujemy dodatkowy bonus ze względu na to, że przepływ jednokierunkowy można obsłużyć w stylu funkcjonalnym (za pomocą RX i podobnych). A to już poważny plus, ponieważ w zasadzie aplikację można uczynić całkowicie reaktywną, a także stosując podejście funkcjonalne. W przypadku grubych programów może to znacznie zaoszczędzić zasoby na rozwój i wsparcie.

Jeśli połączymy to podejście ze skalowaniem poziomym, to jako bonus otrzymamy możliwość wysyłania żądań do jednego serwera i otrzymywania odpowiedzi z innego. Dzięki temu klient może wybrać dogodną dla siebie usługę, a znajdujący się w nim system nadal będzie w stanie poprawnie przetwarzać zdarzenia.

Pozyskiwanie zdarzeń

Jak wiadomo, jedną z głównych cech systemu rozproszonego jest brak wspólnego czasu, wspólnej sekcji krytycznej. Dla jednego procesu możesz przeprowadzić synchronizację (na tych samych muteksach), w ramach której masz pewność, że nikt inny nie wykonuje tego kodu. Jest to jednak niebezpieczne dla systemu rozproszonego, ponieważ będzie wymagało narzutu, a także zabije całe piękno skalowania - wszystkie komponenty nadal będą na to czekać.

Dostajemy stąd ważny fakt – szybkiego systemu rozproszonego nie da się zsynchronizować, bo wtedy obniżymy wydajność. Z drugiej strony często potrzebujemy pewnej spójności pomiędzy komponentami. W tym celu możesz zastosować podejście z ostateczna spójność, gdzie gwarantuje się, że jeśli przez jakiś czas od ostatniej aktualizacji nie nastąpi żadna zmiana danych („ostatecznie”), wszystkie zapytania zwrócą ostatnią zaktualizowaną wartość.

Ważne jest, aby zrozumieć, że w przypadku klasycznych baz danych jest on dość często używany mocna konsystencja, gdzie każdy węzeł ma te same informacje (jest to często osiągane w przypadku, gdy transakcję uznaje się za zawartą dopiero po odpowiedzi drugiego serwera). Są tu pewne złagodzenia ze względu na poziomy izolacji, ale ogólne założenie pozostaje takie samo – można żyć w całkowicie zharmonizowanym świecie.

Wróćmy jednak do pierwotnego zadania. Jeśli część systemu można zbudować za pomocą ostateczna spójność, to możemy skonstruować następujący diagram.

Wygodne wzory architektoniczne

Ważne cechy tego podejścia:

  • Każde przychodzące żądanie umieszczane jest w jednej kolejce.
  • Usługa przetwarzając żądanie może także umieszczać zadania w innych kolejkach.
  • Każde przychodzące zdarzenie posiada identyfikator (który jest niezbędny do deduplikacji).
  • Kolejka ideologicznie działa według schematu „tylko dołącz”. Nie można usuwać z niego elementów ani zmieniać ich układu.
  • Kolejka działa według schematu FIFO (przepraszam za tautologię). Jeśli chcesz wykonać wykonanie równoległe, to na jednym etapie powinieneś przenieść obiekty do różnych kolejek.

Przypomnę, że rozważamy przypadek przechowywania plików online. W tym przypadku system będzie wyglądał mniej więcej tak:

Wygodne wzory architektoniczne

Ważne jest, aby usługi na schemacie nie koniecznie oznaczały oddzielny serwer. Nawet proces może być taki sam. Ważna jest jeszcze jedna rzecz: ideologicznie te rzeczy są oddzielone w taki sposób, że można łatwo zastosować skalowanie poziome.

A dla dwóch użytkowników schemat będzie wyglądał następująco (usługi przeznaczone dla różnych użytkowników są oznaczone różnymi kolorami):

Wygodne wzory architektoniczne

Bonusy z takiej kombinacji:

  • Usługi przetwarzania informacji są rozdzielone. Kolejki też są rozdzielone. Jeśli potrzebujemy zwiększyć przepustowość systemu, wystarczy uruchomić więcej usług na większej liczbie serwerów.
  • Gdy otrzymamy informację od użytkownika, nie musimy czekać, aż dane zostaną całkowicie zapisane. Wręcz przeciwnie, wystarczy odpowiedzieć „ok” i stopniowo przystąpić do pracy. Jednocześnie kolejka wygładza szczyty, gdyż dodanie nowego obiektu następuje szybko, a użytkownik nie musi czekać na pełne przejście całego cyklu.
  • Jako przykład dodałem usługę deduplikacji, która próbuje scalić identyczne pliki. Jeśli w 1% przypadków będzie działać przez długi czas, klient prawie tego nie zauważy (patrz wyżej), co jest dużym plusem, ponieważ nie wymagamy już od nas XNUMX% szybkości i niezawodności.

Jednak wady są natychmiast widoczne:

  • Nasz system stracił swoją ścisłą spójność. Oznacza to, że jeśli np. subskrybujesz różne usługi, to teoretycznie możesz uzyskać inny stan (ponieważ jedna z usług może nie mieć czasu na otrzymanie powiadomienia z kolejki wewnętrznej). Kolejną konsekwencją jest brak wspólnego czasu w systemie. Oznacza to, że nie da się na przykład posortować wszystkich zdarzeń po prostu według czasu przybycia, ponieważ zegary pomiędzy serwerami mogą nie być synchroniczne (co więcej, ten sam czas na dwóch serwerach to utopia).
  • Żadnych zdarzeń nie można teraz po prostu wycofać (jak można to zrobić w przypadku bazy danych). Zamiast tego musisz dodać nowe wydarzenie − zdarzenie kompensacyjne, co spowoduje zmianę ostatniego stanu na wymagany. Jako przykład z podobnego obszaru: bez przepisywania historii (co w niektórych przypadkach jest złe) nie możesz cofnąć zatwierdzenia w git, ale możesz zrobić specjalny zatwierdzenie wycofania, co w zasadzie po prostu zwraca stary stan. Jednak zarówno błędne zatwierdzenie, jak i wycofanie zmian pozostaną w historii.
  • Schemat danych może zmieniać się z wydania na wydanie, ale starych zdarzeń nie będzie już można zaktualizować do nowego standardu (ponieważ w zasadzie zdarzeń nie można zmienić).

Jak widać, Event Sourcing dobrze współpracuje z CQRS. Co więcej, wdrożenie systemu z wydajnymi i wygodnymi kolejkami, ale bez rozdzielania przepływów danych, jest już samo w sobie trudne, ponieważ trzeba będzie dodać punkty synchronizacji, które zneutralizują cały pozytywny efekt kolejek. Stosując oba podejścia jednocześnie, konieczne jest nieznaczne dostosowanie kodu programu. W naszym przypadku przy wysyłaniu pliku na serwer pojawia się tylko odpowiedź „ok”, co oznacza jedynie, że „operacja dodawania pliku została zapisana”. Formalnie nie oznacza to, że dane są już dostępne na innych urządzeniach (np. usługa deduplikacji może odbudować indeks). Jednak po pewnym czasie klient otrzyma powiadomienie w stylu „plik X został zapisany”.

W rezultacie:

  • Rośnie liczba statusów wysyłania plików: zamiast klasycznego „plik wysłany” otrzymujemy dwa: „plik został dodany do kolejki na serwerze” oraz „plik został zapisany w pamięci”. To drugie oznacza, że ​​inne urządzenia mogą już rozpocząć odbieranie pliku (skorygowane o to, że kolejki działają z różną szybkością).
  • Ze względu na to, że informacje o zgłoszeniu docierają obecnie różnymi kanałami, musimy znaleźć rozwiązania pozwalające uzyskać status przetwarzania pliku. W rezultacie: w przeciwieństwie do klasycznej reakcji żądanie-odpowiedź, podczas przetwarzania pliku można uruchomić ponownie klienta, ale sam status tego przetwarzania będzie prawidłowy. Co więcej, ten element działa w zasadzie od razu po wyjęciu z pudełka. W rezultacie: jesteśmy teraz bardziej tolerancyjni wobec niepowodzeń.

Sharding

Jak opisano powyżej, systemom pozyskiwania zdarzeń brakuje ścisłej spójności. Oznacza to, że możemy korzystać z kilku magazynów bez synchronizacji między nimi. Podchodząc do naszego problemu możemy:

  • Oddziel pliki według typu. Można na przykład zdekodować zdjęcia/filmy i wybrać bardziej wydajny format.
  • Oddzielne konta według kraju. Ze względu na wiele przepisów może to być wymagane, ale ten schemat architektury automatycznie zapewnia taką możliwość

Wygodne wzory architektoniczne

Jeśli chcesz przenieść dane z jednego magazynu na drugi, standardowe środki już nie wystarczą. Niestety w tym przypadku trzeba zatrzymać kolejkę, wykonać migrację i dopiero ją rozpocząć. W ogólnym przypadku danych nie można przesyłać „w locie”, jednak jeśli kolejka zdarzeń jest przechowywana w całości i dysponujemy migawkami poprzednich stanów przechowywania, wówczas możemy odtworzyć zdarzenia w następujący sposób:

  • W Źródle zdarzenia każde zdarzenie ma swój własny identyfikator (najlepiej niemalejący). Oznacza to, że możemy dodać do magazynu pole - identyfikator ostatnio przetworzonego elementu.
  • Duplikujemy kolejkę tak, aby wszystkie zdarzenia mogły zostać przetworzone dla kilku niezależnych magazynów (pierwszy to ten, w którym dane są już zapisane, a drugi jest nowy, ale wciąż pusty). Druga kolejka oczywiście nie jest jeszcze przetwarzana.
  • Uruchamiamy drugą kolejkę (czyli rozpoczynamy odtwarzanie wydarzeń).
  • Kiedy nowa kolejka jest stosunkowo pusta (czyli średnia różnica czasu między dodaniem elementu a jego pobraniem jest akceptowalna), można przystąpić do przełączania czytników na nowy magazyn.

Jak widać nie mieliśmy i nadal nie mamy ścisłej spójności w naszym systemie. Istnieje jedynie ostateczna spójność, czyli gwarancja, że ​​zdarzenia są przetwarzane w tej samej kolejności (ale prawdopodobnie z różnymi opóźnieniami). A dzięki temu możemy stosunkowo łatwo przenieść dane bez zatrzymywania systemu na drugi koniec globu.

Kontynuując zatem nasz przykład dotyczący przechowywania plików online, taka architektura daje nam już szereg korzyści:

  • Możemy w dynamiczny sposób przybliżać obiekty do użytkowników. W ten sposób możesz poprawić jakość obsługi.
  • Możemy przechowywać niektóre dane w firmach. Na przykład użytkownicy korporacyjni często wymagają przechowywania swoich danych w kontrolowanych centrach danych (aby uniknąć wycieków danych). Dzięki shardingowi możemy to łatwo obsłużyć. A zadanie jest jeszcze łatwiejsze, jeśli klient posiada kompatybilną chmurę (np. Samoobsługowe rozwiązanie Azure).
  • A najważniejsze jest to, że nie musimy tego robić. Przecież na początek bylibyśmy całkiem zadowoleni z jednego miejsca na wszystkie konta (żeby szybko zacząć działać). A kluczową cechą tego systemu jest to, że choć można go rozbudowywać, to na początkowym etapie jest on dość prosty. Po prostu nie musisz od razu pisać kodu, który będzie działał z milionem oddzielnych niezależnych kolejek itp. W razie potrzeby można to zrobić w przyszłości.

Hosting treści statycznych

Ten punkt może wydawać się dość oczywisty, ale nadal jest niezbędny w przypadku mniej lub bardziej standardowo załadowanej aplikacji. Jej istota jest prosta: cała zawartość statyczna jest dystrybuowana nie z tego samego serwera, na którym znajduje się aplikacja, ale ze specjalnych, dedykowanych specjalnie do tego zadania. Dzięki temu operacje te wykonywane są szybciej (warunkowy nginx udostępnia pliki szybciej i taniej niż serwer Java). Plus architektura CDN (Content Delivery Network) pozwala nam lokalizować nasze pliki bliżej użytkowników końcowych, co pozytywnie wpływa na wygodę pracy z serwisem.

Najprostszym i najbardziej standardowym przykładem treści statycznych jest zestaw skryptów i obrazów dla strony internetowej. Z nimi wszystko jest proste – są znane z góry, następnie archiwum przesyłane jest na serwery CDN, skąd są dystrybuowane do użytkowników końcowych.

Jednak w rzeczywistości w przypadku treści statycznych można zastosować podejście nieco podobne do architektury lambda. Wróćmy do naszego zadania (przechowywanie plików online), w którym musimy rozesłać pliki użytkownikom. Najprostszym rozwiązaniem jest stworzenie usługi, która na każde żądanie użytkownika dokona wszelkich niezbędnych kontroli (autoryzacja itp.), a następnie pobierze plik bezpośrednio z naszego magazynu. Główną wadą tego podejścia jest to, że zawartość statyczna (a plik z określoną wersją jest w rzeczywistości treścią statyczną) jest dystrybuowana przez ten sam serwer, który zawiera logikę biznesową. Zamiast tego możesz wykonać następujący diagram:

  • Serwer udostępnia adres URL pobierania. Może mieć postać identyfikator_pliku + klucz, gdzie kluczem jest mini-cyfrowy podpis, który daje prawo dostępu do zasobu przez kolejne XNUMX godziny.
  • Plik jest dystrybuowany przez prosty nginx z następującymi opcjami:
    • Buforowanie treści. Ponieważ usługa ta może znajdować się na osobnym serwerze, zostawiliśmy sobie rezerwę na przyszłość z możliwością przechowywania na dysku wszystkich ostatnio pobranych plików.
    • Sprawdzanie klucza w momencie tworzenia połączenia
  • Opcjonalnie: przetwarzanie treści strumieniowych. Przykładowo, jeśli skompresujemy wszystkie pliki w serwisie, to rozpakowanie możemy wykonać bezpośrednio w tym module. W rezultacie: operacje IO są wykonywane tam, gdzie ich miejsce. Archiwizator w Javie z łatwością przydzieli dużo dodatkowej pamięci, ale przepisanie usługi z logiką biznesową na warunki warunkowe Rust/C++ może również być nieskuteczne. W naszym przypadku wykorzystywane są różne procesy (a nawet usługi), dzięki czemu możemy dość skutecznie oddzielić logikę biznesową od operacji IO.

Wygodne wzory architektoniczne

Ten schemat nie jest bardzo podobny do dystrybucji zawartości statycznej (ponieważ nie przesyłamy gdzieś całego pakietu statycznego), ale w rzeczywistości to podejście dotyczy właśnie dystrybucji niezmiennych danych. Co więcej, schemat ten można uogólnić na inne przypadki, w których treść nie jest po prostu statyczna, ale może być reprezentowana jako zbiór niezmiennych i nieusuwalnych bloków (chociaż można je dodać).

Inny przykład (dla wzmocnienia): jeśli pracowałeś z Jenkins/TeamCity, to wiesz, że oba rozwiązania są napisane w Javie. Obydwa są procesami Java, które obsługują zarówno orkiestrację kompilacji, jak i zarządzanie treścią. W szczególności obaj mają zadania takie jak „przesłanie pliku/folderu z serwera”. Przykładowo: wydawanie artefaktów, przesyłanie kodu źródłowego (gdy agent nie pobiera kodu bezpośrednio z repozytorium, ale robi to za niego serwer), dostęp do logów. Wszystkie te zadania różnią się obciążeniem we/wy. Czyli okazuje się, że serwer odpowiedzialny za złożoną logikę biznesową musi jednocześnie być w stanie skutecznie przepuszczać przez siebie duże strumienie danych. A co najciekawsze, taką operację można oddelegować do tego samego nginxa według dokładnie tego samego schematu (z tą różnicą, że do żądania należy dodać klucz danych).

Jeśli jednak wrócimy do naszego systemu, otrzymamy podobny diagram:

Wygodne wzory architektoniczne

Jak widać, system stał się radykalnie bardziej złożony. Teraz nie jest to już tylko miniproces przechowujący pliki lokalnie. Teraz nie jest wymagane najprostsze wsparcie, kontrola wersji API itp. Dlatego po narysowaniu wszystkich diagramów najlepiej jest szczegółowo ocenić, czy rozszerzalność jest warta kosztów. Jeśli jednak chcemy mieć możliwość rozbudowy systemu (w tym pracy z jeszcze większą liczbą użytkowników), wówczas będziemy musieli sięgnąć po podobne rozwiązania. Ale w rezultacie system jest architektonicznie gotowy na zwiększone obciążenie (prawie każdy komponent można sklonować w celu skalowania poziomego). System można aktualizować bez zatrzymywania go (po prostu niektóre operacje zostaną nieco spowolnione).

Jak powiedziałem na samym początku, teraz wiele usług internetowych zaczęło otrzymywać zwiększone obciążenie. A niektóre z nich po prostu zaczęły przestać działać poprawnie. Tak naprawdę systemy zawiodły dokładnie w momencie, gdy firma miała zarabiać. Oznacza to, że zamiast odroczyć dostawę i zamiast sugerować klientom „zaplanuj dostawę na nadchodzące miesiące”, system po prostu powiedział „idź do konkurencji”. W rzeczywistości taka jest cena niskiej produktywności: straty wystąpią dokładnie wtedy, gdy zyski będą największe.

wniosek

Wszystkie te podejścia były znane już wcześniej. Ten sam VK od dawna wykorzystuje ideę statycznego hostingu treści do wyświetlania obrazów. Wiele gier online korzysta ze schematu Shardingu, aby podzielić graczy na regiony lub oddzielić lokalizacje gry (jeśli sam świat jest jednym). Podejście Event Sourcing jest aktywnie wykorzystywane w wiadomościach e-mail. Większość aplikacji handlowych, w których dane są stale odbierane, jest w rzeczywistości zbudowana w oparciu o podejście CQRS, aby móc filtrować otrzymane dane. Cóż, skalowanie poziome jest stosowane w wielu usługach już od dłuższego czasu.

Jednak co najważniejsze, wszystkie te wzorce stały się bardzo łatwe do zastosowania w nowoczesnych zastosowaniach (oczywiście jeśli są odpowiednie). Chmury oferują od razu Sharding i skalowanie poziome, co jest znacznie prostsze niż samodzielne zamawianie różnych serwerów dedykowanych w różnych centrach danych. CQRS stał się znacznie łatwiejszy, choćby dzięki rozwojowi bibliotek takich jak RX. Około 10 lat temu rzadka witryna internetowa mogła to wspierać. Event Sourcing jest również niezwykle łatwy w konfiguracji dzięki gotowym kontenerom z Apache Kafka. 10 lat temu byłaby to innowacja, teraz jest to codzienność. Podobnie jest w przypadku Static Content Hosting: dzięki wygodniejszym technologiom (m.in. szczegółowej dokumentacji i dużej bazie odpowiedzi) to podejście stało się jeszcze prostsze.

Dzięki temu realizacja szeregu dość skomplikowanych wzorów architektonicznych stała się obecnie znacznie prostsza, dlatego warto przyjrzeć się temu wcześniej. Jeśli w dziesięcioletniej aplikacji porzucono jedno z powyższych rozwiązań ze względu na wysoki koszt wdrożenia i obsługi, teraz w nowej aplikacji lub po refaktoryzacji możesz stworzyć usługę, która będzie już zarówno rozszerzalna architektonicznie ( pod względem wydajności) i gotowe na nowe żądania klientów (np. dotyczące lokalizacji danych osobowych).

I co najważniejsze: nie używaj tych podejść, jeśli masz prostą aplikację. Tak, są piękne i ciekawe, ale w przypadku witryny, w której szczyt odwiedza 100 osób, często można sobie poradzić z klasycznym monolitem (przynajmniej z zewnątrz wszystko w środku można podzielić na moduły itp.).

Źródło: www.habr.com

Dodaj komentarz