Designing Data-Intensive Applications chapter 1 summary

Designing Data Intensive Applications – Niezawodne, skalowalne i łatwe w utrzymaniu aplikacje – podsumowanie rozdziału 1

Oto podsumowanie, wnioski i przemyślenia z pierwszego rozdziału Designing Data-Intensive Applications, z kilkoma uzupełnieniami wynikającymi z mojego doświadczenia.

Co wpływa na architekturę systemu?

Większość systemów, które zarządzają danymi, musi je przechowywać (bazy danych), zapamiętywać rezultat kosztownych operacji (cache), pozwalać na efektywne przeszukiwanie swoich zasobów (search indexes), komunikować się asynchronicznie z innym procesem (stream processing) oraz raz na jakiś czas przetworzyć wszystko od początku (batch processing). 

Może brzmi to oczywiście, ale jest wiele sposobów na osiągnięcie każdego z tych celów, w zależności od potrzeb. Część narzędzi potrafi realizować kilka z wymienionych zadań, zawsze jednak pewnym kosztem, zyskując natomiast na jednym wdrożeniu i chowając kilka implementacji w jednym pudełku. 

Architektura systemu jest grą kompromisów. Czasem budujemy lepiankę zamiast szklanego wieżowca, ponieważ oczekiwany zysk z szybkiego dostarczenia jest wysoki bądź konsekwencje niezmieszczenia się w danym czasie są bardzo dotkliwe.

Są również inne czynniki, które wpływają na kształt systemu: doświadczenie zespołu (rozsądnie jest nie wybierać Kafki, jeśli nikt nie zna Javy), wymagania prawne (nie mogę polegać na immutable log, jeśli zamierzam usuwać dane) czy zależności od pozostałych części systemu.

Jak zatem podejmować decyzje, kiedy istnieje tyle możliwości i ograniczeń? Na to pytanie trudno odpowiedzieć bezpośrednio, mamy jednak trzech przewodników, którzy wyznaczają drogę do dobrego systemu. Są nimi: niezawodność, skalowalność i łatwość utrzymania. Wbrew obiegowej opinii nie są to puste nazwy serwowane jedynie na marketingowej tacy. Nie pozostaje nic innego, jak je doprecyzować.

Niezawodność

Niezawodny to taki, który działa nawet wtedy, kiedy rzeczy nie idą po naszej myśli. Nie bądźmy naiwni, błędy zawsze będą się zdarzać. Innymi słowy, system tolerujący awarie (fault tolerant) to ten, który jest odporny na uszkodzenia. W nomenklaturze technicznej faults są oczekiwane, ale failures, czyli sytuacji, kiedy cały system zawiódł, unikamy jak ognia. Projektujemy zatem w taki sposób, aby faults nie eskalowały do failures.

Jak testować, czy nasz system toleruje awarie? Drastycznym, ale skutecznym sposobem jest celowe psucie samemu, najlepiej na produkcyjnym środowisku. Na czym to polega? Bez ostrzeżenia, ale z ręką na pulsie zabijamy daną usługę i obserwujemy, jak wpłynęło to na całość systemu. Tak, są do tego narzędzia, jak Netflix Chaos Monkey

Odpowiedzmy sobie również na pytanie, jakie są źródła problemów.

Wady sprzętowe (hardware faults)

Mimo stosowania nadmiarowych urządzeń, jak zastępcze źródło prądu czy hot-swappable CPUs, nierozsądnie byłoby sądzić, że sprzęt nie sparaliżuje systemu. W kwietniu 2022 roku największy dostawca maszyn wirtualnych na świecie (AWS) daje Ci gwarancję, że będą one działać przez 99,5% czasu, co przekłada się na nieco ponad 3,5 godziny niedostępności w miesiącu. Z tego powodu instancje aplikacji nie powinny znajdować się na tym samym serwerze, w przełożeniu na wdrożenia w Kubernetes, instancje aplikacji nie powinny znajdować się na Nodach, które są na jednym serwerze.

Aplikacja, która jest odporna na hardware faults jest dużo łatwiejsza w utrzymaniu, ponieważ nie trzeba planować czasu niedostępności, żeby wykonać aktualizacje wersji systemu operacyjnego czy zmienić instancję obliczeniową.

Słyszałeś może powiedzenie, że ludzie dzielą się na dwa typy: na tych, którzy robią backupy, i na tych, którzy będą je robić. Jednym z powodów, dla którego możemy utracić dane, jest posiadanie tylko jednej kopii na dysku twardym, który akurat uległ awarii. Coraz powszechniej korzystamy z usług do przechowywania danych, które w pakiecie oferują backupy, więc można o tym nie myśleć, warto mieć to jednak z tyłu głowy, ponieważ dane to zwykle największy kapitał przedsiębiorstwa. 

Wady oprogramowania (software faults)

Jeśli już mamy aplikację, której instancje nie są na tym samym serwerze, problem praktycznie znika. Szansa, że wszystkie nasze blachy padną w tym samym czasie, jest już na tyle niska, że nie musimy zawracać sobie tym głowy. Gorzej z błędami, które występują wszędzie naraz. Najgorzej, że te akurat sami sprowadzamy na siebie w kodzie. Okazuje się, że największy problem tkwi pomiędzy krzesłem a klawiaturą. Wprawdzie „w kodzie” to nie znaczy,  że w aplikacji, którą sami wytwarzamy. Może on przecież wystąpić w bibliotece czy samym systemie operacyjnym. Te błędy są zwykle uśpione i tylko czekają na odpowiednie dane wejściowe.

Dlaczego to jest ważne?

Pomińmy nawet oprogramowanie obsługujące elektrownię atomową czy numery alarmowe.

Niezawodność jest obietnicą każdego biznesu wobec konsumenta. Nawet jeśli tworzysz kolejną przeglądarkę bazy danych (lub CRUD-a – jeśli wolisz) w postaci listy TO DO do App Store’a, to pomyśl o użytkowniku sklerotyku, który dodał notatkę, żeby zadzwonić do swojej mamy po dwóch latach od ostatniej rozmowy. W zawodnym systemie może ona przepaść, przez co pogrążysz relacje rodzinne na kolejne miesiące.

Skalowalność

Jest to umiejętność radzenia sobie z rosnącym obciążeniem. Czym jest zatem obciążenie? To ilość danych, które obsługujemy w danej jednostce czasu, natomiast sam charakter danych wpływa na to, jakie problemy przyjdzie nam rozwiązać, kiedy będziemy chcieli ich obsłużyć więcej. System, który ma obsłużyć w sekundę 100 zapytań o rozmiarze 1 Mb, będzie wyglądał inaczej niż system, który obsługuje w sekundę 100 000 zapytań o rozmiarze 1 Kb, mimo że objętość danych w jednostce czasu pozostanie taka sama.

Oto kilka czynników, które wpływają na architekturę systemu:

  1. liczba readów do bazy na sekundę,
  2. liczba writów do bazy na sekundę,
  3. średni rozmiar zapytania,
  4. liczba zapytań na sekundę.

Systemy składają się z wielu komponentów, jak serwisy operacyjne, nierelacyjna baza danych, kolejka, cache i tak dalej, a każdy z nich ma swoje mocne i słabe strony pod względem charakteru rosnących danych. Co więcej, nie sama natura komponentów wpływa na skalowalność, ale również to, jak są połączone. Jeśli serwis operacyjny musi skomunikować się z 10 innymi, aby obsłużyć żądanie, to będzie trudniej zarządzać przepustowością, niż gdyby miał tylko jedną zależność. 

Żeby rozpatrywać wzrost, musimy rozpatrzyć problem, czym jest obciążenie dla systemu. Pomagają temu pytania dotyczące tego konkretnego systemu:

  1. Co się stanie, jeśli liczba zapytań na sekundę do cache wzrośnie 10-krotnie?
  2. Jak system poradzi sobie z 10-krotnie większą liczbą zarejestrowanych użytkowników?
  3. Co się stanie, jeśli zaloguje się 10 razy więcej użytkowników niż zwykle?

Definiowanie wydajności

Możemy przeprowadzić dwie symulacje, żeby określić wydajność systemu. Pierwsza zakłada, że posiadamy stałą ilość zasobów obliczeniowych, podnosimy obciążenie i obserwujemy wydajność. Druga zakłada, że chcemy utrzymać taką samą wydajność, podnosimy obciążenie i dostosowujemy ilość zasobów obliczeniowych.

Kiedy myślimy o obciążeniu, naturalnie przychodzi nam na myśli liczba obsługiwanych zapytań na sekundę (throughput). Jednak czy zapytanie, które zostało obsłużone po 2 sekundach, powinniśmy uznać za sukces? Jeśli końcowym odbiorcą jest użytkownik, to badania Amazona pokazują, że nie. Tak długi czas oczekiwania wpływa zarówno na satysfakcję, jak i samą wartość koszyka.

Zdefiniujmy na nowo przykładową metrykę sukcesu, mierzącą wydajność tym razem dwoma warunkami:

  1. środkowa (mediana) odpowiedzi mniejsza niż 200 ms,
  2. 99,9% odpowiedzi w czasie krótszym niż 500 ms.

Takie podejście do sprawy jest lepsze, ponieważ uwzględnia rozkład oczekiwania na odpowiedź, dlatego kompromis w postaci mierzenia wartości średniej jest ciągle zbyt dużym uogólnieniem. 

Co istotne, podczas testowania wydajności pod obciążeniem generowane sztucznie żądania muszą być wysyłane niezależnie od przychodzących odpowiedzi, żeby odwzorować świat rzeczywisty.

Jak sobie radzić z rosnącym obciążeniem?

Regułą jest, że kiedy jeden z czynników wpływający na obciążenie zwiększy się o rząd wielkości, sama architektura również ulegnie zmianie, zapewne nawet wcześniej. Do tego czasu mamy dwie strategie zwiększania mocy przerobowych: skalowanie w górę (scale up, vertical scaling), czyli zwiększanie mocy obliczeniowych tej konkretnej maszyny, na której pracuje komponent systemu, oraz skalowanie w bok (scale out, horizontal scaling), czyli zwiększanie ilości maszyn obliczeniowych

Skalowanie horyzontalne jest w zasadzie proste dla usług bez stanu, natomiast usługi, które przechowują dane, na przykład relacyjne bazy danych, borykają się problemem redystrybucji partycji, który uwzględnia downtime. Z tego powodu mądrością wypracowaną przez praktyków jest: „skaluj w górę tak długo, jak tylko możesz” dla usług ze stanem.

System, który automatycznie dostosowuje się do obciążenia, nazywamy systemem elastycznym, co jest obecnie złotym standardem, w przeciwieństwie do manualnego zarządzania zasobami obliczeniowymi.

Łatwość utrzymania

Truizmem już jest, że początkowy koszt wytwarzania oprogramowania jest znacznie mniejszy niż jego późniejsze utrzymanie i rozszerzanie. Utrzymanie to naprawianie bugów, adaptowanie systemu pod nowe funkcjonalności, jak również ich implementacja czy sama spłata wcześniej zaciągniętego długu technicznego.

Co zatem wyróżnia system, który jest łatwo utrzymywać? Przede wszystkim to, jak prosto pracuje nam się z nim na produkcji, czyli: 

  • zadania, które nie wymagają interfejsu białkowego, są zautomatyzowane, 
  • ma jasny wgląd w stan życia systemu w czasie rzeczywistym, czyli monitorowanie i loging,
  • ma jasne i proste procesy określające rutynowe czynności, jak patchowanie czy restart usług. 

To oczywiście głównie działka specjalizacji DevOps. 

Jak natomiast rzemieślnik aplikacyjny może się przyczynić do lepszej przyszłości? Redukować przypadkową złożoność, czyli logikę, która nie wynika z samego problemu, a z jego implementacji.

Jak zapobiegać nieuniknionej przypadkowej złożoności? W tym wypadku panaceum jest abstrakcją. Abstrakcją chowającą implementacyjne szczegóły i wystawiającą jasny i spójny interfejs dla konsumentów.

Podsumowanie

Kiedy odpowiadamy na pytanie: „jak projektować niezawodne, skalowalne i łatwe w utrzymaniu aplikacje?” nie możemy szukać gotowych rozwiązań, ponieważ w grę wchodzi za dużo zmiennych. Lepiej jest posiłkować się dobrymi praktykami i wobec nich rozważać konkretną implementację. Pewna doza pokory i cierpliwości to również nieodzowny element długofalowej gry na technologicznej szachownicy, bowiem jedynie w ewolucyjny sposób można stworzyć najlepsze rozwiązania.

Udostępnij ten wpis


Dobrnąłeś do końca. Jeśli ten artykuł był dla Ciebie wartościowy i chcesz otrzymywać informacje o kolejnych, to zapraszam Cię do zapisania się do listy mailingowej. Gwarantuję zero spamu.

Radek.

Inne artykuły