Wzorce odporności na awarie w systemach rozproszonych

geek.justjoin.it 2 lat temu

Tworzenie systemów rozproszonych wiąże się z koniecznością radzenia sobie z wieloma nowymi problemami, niespotykanymi zwykle w aplikacjach działających jako jeden proces.

Wstęp

Wewnątrz pojedynczego procesu możemy być pewni, iż wywołana funkcja zostanie wykonana, a zaraz po jej zakończeniu otrzymamy rezultat lub informację o błędzie (w postaci zwróconej wartości lub wyjątku). W systemach rozproszonych nie mamy takich gwarancji. Wiadomości między serwisami mogą się gubić lub docierać w niewłaściwej kolejności, serwery mogą ulegać awarii, połączenia sieciowe mogą zostać zerwane lub, w przypływie nagłej popularności usługi, serwery mogą zostać zasypane nadmierną liczbą zapytań, których nie będą w stanie obsłużyć.

Często od nowoczesnych systemów wymaga się wysokiej dostępności. Z drugiej strony, wszystkie ich składowe (oprogramowanie, sieć, dyski, komputery itd.) są do pewnego stopnia zawodne. Problemy z jednym komponentem mogą powodować kaskadowe wystąpienie problemów w innych częściach systemu, prowadząc w ten sposób choćby do jego całkowitej niedostępności.

W dalszej części artykułu poznamy niektóre z powszechnie stosowanych wzorców pozwalających na projektowanie systemów, które będą do pewnego stopnia odporne na częściowe awarie. Tę cechę określa się mianem fault tolerance.

Redundancja

Części systemu, których awaria może spowodować jego całkowitą niedostępność, określane są jako pojedyncze punkty awarii (ang. single point of failure). Wyeliminowanie ich pozwoli nam poprawić dostępność systemu i jego odporność na błędy.

Często serwery baz danych stanowią pojedynczy punkt awarii w aplikacjach. Większość powszechnie stosowanych RDBMS (Relational Database Management System) może zostać skonfigurowana, aby działać w trybie wysokiej dostępności (high availability). Wtedy, oprócz głównego serwera (primary), działa także co najmniej jedna jego replika – serwer zapasowy (secondary), gotowy w trakcie awarii przejąć jego rolę.

Pomiędzy serwerami danych może zachodzić replikacja synchroniczna. To znaczy, iż każda operacja wykonana na głównym serwerze musi być natychmiast wykonana na serwerze zapasowym. Replikacja synchroniczna spowalnia wykonanie wszystkich operacji, ponieważ wymaga potwierdzenia każdej z nich przez serwer zapasowy, ale w przypadku awaryjnego przepięcia (ang. failover) gwarantuje brak utraty danych. Drugą opcją jest replikacja asynchroniczna, gdzie serwer zapasowy może wykonywać operacje z opóźnieniem. Przy użyciu replikacji asynchronicznej i konieczności awaryjnego przepięcia, serwer zapasowy, przejmujący teraz rolę serwera głównego, może nie posiadać najnowszych zmian, które zostały niedawno wykonane.

Zachowanie bazy danych, działającej w trybie wysokiej dostępności, w przypadku awarii serwera głównego

Aby usługa nie stała się pojedynczym punktem awarii, musi być uruchomiona co najmniej na dwóch serwerach, z którymi użytkownik komunikuje się za pośrednictwem mechanizmu równoważenia obciążenia (ang. load balancer). W przypadku wystąpienia awarii jednego z serwerów, może on wykryć ten błąd dzięki mechanizmu health check i przestać przekierowywać ruch do tego serwera, dopóki usterka nie ustanie. Takie rozwiązanie pozwala również na skalowanie liczby serwerów w odpowiedzi na zwiększone obciążenie.

Wykorzystanie mechanizmu równoważenia obciążenia powoduje, iż kolejne zapytania od jednego użytkownika mogą trafiać do różnych maszyn. o ile przechowują one dane powiązane z sesją użytkownika w pamięci, może to skutkować tym, iż dane pozornie “znikają”. Na przykład, sklep internetowy przechowujący zawartość koszyka w pamięci serwera może “zgubić” poprzednio dodane towary.

Można skonfigurować load balancer, tak żeby wszystkie zapytania w ramach jednej sesji użytkownika powiązane były zawsze z jednym serwerem (tzw. session affinity). Pozornie rozwiązuje to problem, ponieważ przy normalnym działaniu użytkownik będzie widział spójny widok koszyka w ramach jednej sesji. Co jednak w przypadku awarii tego jednego serwera? Polecanym rozwiązaniem tego problemu jest uczynienie serwerów bezstanowymi i przechowywanie sesji użytkownika w osobnej bazie danych.

Przykład działania mechanizmu równoważenia obciążenia pomiędzy kilkoma bezstanowymi serwerami aplikacji. Jeden z nich jest niedostępny, więc został wyłączony z obsługi zapytań, do czasu powrotu do normalnego funkcjonowania.

Timeout

Kiedy procesy komunikują się po asynchronicznej sieci (takiej jak Internet), maksymalny czas dostarczenia wiadomości jest nieograniczony. Może ona także zostać porzucona, nie docierając do odbiorcy.

Jeżeli wyślemy wiadomość, na którą nie otrzymaliśmy odpowiedzi, nie ma sposobu stwierdzenia, czy:

  • wiadomość zaginęła po drodze (nie dotarła do odbiorcy),
  • lub wiadomość dotarła do odbiorcy, ale w trakcie jej przetwarzania nastąpił błąd,
  • lub wiadomość dotarła do odbiorcy i jest w trakcie przetwarzania,
  • lub wiadomość dotarła do odbiorcy i została przetworzona, ale odpowiedź nie dotarła do nas.

Oczekiwanie w nieskończoność na odpowiedź, która może nigdy nie dotrzeć, prowadzi do blokowania wątków i połączeń a także może być przyczyną dalszych błędów. Rozwiązaniem tego problemu jest stosowanie timeoutu, czyli ograniczonego czasu oczekiwania na odpowiedź i traktowanie każdej sytuacji, gdzie ten czas został przekroczony, jako błąd.

Kiedy sami tworzymy logikę komunikacji z innymi serwerami, sami też jesteśmy odpowiedzialni za adekwatne zaimplementowanie mechanizmu timeoutu. Używając gotowych bibliotek warto sprawdzić, czy posiadają konfigurację timeoutu i dostosować ją do naszych potrzeb. Często domyślne wartości mogą nie odpowiadać naszemu użyciu.

Zbyt krótki timeout może skutkować częstym porzucaniem zapytań, które niedługo później zakończyłyby się sukcesem. Zbyt długi natomiast spowolni działanie systemu i zablokuje zasoby w sytuacji, kiedy otrzymanie odpowiedzi jest już mało prawdopodobne.

Jak więc dobrać długość timeoutu? Nie ma jednej, uniwersalnej wartości, która zadziała wszędzie. Warto jednak wziąć pod uwagę z jednej strony wymagania biznesowe (jak długo użytkownik będzie miał cierpliwość czekać na odpowiedź), a z drugiej realny, zmierzony przez nas czas odpowiedzi od serwera w różnych sytuacjach.

Retry

Co możemy zrobić, kiedy zapytanie do serwera zakończyło się niepowodzeniem? Jednym z rozwiązań jest ponowienie zapytania po krótkim czasie. Nie mamy jednak pewności, czy oryginalne zapytanie zostało poprawnie obsłużone, czy nie. Bezpiecznie powtarzać możemy tylko te zapytania, które są idempotentne, to znaczy, iż o ile wykonamy je kilka razy, to stan systemu będzie taki sam, jakby zostały wykonane tylko raz.

Zasadność ponawiania zapytania zależy od tego, czy konkretny błąd, który wystąpił uważamy za przemijający (ang. transient) czy też wskazuje on na to, iż zapytanie jest niepoprawne i za każdym razem zakończy się błędem. Określenie, które błędy są przemijające może wymagać wiedzy o protokole, dzięki którego komunikujemy się z serwerem lub przeanalizowania możliwych wyjątków lub kodów błędów. Przykładowo w protokole HTTP, kody odpowiedzi z zakresu 500-599 wskazują na błąd serwera i mogą być potencjalnie rozwiązane po ponowieniu zapytania.

Często stosowanym schematem jest exponential backoff, czyli kilkukrotne ponawianie prób wykonania zapytania w wykładniczo rosnących odstępach czasowych (na przykład najpierw po 1 sekundzie, potem po 2, po 4, po 8 i po 16). Jest on korzystny, ponieważ unika zasypywania serwera dużą ilością zapytań, o ile błędy wynikają z nadmiernego jego obciążenia.

Idź do oryginalnego materiału