![Search icon](https://hackernoon.imgix.net/search-new.png?w=19&h=19)
Odporność w oprogramowaniu odnosi się do zdolności aplikacji do kontynuowania płynnego i niezawodnego działania, nawet w obliczu nieoczekiwanych problemów lub awarii. W projektach Fintech odporność ma szczególnie duże znaczenie z kilku powodów. Po pierwsze, firmy są zobowiązane do spełniania wymogów regulacyjnych, a regulatorzy finansowi podkreślają odporność operacyjną w celu utrzymania stabilności w systemie. Ponadto proliferacja narzędzi cyfrowych i poleganie na zewnętrznych dostawcach usług naraża firmy Fintech na zwiększone zagrożenia bezpieczeństwa. Odporność pomaga również łagodzić ryzyko przerw spowodowanych różnymi czynnikami, takimi jak zagrożenia cybernetyczne, pandemie lub wydarzenia geopolityczne, chroniąc podstawowe operacje biznesowe i krytyczne aktywa.
Przez wzorce odporności rozumiemy zbiór najlepszych praktyk i strategii zaprojektowanych w celu zapewnienia, że oprogramowanie może wytrzymać zakłócenia i utrzymać swoje działanie. Wzorce te działają jak sieci bezpieczeństwa, zapewniając mechanizmy obsługi błędów, zarządzania obciążeniem i odzyskiwania po awariach, zapewniając w ten sposób, że aplikacje pozostają solidne i niezawodne w niesprzyjających warunkach.
Do najczęstszych strategii odporności należą: grodzie, pamięć podręczna, odzyskiwanie, ponawianie prób i wyłącznik obwodu. W tym artykule omówię je bardziej szczegółowo, podając przykłady problemów, które mogą pomóc rozwiązać.
Przyjrzyjmy się powyższemu ustawieniu. Mamy bardzo zwyczajną aplikację z kilkoma back-endami za nami, z których pobieramy dane. Jest kilku klientów HTTP podłączonych do tych back-endów. Okazuje się, że wszyscy oni współdzielą tę samą pulę połączeń! A także inne zasoby, takie jak CPU i RAM.
Co się stanie, jeśli jeden z back-endów napotka jakieś problemy skutkujące dużym opóźnieniem żądania? Ze względu na długi czas odpowiedzi, cała pula połączeń zostanie całkowicie zajęta przez żądania oczekujące na odpowiedzi z back-endu 1. W rezultacie żądania przeznaczone dla sprawnego back-endu 2 i back-endu 3 nie będą mogły być kontynuowane, ponieważ pula jest wyczerpana. Oznacza to, że awaria jednego z naszych back-endów może spowodować awarię całej aplikacji. W idealnym przypadku chcemy, aby tylko funkcjonalność związana z wadliwym back-endem uległa degradacji, podczas gdy reszta aplikacji będzie działać normalnie.
Czym jest wzór Bulkhead?
Termin Bulkhead pattern pochodzi z budowy statków, polega na tworzeniu kilku odizolowanych przedziałów w obrębie statku. Jeśli w jednym przedziale wystąpi przeciek, wypełnia się on wodą, ale pozostałe przedziały pozostają nienaruszone. Ta izolacja zapobiega zatonięciu całego statku z powodu pojedynczego pęknięcia.
Wzorzec Bulkhead można wykorzystać do izolowania różnych typów zasobów w aplikacji, zapobiegając wpływowi awarii jednej części na cały system. Oto, jak możemy zastosować go do naszego problemu:
Załóżmy, że nasze systemy zaplecza mają niskie prawdopodobieństwo napotkania błędów indywidualnie. Jednak gdy operacja obejmuje równoległe przeszukiwanie wszystkich tych systemów zaplecza, każdy z nich może niezależnie zwrócić błąd. Ponieważ błędy te występują niezależnie, ogólne prawdopodobieństwo błędu w naszej aplikacji jest wyższe niż prawdopodobieństwo błędu dowolnego pojedynczego systemu zaplecza. Skumulowane prawdopodobieństwo błędu można obliczyć za pomocą wzoru P_total=1−(1−p)^n, gdzie n jest liczbą systemów zaplecza.
Na przykład, jeśli mamy dziesięć zapleczy, z których każde ma prawdopodobieństwo błędu p=0,001 (co odpowiada SLA na poziomie 99,9%), prawdopodobieństwo błędu wynosi:
P_całkowity=1−(1−0,001)^10=0,009955
Oznacza to, że nasze łączne SLA spada do około 99%, co pokazuje, jak ogólna niezawodność spada podczas równoległego wykonywania zapytań do wielu zapleczy. Aby złagodzić ten problem, możemy zaimplementować pamięć podręczną w pamięci.
Pamięć podręczna w pamięci służy jako szybki bufor danych, przechowując często używane dane i eliminując potrzebę pobierania ich z potencjalnie wolnych źródeł za każdym razem. Ponieważ pamięci podręczne przechowywane w pamięci mają 0% szansy na błąd w porównaniu do pobierania danych przez sieć, znacznie zwiększają niezawodność naszej aplikacji. Ponadto buforowanie zmniejsza ruch sieciowy, co jeszcze bardziej obniża ryzyko błędów. W rezultacie, wykorzystując pamięć podręczną w pamięci, możemy osiągnąć jeszcze niższy współczynnik błędów w naszej aplikacji w porównaniu z naszymi systemami zaplecza. Ponadto pamięci podręczne w pamięci oferują szybsze pobieranie danych niż pobieranie oparte na sieci, tym samym zmniejszając opóźnienie aplikacji — znaczącą zaletę.
W przypadku danych spersonalizowanych, takich jak profile użytkowników lub rekomendacje, korzystanie z pamięci podręcznej w pamięci może być również wysoce skuteczne. Musimy jednak upewnić się, że wszystkie żądania od użytkownika konsekwentnie trafiają do tej samej instancji aplikacji, aby wykorzystać dane buforowane dla nich, co wymaga stałych sesji. Implementacja stałych sesji może być trudna, ale w tym scenariuszu nie potrzebujemy skomplikowanych mechanizmów. Niewielkie rebalansowanie ruchu jest dopuszczalne, więc wystarczy stabilny algorytm równoważenia obciążenia, taki jak spójne haszowanie.
Co więcej, w przypadku awarii węzła, spójne hashowanie zapewnia, że tylko użytkownicy powiązani z uszkodzonym węzłem przechodzą rebalansowanie, minimalizując zakłócenia w systemie. Takie podejście upraszcza zarządzanie spersonalizowanymi pamięciami podręcznymi i zwiększa ogólną stabilność i wydajność naszej aplikacji.
Jeśli dane, które zamierzamy buforować, są krytyczne i używane w każdym żądaniu obsługiwanym przez nasz system, takie jak zasady dostępu, plany subskrypcji lub inne ważne jednostki w naszej domenie — źródło tych danych może stanowić istotny punkt awarii w naszym systemie. Aby sprostać temu wyzwaniu, jednym ze sposobów jest pełna replikacja tych danych bezpośrednio do pamięci naszej aplikacji.
W tym scenariuszu, jeśli ilość danych w źródle jest możliwa do opanowania, możemy zainicjować proces, pobierając migawkę tych danych na początku naszej aplikacji. Następnie możemy odbierać zdarzenia aktualizacji, aby upewnić się, że buforowane dane pozostają zsynchronizowane ze źródłem. Przyjmując tę metodę, zwiększamy niezawodność dostępu do tych kluczowych danych, ponieważ każde pobieranie odbywa się bezpośrednio z pamięci z prawdopodobieństwem błędu 0%. Ponadto pobieranie danych z pamięci jest wyjątkowo szybkie, co optymalizuje wydajność naszej aplikacji. Ta strategia skutecznie łagodzi ryzyko związane z poleganiem na zewnętrznym źródle danych, zapewniając spójny i niezawodny dostęp do kluczowych informacji dla działania naszej aplikacji.
Jednak konieczność pobierania danych podczas uruchamiania aplikacji, a tym samym opóźnianie procesu uruchamiania, narusza jedną z zasad „aplikacji 12-factor”, opowiadającą się za szybkim uruchamianiem aplikacji. Nie chcemy jednak rezygnować z korzyści płynących z używania buforowania. Aby rozwiązać ten dylemat, przyjrzyjmy się potencjalnym rozwiązaniom.
Szybkie uruchamianie jest kluczowe, zwłaszcza dla platform takich jak Kubernetes, które polegają na szybkiej migracji aplikacji do różnych węzłów fizycznych. Na szczęście Kubernetes może zarządzać wolno uruchamiającymi się aplikacjami, korzystając z funkcji takich jak sondy startowe.
Innym wyzwaniem, z którym możemy się zmierzyć, jest aktualizacja konfiguracji podczas działania aplikacji. Często dostosowanie czasu pamięci podręcznej lub limitów czasu żądań jest konieczne, aby rozwiązać problemy produkcyjne. Nawet jeśli możemy szybko wdrożyć zaktualizowane pliki konfiguracji do naszej aplikacji, zastosowanie tych zmian zazwyczaj wymaga ponownego uruchomienia. Ze względu na wydłużony czas uruchamiania każdej aplikacji, ponowne uruchomienie może znacznie opóźnić wdrażanie poprawek dla naszych użytkowników.
Aby temu zaradzić, jednym z rozwiązań jest przechowywanie konfiguracji w zmiennej współbieżnej i okresowe aktualizowanie jej przez wątek tła. Jednak pewne parametry, takie jak limity czasu żądania HTTP, mogą wymagać ponownej inicjalizacji klientów HTTP lub bazy danych, gdy odpowiednia konfiguracja ulegnie zmianie, co stanowi potencjalne wyzwanie. Jednak niektórzy klienci, tacy jak sterownik Cassandra dla Javy, obsługują automatyczne ponowne ładowanie konfiguracji, co upraszcza ten proces.
Wdrożenie konfiguracji z możliwością ponownego ładowania może złagodzić negatywny wpływ długich czasów uruchamiania aplikacji i zapewnić dodatkowe korzyści, takie jak ułatwienie implementacji flag funkcji. Takie podejście pozwala nam zachować niezawodność i responsywność aplikacji, jednocześnie skutecznie zarządzając aktualizacjami konfiguracji.
Przyjrzyjmy się teraz innemu problemowi: w naszym systemie, gdy żądanie użytkownika jest odbierane i przetwarzane poprzez wysłanie zapytania do zaplecza lub bazy danych, czasami zamiast oczekiwanych danych otrzymywana jest odpowiedź błędu. Następnie nasz system odpowiada użytkownikowi „błędem”.
Jednak w wielu sytuacjach korzystniejsze może być wyświetlenie lekko nieaktualnych danych wraz z komunikatem informującym o opóźnieniu odświeżania danych niż wyświetlanie użytkownikowi dużego, czerwonego komunikatu o błędzie.
Aby rozwiązać ten problem i poprawić działanie naszego systemu, możemy wdrożyć wzorzec Fallback. Koncepcja tego wzorca polega na posiadaniu drugorzędnego źródła danych, które może zawierać dane o niższej jakości lub świeżości w porównaniu do źródła podstawowego. Jeśli źródło podstawowe jest niedostępne lub zwraca błąd, system może powrócić do pobierania danych z tego drugorzędnego źródła, zapewniając, że jakaś forma informacji zostanie przedstawiona użytkownikowi zamiast wyświetlania komunikatu o błędzie.
Jeśli przyjrzysz się powyższemu obrazkowi, zauważysz podobieństwo między problemem, z którym mamy teraz do czynienia, a tym, który napotkaliśmy w przykładzie z pamięcią podręczną.
Aby to rozwiązać, możemy rozważyć wdrożenie wzorca znanego jako ponawianie próby. Zamiast polegać na pamięciach podręcznych, system można zaprojektować tak, aby automatycznie ponownie wysyłał żądanie w przypadku błędu. Ten wzorzec ponawiania próby oferuje prostszą alternatywę i może skutecznie zmniejszyć prawdopodobieństwo wystąpienia błędów w naszej aplikacji. W przeciwieństwie do buforowania, które często wymaga złożonych mechanizmów unieważniania pamięci podręcznej w celu obsługi zmian danych, ponawianie nieudanych żądań jest stosunkowo proste do wdrożenia. Ponieważ unieważnianie pamięci podręcznej jest powszechnie uważane za jedno z najtrudniejszych zadań w inżynierii oprogramowania, przyjęcie strategii ponawiania próby może usprawnić obsługę błędów i poprawić odporność systemu.
Jednak przyjęcie strategii ponawiania prób bez uwzględnienia potencjalnych konsekwencji może prowadzić do dalszych komplikacji.
Wyobraźmy sobie, że jeden z naszych back-endów doświadcza awarii. W takim scenariuszu inicjowanie ponownych prób do niedziałającego back-endu może skutkować znacznym wzrostem wolumenu ruchu. Ten nagły wzrost ruchu może przytłoczyć back-end, pogłębiając awarię i potencjalnie powodując efekt kaskadowy w całym systemie.
Aby poradzić sobie z tym wyzwaniem, ważne jest uzupełnienie wzorca ponawiania prób o wzorzec wyłącznika obwodu. Wyłącznik obwodu służy jako mechanizm zabezpieczający, który monitoruje współczynnik błędów usług downstream. Gdy współczynnik błędów przekroczy wstępnie zdefiniowany próg, wyłącznik obwodu przerywa żądania do dotkniętej usługi na określony czas. W tym czasie system powstrzymuje się od wysyłania dodatkowych żądań, aby umożliwić usłudze, która uległa awarii, odzyskanie sprawności. Po wyznaczonym odstępie czasu wyłącznik obwodu ostrożnie zezwala na przejście ograniczonej liczby żądań, sprawdzając, czy usługa się ustabilizowała. Jeśli usługa odzyskała sprawność, normalny ruch jest stopniowo przywracany; w przeciwnym razie obwód pozostaje otwarty, nadal blokując żądania, dopóki usługa nie wznowi normalnego działania. Poprzez zintegrowanie wzorca wyłącznika obwodu z logiką ponawiania prób możemy skutecznie zarządzać sytuacjami błędów i zapobiegać przeciążeniu systemu podczas awarii zaplecza.
Podsumowując, wdrażając te wzorce odporności, możemy wzmocnić nasze aplikacje przed sytuacjami awaryjnymi, utrzymać wysoką dostępność i zapewnić użytkownikom bezproblemowe działanie. Ponadto chciałbym podkreślić, że telemetria to kolejne narzędzie, którego nie należy pomijać, zapewniając odporność projektu. Dobre dzienniki i metryki mogą znacznie poprawić jakość usług i zapewnić cenne informacje na temat ich wydajności, pomagając podejmować świadome decyzje w celu ich dalszej poprawy.