Cześć, Habr!
W świetle bieżących wydarzeń związanych z koronawirusem szereg usług internetowych zaczęło otrzymywać zwiększone obciążenie. Na przykład, ponieważ nie wystarczyło mocy obliczeniowej. I nie zawsze można przyspieszyć serwer, po prostu dodając mocniejszy sprzęt, jednakże zapytania klientów należy przetwarzać (w przeciwnym razie odejdą do konkurencji).
W tym artykule krótko opiszę popularne praktyki, które pozwolą stworzyć szybki i odporne na awarie usługi. Wybrałem jednak tylko te schematy rozwoju, które teraz są łatwe do wykorzystania.Dla każdego punktu już masz gotowe biblioteki, lub masz możliwość rozwiązania problemu za pomocą platformy chmurowej.
Skalowanie horyzontalne
Najprostszy i najbardziej znany punkt. Ogólnie rzecz biorąc, najczęściej występują dwa schematy rozkładu obciążenia — skalowanie horyzontalne i wertykalne. pozwalasz usługom działać równolegle, w ten sposób rozkładając obciążenie między nimi. zamawiasz mocniejsze serwery lub optymalizujesz kod.
Na przykład weźmiemy abstrakcyjne chmurowe miejsce na pliki, czyli jakiś odpowiednik OwnCloud, OneDrive itd.
Standardowy obrazek takiego schematu znajduje się poniżej, jednakże tylko pokazuje złożoność systemu. Musimy jakoś zsynchronizować usługi. Co się stanie, jeśli użytkownik zapisze plik na tablecie, a następnie chce go zobaczyć na telefonie?

Różnica między podejściami: przy skalowaniu wertykalnym jesteśmy gotowi zwiększać moc węzłów, podczas gdy przy skalowaniu horyzontalnym — dodajemy nowe węzły, aby rozłożyć obciążenie.
CQRS
to dość ważny wzorzec, ponieważ umożliwia różnym klientom nie tylko łączenie się z różnymi usługami, ale także uzyskiwanie tych samych strumieni zdarzeń. Jego korzyści nie są tak oczywiste dla prostych aplikacji, jednak jest on niezmiernie ważny (i prosty) dla obciążonego serwisu. Jego istota: przychodzące i wychodzące strumienie danych nie powinny się krzyżować. To znaczy, że nie możesz wysłać zapytania i oczekiwać odpowiedzi, zamiast tego wysyłasz zapytanie do usługi A, jednak otrzymujesz odpowiedź w usłudze B.
Pierwszą zaletą tego podejścia jest możliwość przerwania połączenia (w najszerszym tego słowa znaczeniu) w trakcie realizacji długiego zapytania. Dla przykładu weźmy bardziej standardową sekwencję:
- Klient wysłał zapytanie do serwera.
- Serwer rozpoczął długą obróbkę.
- Serwer odpowiedział klientowi wynikiem.
Wyobraźmy sobie, że w punkcie 2 doszło do przerwania połączenia (czy to sieć została przełączona, czy użytkownik przeszedł na inną stronę, przerywając połączenie). W takim przypadku serwerowi będzie trudno wysłać odpowiedź do użytkownika z informacją, co dokładnie zostało przetworzone. Stosując CQRS, sekwencja będzie nieco inna:
- Klient zapisał się na aktualizacje.
- Klient wysłał zapytanie do serwera.
- Serwer odpowiedział 'zapytanie przyjęte'.
- Serwer odpowiedział wynikiem za pośrednictwem kanału z punktu '1'.

Jak widać, schemat jest nieco bardziej skomplikowany. Co więcej, intuicyjne podejście request-response tutaj nie występuje. Jednak jak widać, przerwanie połączenia podczas przetwarzania zapytania nie prowadzi do błędu. Co więcej, jeśli użytkownik rzeczywiście podłączył się do usługi z kilku urządzeń (na przykład z telefonu komórkowego i tabletu), można zorganizować tak, aby odpowiedź przychodziła na oba urządzenia.
Co ciekawe, kod przetwarzania przychodzących wiadomości staje się w pewnym sensie jednolity (nie w 100%) zarówno dla zdarzeń, na które wpływa sam klient, jak i dla pozostałych zdarzeń, w tym tych od innych klientów.
Jednak w rzeczywistości zyskujemy dodatkowe korzyści związane z tym, że jednostronny strumień można przetwarzać w stylu funkcyjnym (używając RX i analogii). A to już jest poważna zaleta, ponieważ zasadniczo aplikację można uczynić w pełni reaktywną, z zastosowaniem podejścia funkcyjnego. Dla dużych programów może to znacznie zaoszczędzić zasoby na rozwój i wsparcie.
Jeśli połączymy to podejście z poziomym skalowaniem, zyskujemy możliwość wysyłania zapytań do jednego serwera, a odbierania odpowiedzi od innego. W ten sposób klient może sam wybierać wygodną dla siebie usługę, a system wewnętrzny wciąż będzie mógł prawidłowo przetwarzać zdarzenia.
Event Sourcing
Jak wiesz, jedną z głównych cech systemu rozproszonego jest brak wspólnego czasu i wspólnej sekcji krytycznej. Dla jednego procesu można wprowadzić synchronizację (na tych samych mutexach), w której masz pewność, że nikt inny nie wykonuje tego kodu. Jednak w systemie rozproszonym takie rozwiązanie jest niebezpieczne, ponieważ wiąże się z dodatkowymi kosztami, a cały urok skalowalności zostaje zniweczony — wszyscy komponenty będą czekały na jeden.
Stąd wynika ważny fakt — szybkiego systemu rozproszonego nie można synchronizować, ponieważ wtedy zmniejszymy wydajność. Z drugiej strony często potrzebna jest określona spójność komponentów. W tym celu można zastosować podejście z , gdzie gwarantuje się, że w przypadku braku zmian danych po pewnym czasie po ostatniej aktualizacji („w końcu”) wszystkie zapytania będą zwracać ostatnią zaktualizowaną wartość.
Ważne jest, aby rozumieć, że w klasycznych bazach danych stosuje się dość często , gdzie każdy węzeł ma te same informacje (takie wyniki często osiąga się, gdy transakcja uznawana jest za zakończoną dopiero po odpowiedzi drugiego serwera). Istnieją pewne ustępstwa ze względu na poziomy izolacji, jednak ogólna zasada pozostaje ta sama — możesz żyć w całkowicie spójnym świecie.
Jednak wróćmy do pierwotnego zadania. Jeśli część systemu może być zbudowana z , to można zbudować następujący schemat.

Ważne cechy tego podejścia:
- Każde przychodzące zapytanie jest umieszczane w kolejce.
- Podczas przetwarzania zapytania usługa może również umieszczać zadania w innych kolejkach.
- Każde przychodzące zdarzenie ma identyfikator (konieczny do de-duplikacji).
- Kolejka ideologicznie działa na zasadzie "tylko dodawanie". Nie można z niej usuwać elementów ani ich przestawiać.
- Kolejka działa na zasadzie FIFO (przepraszam za tautologię). Jeśli konieczne jest równoległe przetwarzanie, to na jednym z etapów należy przenieść obiekty do różnych kolejek.
Przypominam, że rozważamy przypadek internetowego magazynu plików. W tym przypadku system będzie wyglądał mniej więcej tak:

Ważne jest, że usługi na diagramie niekoniecznie oznaczają oddzielny serwer. Nawet proces może być ten sam. Ważne jest coś innego: ideologicznie te rzeczy są podzielone w taki sposób, aby można było łatwo zastosować poziome skalowanie.
A dla dwóch użytkowników diagram będzie wyglądał tak (usługi przeznaczone dla różnych użytkowników oznaczone są różnymi kolorami):

Korzyści z takiej kombinacji:
- Usługi przetwarzania informacji są podzielone. Kolejki również są podzielone. Jeśli będziemy musieli zwiększyć przepustowość systemu, wystarczy uruchomić więcej usług na większej liczbie serwerów.
- Kiedy otrzymujemy informacje od użytkownika, nie musimy czekać na pełne zapisanie danych. Przeciwnie, wystarczy odpowiedzieć "ok", a następnie stopniowo zacząć pracę. Po drodze kolejka wygładza szczyty, ponieważ dodanie nowego obiektu zachodzi szybko, a użytkownik nie musi czekać na pełne przejście przez cały cykl.
- Dla przykładu dodałem usługę deduplikacji, która stara się łączyć identyczne pliki. Jeśli działa ona długo w 1% przypadków, klient tego praktycznie nie zauważy (patrz powyżej), co jest dużym plusem, ponieważ nie wymagamy już stuprocentowej prędkości i niezawodności.
Jednak od razu widać również minusy:
- Nasza system stracił ścisłą spójność. Oznacza to, że jeśli na przykład zasubskrybujemy różne usługi, teoretycznie można otrzymać różny stan (ponieważ jedna z usług może nie zdążyć przyjąć powiadomienia z wewnętrznej kolejki). Jako kolejne konsekwencje, system nie ma teraz wspólnego czasu. To znaczy, że nie można na przykład posortować wszystkich zdarzeń tylko po czasie przybycia, ponieważ zegary między serwerami mogą nie być zsynchronizowane (ponadto ta sama godzina na dwóch serwerach to utopia).
- Żadne zdarzenia nie mogą być teraz po prostu cofnięte (jak w przypadku bazy danych). Zamiast tego należy dodać nowe zdarzenie — , które zmieni ostatni stan na wymagany. Jako przykład z podobnej dziedziny: bez przepisania historii (co jest złe w wielu przypadkach) w git nie można cofnąć commita, jednak można zrobić specjalny , który w zasadzie po prostu przywróci poprzedni stan. Jednak w historii pozostanie zarówno błędny commit, jak i rollback.
- Schemat danych może zmieniać się z wydania na wydanie, jednak stare zdarzenia nie mogą być teraz aktualizowane do nowego standardu (ponieważ zdarzeń zasadniczo nie można zmieniać).
Jak widać, Event Sourcing doskonale współpracuje z CQRS. Co więcej, zrealizowanie systemu z efektywnymi i wygodnymi kolejkami, jednak bez rozdzielenia przepływów danych, już samo w sobie jest trudne, ponieważ konieczne będzie dodanie punktów synchronizacji, które zniwelują cały pozytywny efekt kolejek. Stosując oba podejścia jednocześnie, konieczne jest niewielkie dostosowanie kodu pracy programu. W naszym przypadku, przy wysyłaniu pliku na serwer, w odpowiedzi otrzymujemy tylko „ok”, co oznacza jedynie, że „operacja dodania pliku została zapisana”. Formalnie nie oznacza to, że dane są już dostępne na innych urządzeniach (na przykład, usługa deduplikacji może odbudowywać indeks). Jednak po pewnym czasie klient otrzyma powiadomienie w stylu „plik X został zapisany”.
W rezultacie:
- Liczba statusów wysyłania plików rośnie: zamiast klasycznego „plik wysłany” otrzymujemy dwa: „plik dodany do kolejki na serwerze” i „plik zapisany w magazynie”. Ostatnie oznacza, że inne urządzenia mogą już zaczynać pobierać plik (z uwzględnieniem, że kolejki działają z różną szybkością).
- Z powodu tego, że informacje o wysłaniu teraz przychodzą różnymi kanałami, musimy wymyślić rozwiązania, aby otrzymać status przetwarzania pliku. W konsekwencji: w przeciwieństwie do klasycznego request-response, klient może być uruchamiany na nowo w trakcie przetwarzania pliku, jednak status tego przetwarzania będzie poprawny. Co więcej, ten punkt działa, w zasadzie, od razu. W rezultacie: jesteśmy teraz bardziej tolerancyjni na awarie.
Sharding
Jak już opisano powyżej, w systemach z event sourcing brak jest rygorystycznej spójności. Oznacza to, że możemy korzystać z kilku magazynów bez jakiejkolwiek synchronizacji między nimi. Zbliżając się do naszego zadania, możemy:
- Separować pliki według typów. Na przykład, obrazy/wideo można dekodować i wybierać bardziej efektywny format.
- Rozdzielanie kont według krajów. W związku z wieloma przepisami tego typu może być to konieczne, jednak ta architektura daje taką możliwość automatycznie.

Jeśli chcesz przenieść dane z jednego magazynu do drugiego, wtedy standardowe środki nie wystarczą. Niestety, w takim przypadku konieczne jest wstrzymanie kolejki, wykonanie migracji, a następnie jej uruchomienie. W ogólnym przypadku „na żywo” danych nie da się przenieść, jednak jeśli kolejka zdarzeń jest przechowywana w całości, a Ty masz migawki poprzednich stanów magazynu, możemy odtworzyć zdarzenia w następujący sposób:
- W Źródle Zdarzeń każde zdarzenie ma swój identyfikator (w idealnym przypadku - niezmniejszający się). A więc w magazynie możemy dodać pole - id ostatniego przetworzonego elementu.
- Duplikujemy kolejkę, aby wszystkie zdarzenia mogły być przetwarzane dla kilku niezależnych magazynów (pierwszy to ten, w którym już teraz znajdują się dane, a drugi to nowy, lecz na razie pusty). Druga kolejka, naturalnie, na razie nie jest przetwarzana.
- Uruchamiamy drugą kolejkę (to znaczy zaczynamy odtwarzanie zdarzeń).
- Kiedy nowa kolejka będzie względnie pusta (tzn. średnia różnica w czasie między dodaniem elementu a jego wyciągnięciem będzie akceptowalna), można zacząć przełączać czytelników na nowy magazyn.
Jak widać, w naszym systemie nie było i nie ma ścisłej spójności. Jest tylko eventualna spójność, czyli gwarancja, że zdarzenia są przetwarzane w tym samym porządku (może to jednak nastąpić z różnym opóźnieniem). Wykorzystując to, możemy stosunkowo łatwo przenieść dane bez zatrzymywania systemu na drugi koniec świata.
W ten sposób, kontynuując nasz przykład dotyczący online magazynu dla plików, taka architektura daje nam szereg bonusów:
- Możemy przenosić obiekty bliżej użytkowników, w sposób dynamiczny. Dzięki temu można poprawić jakość usługi.
- Możemy przechowywać część danych w obrębie firm. Na przykład użytkownicy klasy Enterprise często wymagają przechowywania swoich danych w kontrolowanych centrach danych (aby uniknąć wycieków danych). Dzięki shardingowi możemy to łatwo wspierać. A zadanie staje się jeszcze prostsze, jeśli klient ma zgodne chmury (na przykład, ).
- A najważniejsze — możemy tego nie robić. Na początku wystarczyłoby jedno repozytorium dla wszystkich kont (by szybciej zacząć pracę). Kluczową cechą tego systemu jest to, że choć jest rozbudowany, na początkowym etapie jest wystarczająco prosty. Po prostu nie trzeba od razu pisać kodu, który działa z milionem niezależnych kolejek itd. Jeśli zajdzie taka potrzeba, można to zrobić w przyszłości.
Hosting treści statycznych
Ten punkt może wydawać się oczywisty, jednak jest konieczny dla względnie standardowej aplikacji obciążonej ruchem. Jego istota jest prosta: cała treść statyczna jest dostarczana nie z tego samego serwera, na którym znajduje się aplikacja, ale z specjalnych, przeznaczonych do tego serwerów. W rezultacie te operacje są szybsze (przykładowo nginx dostarcza pliki szybciej i w sposób mniej kosztowny niż serwer Java). Ponadto architektura CDN () pozwala na umieszczanie naszych plików bliżej końcowych użytkowników, co pozytywnie wpływa na wygodę korzystania z usługi.
Najprostszy i najstandardowy przykład treści statycznej to zestaw skryptów i obrazków dla strony internetowej. Z nimi wszystko jest proste — są znane z góry, następnie archiwum ładowane jest na serwery CDN, skąd jest dostarczane do końcowych użytkowników.
Jednak w rzeczywistości dla treści statycznej można zastosować podejście podobne do architektury lambda. Wróćmy do naszego zadania (internetowe repozytorium plików), w którym musimy dostarczać pliki użytkownikom. Najprostsze rozwiązanie to zbudowanie usługi, która dla każdego zapytania użytkownika wykonuje wszystkie niezbędne sprawdzenia (autoryzacja itd.), a następnie pobiera plik bezpośrednio z naszego repozytorium. Główną wadą takiego podejścia jest to, że treść statyczna (a plik z określoną rewizją w zasadzie jest treścią statyczną) jest dostarczana z tego samego serwera, który zawiera logikę biznesową. Zamiast tego można stworzyć następującą schemat:
- Serwer wydaje URL do pobrania. Może on mieć postać file_id + key, gdzie key to mini cyfrowy podpis, który daje prawo dostępu do zasobu na najbliższe 24 godziny.
- Rozdzielaniem pliku zajmuje się prosty nginx z następującymi opcjami:
- Cache'owanie treści. Ponieważ ta usługa może znajdować się na oddzielnym serwerze, zostawiliśmy sobie zapas na przyszłość z możliwością przechowywania wszystkich ostatnich pobranych plików na dysku.
- Sprawdzanie klucza w momencie nawiązywania połączenia
- Opcjonalnie: przetwarzanie strumieniowe treści. Na przykład, jeśli kompresujemy wszystkie pliki w usłudze, można przeprowadzić dekompresję bezpośrednio w tym module. W efekcie: operacje IO są wykonywane tam, gdzie mają swoje miejsce. Archiwizator w Javie łatwo może przydzielić dużą ilość niepotrzebnej pamięci, jednak przepisanie usługi z logiką biznesową na warunkowe Rust/C++ może okazać się również nieefektywne. W naszym przypadku używane są różne procesy (lub nawet usługi), dlatego można dość efektywnie oddzielić logikę biznesową od operacji IO.

Tego typu schemat nie przypomina zbytnio dystrybucji statycznej treści (ponieważ nie przenosimy całego pakietu statyki gdzieś), jednak w rzeczywistości taki sposób zajmuje się dystrybucją niezmiennych danych. Co więcej, ten schemat można uogólniać na inne przypadki, kiedy treść nie jest tylko statyczna, ale może być przedstawiona w postaci zbioru niezmiennych i nieusuwalnych bloków (chociaż mogą być one dodawane).
Inny przykład (na potwierdzenie): jeśli pracowałeś z Jenkins/TeamCity, wiesz, że oba rozwiązania są napisane w Javie. Oba są procesami Java, które zajmują się zarówno orkiestracją budów, jak i zarządzaniem treścią. W szczególności mają obie zadania polegające na "przekazaniu pliku/folderu z serwera". Na przykład: wydawanie artefaktów, przesyłanie kodu źródłowego (gdy agent nie pobiera kodu bezpośrednio z repozytorium, a robi to serwer), dostęp do logów. Wszystkie te zadania różnią się obciążeniem IO. Oznacza to, że serwer odpowiedzialny za skomplikowaną logikę biznesową musi również efektywnie przesyłać duże strumienie danych. Co ciekawe, taką operację można zlecić temu samemu nginxowi według dokładnie tej samej zasady (z tym, że w zapytaniu należy dodać klucz danych).
Jednak jeśli wrócimy do naszego systemu, to otrzymujemy podobny schemat:

Jak widać, system radykalnie się skomplikował. Teraz to już nie tylko mini-proces, który przechowuje pliki lokalnie. Teraz potrzebne jest już nie tak proste wsparcie, kontrola wersji API itd. Dlatego po narysowaniu wszystkich diagramów najlepiej szczegółowo ocenić, czy rozszerzalność podobnych kosztów jest opłacalna. Jeśli jednak chcesz mieć możliwość rozszerzania systemu (w tym do obsługi jeszcze większej liczby użytkowników), będziesz musiał zdecydować się na takie rozwiązania. Z drugiej strony, jako wynik, system architektonicznie jest gotowy na zwiększenie obciążenia (praktycznie każdy komponent można klonować dla horyzontalnego skalowania). System można aktualizować bez jego zatrzymywania (tylko niektóre operacje będą nieznacznie spowolnione).
Jak już mówiłem na początku, wiele internetowych usług zaczęło doświadczać zwiększonego obciążenia. I niektóre z nich po prostu przestały działać poprawnie. W zasadzie systemy zawiodły dokładnie w momencie, gdy biznes powinien generować zyski. To znaczy, zamiast odroczonej dostawy, zamiast proponować klientom, aby „zaplanowali dostawę na najbliższe miesiące”, system po prostu powiedział „idźcie do konkurencji”. Właśnie taka jest cena niskiej wydajności: straty wystąpią dokładnie wtedy, gdy zyski byłyby najwyższe.
Podsumowanie
Wszystkie te podejścia były znane już wcześniej. Ten sam VK od dawna wykorzystuje pomysł Static Content Hosting do dostarczania obrazków. Mnóstwo gier online używa schematu Sharding do podziału graczy na regiony lub do podziału lokacji gry (jeśli świat jest jednością). Podejście Event Sourcing jest aktywnie wykorzystywane w poczcie elektronicznej. Większość aplikacji traderów, w których stale napływają dane, tak naprawdę oparta jest na podejściu CQRS, aby móc filtrować otrzymywane dane. Horyzontalne skalowanie było stosowane od dłuższego czasu w wielu usługach.
Jednak najważniejsze jest to, że wszystkie te wzorce stały się bardzo łatwe do zastosowania w nowoczesnych aplikacjach (o ile są na miejscu, oczywiście). Chmury oferują Sharding i poziome skalowanie od razu, co jest znacznie łatwiejsze niż zamawianie różnych dedykowanych serwerów w różnych centrach danych samodzielnie. CQRS stał się znacznie prostszy, chociażby z powodu rozwoju bibliotek, takich jak RX. Około 10 lat temu rzadko która strona internetowa mogła by to obsłużyć. Event Sourcing również konfiguruje się niesamowicie łatwo dzięki gotowym kontenerom z Apache Kafka. Około 10 lat temu byłoby to innowacją, teraz to codzienność. Podobnie jest z hostingiem treści statycznych: dzięki wygodniejszym technologiom (w tym dlatego, że istnieje szczegółowa dokumentacja i duża baza odpowiedzi), takie podejście stało się jeszcze prostsze.
W rezultacie wdrożenie szeregu dość złożonych wzorców architektonicznych stało się teraz znacznie łatwiejsze, co oznacza, że warto się temu wcześniej przyjrzeć. Jeśli w aplikacji o dziesięcioletniej historii zrezygnowano z jednego z wyżej wymienionych rozwiązań z powodu wysokich kosztów wdrożenia i eksploatacji, to teraz, w nowej aplikacji, lub po refaktoryzacji, można stworzyć serwis, który architektonicznie będzie zarówno elastyczny (z punktu widzenia wydajności), jak i gotowy na nowe wymagania ze strony klientów (na przykład w celu lokalizacji danych osobowych).
I najważniejsze: proszę, nie stosujcie tych podejść, jeśli macie prostą aplikację. Tak, są piękne i interesujące, jednak dla strony z maksymalnym ruchem na poziomie 100 osób często można ograniczyć się do klasycznego monolitu (przynajmniej na zewnątrz, wewnątrz wszystko można podzielić na moduły itd.).
Źródło: habr.com
