O transakcjach biznesowych w rozproszonym świecie mikrousług

Ciężko wraca się do pisania po tak długiej przerwie, ale chciałbym podkreślić, że nie był to na pewno czas zmarnowany. W okresie wakacyjnym powstało sporo ciekawego kodu, który na pewno niejednokrotnie zawita na tym blogu. Tak więc stay tuned…i powracamy do tematyki ściśle powiązanej z mikrousługami!

We wpisie traktującym o informowaniu użytkownika o statusie jego operacji (jeżeli nie czytałeś tego wpisu to zachęcam do zrobienia pauzy i zapoznania się) przestawiłem podejście, które przy pomocy dwóch stricte infrastrukturalnych usług komunikowało na bieżąco poprzez chanel WebSocket-owy co dzieje się z wysłanym żądaniem HTTP. Była to implikacja przejścia na całkowicie asynchroniczną bramkę (API Gateway), która zamiast przekazywać ów żądanie do mikrousługi, publikowała odpowiedni command na exchange (w naszym przypadku RabbitMQ) i zapominała o sprawie.

Nie trudno jednak zauważyć, że podejście to działało dobrze tylko w jednym, konkretnym przypadku. Była to sytuacja, w której cały proces biznesowy składał się jedynie z jednego kroku jak na przykład:

  • dodaj produkt
  • zaktualizuj dane użytkownika
  • usuń zamówienie

Jest więc nie innego jak zwykły CRUD 😉 Każda komenda produkowała dokładnie jedno zdarzenie, które po zdjęciu przez serwis operacji mogło być łatwo poddane translacji na wiadomość dla użytkownika. A co w przypadku gdyby nasz proces składał się z N kroków i co gorsza każdy powinien być wykonany w innej usłudze? Przykładowo utworzenie produktu powinno skutkować dodaniem dla niego polityki rabatowej w innej usłudze, aby na końcu wysłać newsletter do pewnej grupy klientów, którzy mogliby być nim zainteresowani (według jakiejś zadanej heurystyki). Przykład nie jest zbytnio wydumany, a już przysparza nam wielu problemów jak np.

  • skąd usługa operacji posiada wiedzę o tym jaka wiadomość (np. ProductCreated) jest przypisana do jakiego kroku procesu? Musi ona przecież wysłać wiadomość WS do użytkownika ze statusem jego operacji np. Pending, Completed, Rejected.
  • kto koordynuje całym procesem w obrębie naszych mikrousług?
  • w jaki sposób wycofać transakcję jeśli w dowolnym kroku pojawi się błąd?

Problem rozproszonych transakcji jest dość powszechny, a wzorców na ich implementację kilka. Przejdźmy zatem wspólnie przez kilka wybranych poznają wady i zalety każdego z nich. Zanim to jednak zrobimy musimy wspólnie ustalić pewną przykładową transakcję biznesową, którą będziemy ów podejściami modelować. Na prezentacjach często pojawia się pewien „klasyg” tj. system do automatycznego zamawiania wycieczki. W założeniu użytkownik podaje jedynie cel swojej podróży oraz zakres dat. Następnie system automatycznie rezerwuje:

  • loty
  • hotel
  • samochód na miejscu

Z uwagi na fakt, że wszystkie trzy punkty muszą być spełnione, aby móc pojechać na wakacje, w przypadku braku dostępności dowolnego zasobu, użytkownik powinien zostać o tym poinformowany, a transakcja powinna być wycofana. Zakładamy również, że mając rozproszony system każdy krok będzie realizowany w osobnej mikrousłudze.

 

2PC – Two-Phase Commit

Pierwsze podejście może wydawać się najbardziej oczywiste. Wszak lepiej zapobiegać niż leczyć! Może zamiast wykonywać jakieś dziwne operacje w naszym systemie, które w przypadku niepowodzenia musimy „rollback-ować”, lepiej będzie gdy podzielimy naszą transakcję na dwie fazy:

 

 

Pomiędzy użytkownikiem, a usługami dodajmy pewnego koordynatora, którego zadaniem będzie obsługa całego procesu rezerwacji wycieczki. W pierwszej fazie koordynator wykona żądanie HTPP do każdej z usług, aby zablokować zasób w zadanym czasie i miejscu. W ramach przetwarzania tego żądania, każda z usług powinna w jakiś sposób oznaczyć dany zasób (tymczasowa flaga, lub inna właściwość) tak, aby wykluczyć go jako rezultat dla innych użytkowników, a następnie zwrócić rezultat, który informuje koordynatora czy akcja się powiodła. W tym momencie możliwe są dwa scenariusze: 

 

 

W pierwszym koordynator nie otrzymuje pozytywnej odpowiedzi zwrotnej od wszystkich usług. W związku z powyższym wycieczka nie może zostać zarezerwowana, a koordynator realizuje fazę drugą, która w tym wypadku polega na wysłaniu żądania do każdej usługi o odblokowanie zasobów.

 

 

Scenariusz nr. 2 zakłada, że koordynator otrzymał 3 x „TAK”. W tym przypadku w ramach fazy drugiej wyśle on już właściwe zapytanie realizujące proces rezerwacji zasobu uruchamiając ewentualną logikę biznesową, której nie wyzwala liśmy „na zaś” w pierwszej fazie. Całe podejście z racji przedstawionego podziału określa się mianem Two-Phase Commit. Czy jest ono dobrym wyborem w kontekście omawianych mikroserwisów? Zacznijmy od plusów:

  • Scentralizowane miejsce zarządzaniem całym flow
  • Opiera się na synchronicznym flow więc użytkownik może zobaczyć rezultat operacji natychmiast

ALE:

  • czas procesowania jest uzależniony od czasu odpowiedzi najwolniejszego węzła (zakładając, że zrównoleglimy zapytania)
  • co w przypadku gdy zablokujemy zasób, a w 2 fazie z jakiegoś powodu (np. natury sieciowej) nie uda się go nam odblokować?
  • sama implementacja musi też być dużo bardziej rozbudowana, aby posiadała wsparcie dla 2 faz procesowania

 

Choreografia zdarzeń (even choreography)

No dobra, pierwsza próba implementacji nie do końca wpisuje się w całą naszą architekturę (even-driven), a dodatkowo niesie za sobą wiele mało pożądanych implikacji. Czas na alternatywę, która nie wymaga praktycznie żadnego dodatkowego kodu (w odniesieniu do DShop), a co najważniejsze robi to co ma robić!

 

 

A gdyby tak wykorzystać fakt, że w naszym systemie wymieniamy dane (pod postacią komend i zdarzeń) poprzez kolejkę wiadomości… Wtedy moglibyśmy zrobić prosty „łańcuszek”, który w prosty sposób realizowałby cały proces.

  1. Użytkownik chce zarezerwować wycieczkę poprzez odpowiednie żadanie HTTP do API Gateway.
  2. Bramka opakowuje dane w komendę, którą publikuje na zadany exchange
  3. Komenda odebrana jest przez usługę lotów, która rozpoczyna proces rezerwacji
  4. W momencie gdy lot został pomyślnie zarezerwowany opublikowane zostaje zdarzenie FlightBooked, pod które subskrybuje się usługa hoteli
  5. W handlerze dla zdarzenia odbywa się proces rezerwacji hotelu
  6. W momencie gdy hotel został pomyślnie zarezerwowany opublikowane zostaje zdarzenie HotelBooked, pod które subskrybuje się usługa pojazdów
  7. W handlerze dla zdarzenia odbywa się proces rezerwacji pojazdu
  8. Jeżeli proces rezerwacji pojazdu się powiedzie, opublikowane zostaje zdarzenie, które informuje o tym, że wycieczka została w pełni zarezerwowana
  9. Jeżeli proces rezerwacji pojazdu (lub wcześniejszy krok) się nie powiedzie, odpowiednie zdarzenie/komenda kompensująca powinna zostać opublikowana celem wycofania transakcji

 

Tym sposobem możemy zamodelować dowolnie długi proces, który składa się z kilku kroków. Oczywiście ma on swoje wady jak i zalety:

  • Wpasowuje się w Event-Driven Architecture co w przypadku DShop oznacza brak potrzeby implementacji dodatkowej infrastruktury, aby wdrożyć to rozwiązanie

ALE:

  • Co w przypadku gdy proces jest bardziej skomplikowany? Np. posiada rozgałęzienia, które po N krokach ponownie się łączą. Kto koordynuje całym procesem?
  • Na kim spoczywa odpowiedzialność w przypadku rollbacka transakcji? Jakby nie patrzeć w tym modelu jest ona rozmyta
  • Choreografia powoduje sporo zamieszania w analizie całego flow, ponieważ nie posiadamy scentralizowanego miejsca, które definiuje cały proces
  • Dalej pozostaje problem, w którym usługa operacji nie wie czy otrzymane zdarzenie kończy flow czy jest N-tym krokiem. Same usługi też nie mają pojęcia gdzie leżą w całym procesie.

Ja podsumował bym to następująco. Choreografia zdarzeń jest bardzo dobrym pomysłem przy prostych scenariuszach jako np. mechanizm integracyjny pomiędzy usługami, ale w przypadku bardziej skomplikowanych scenariuszy może okazać się nie lada wyzwaniem przy implementacji. No cóż… pora przejść do ostatniego podejścia.

 

Saga pattern

Z sagami to jest trochę jak z Yeti. Każdy słyszał, ale nikt nie widział. Niejednokrotnie widzę prezentacje, wpisy (sam nawet taki popełniłem parę lat temu), white papers, które wymieniają to podejście jako dobre do rozwiązania jakiegoś problemu iiiii… na tym koniec. Zero kodu, brak linków, literatury, odniesienia do czegokolwiek co pozwoliłoby nawet samemu zaimplementować to podejście, które w teorii nie wydaje się nadto skomplikowane. Zanim jednak przejdziemy dalej chciałbym zatrzymać się na chwilę, aby w ogóle odpowiedzieć sobie na proste pytanie. Czym jest saga?

Saga jest pojęciem startym bo pierwsza wzmianka pojawiła się pod koniec lat 80, a w założeniu była mechanizmem, który pomagał nadzorować „długo żywotne transakcje” (long living transactions albo LLT) poprzez zapewnienie prostego, bezstanowego routingu danych oraz mechanizmu kompensacji (forward/backward recovery) całej transakcji w przypadku wystąpienia błędu podczas przetwarzania. I to tyle. Problem polega na tym, że przez lata znaczenie tych magicznych 4 liter zmieniło się, ponieważ często mówiąc „saga” programiści mają na myśli pewien mix tego wzorca z innym, a mianowicie z process managerem. Warto jednak zdawać sobie sprawę z różnic:

 

 

Jeżeli chcesz dowiedzieć się więcej to zachęcam do zapoznania się z tym wpisem, gdyż dokładnie tłumaczy róznice między kolejnymi wzorcami. Tak jak zaznaczyłem w poprzednim paragrafie, problemem sagi jest brak konkretnego zdefiniowania. Na samym stackoverflow pełno jest wątków, w kótrych ludzie przerzucają się kolejnymi definicjami i objaśnieniami. Prosty przykład – czy choreografia zdarzeń jest przykładem implementacji sagi? W sumie tak, bo nie zawiera stanu i w razie błędu jesteśmy w stanie wycofać transakcję. Z drugiej strony gdzie tu to zarządzanie całym procesem i routing? Część osób twierdzi, że dopiero w połączeniu z routing slip choreografię możemy nazwać implementacją sagi. Inni o tym nie wspominają. I kto ma racje?

Ja pozwolę sobię do końca tego wpisu uzywać zwrotu „saga” jako pewnego rodzaju hybrydę tych dwóch podejść tj. sagi i process managera. Mowa o mechniźmie scentralizowanego nadzorowania transakcji wraz z persystencją stanu i kompensacją w przypadku wystąpienia błędu. Zobaczmy jak mogłoby to wyglądać w przykładzie z wycieczkami:

 

 

W tym przykładzie użytkownik chcąc rozpocząć cały proces wysyła żądanie HTTP do API Gateway, a to publikuje na exchange komendę BookFlight (choć warto podkreślić, że mogłaby to być komenda bezpośrednio  pod którą subskrybowałaby się saga np. BookVacations). W zasadzie jedyną różnicą w stosunku do choreografii zdarzeń jest to (albo aż to), że teraz wszystko przechodzi przez sagę i to ona odpowiada za publikowanie kolejnych wiadomości w ramach procesu. To saga jest odpowiedzialna za koordynowanie całą sekwencją i to saga posiada wszelkie dane (zapisywane do bazy danych), które pomogą jej ów realizacji. Co w przypadku wystąpienia błędu?

 

 

W powyższym przykładzie usługa pojazdów nie była w stanie zarezerwować niczego we wskazanym czasie. Saga po otrzymaniu stosownego zdarzenia uruchamia proces kompensacji transakcji wysyłając odpowiednie komendy, w tym przypadku CancelHotel oraz CancelFlight. Dzięki temu system powróci do stanu sprzed transakcji. Konfrontując to podejście z poprzednikami możemy dostrzec wiele zalet:

  • W przypadku gdy proces będzie bardziej skomplikowany (przykładowo oczekujemy na N wiadmości zanim pójdziemy dalej) to saga będzie odpowiedzialna za koordynowanie dalszego przebiegu. Implementacja jest dużo prostsza niż w przypadku np. choreografii zdarzeń, ponieważ przechowując stan możemy łatwo zobaczyć czy np. wszystkie wiadomości, które miały do nas trafić już dotarły.
  • Z racji tego, że teraz mamy jedno miejsce, które zarządza całym przebiegiem transakcji nie mamy problemu z rozmytą odpowiedzialnością usług w przypadku kompensacji
  • I w końcu! Poniewaz saga wie, który krok procesu przetwarza, może ona wysyłać bezpośrednio do usługi operacji informację czy proces trwa nadal czy się już zakończył.

Tyle legend na dziś. W następnym wpisie w końcu (jeżeli nie miałeś/aś wcześniej okazji) zobaczysz wspomniane Yeti, czyli faktyczną implmentację sagi w C#, która rozwiąże nietrywialny problem w systemie rozproszonym. Na zakończenie dodam jeszcze, że gdyby interesowała Cie implementacja dwóch pierwszych podejśc tj. 2PC i choreografii to przykład znajdziesz w tym repozytorium. Jeżeli chcesz zobaczyć już faktyczną implementację sagi (na dużo prostrzym przykadzie) to zapraszam Ciebie do obejrzenia odcinka naszego kursu „Distributed .NET Core” 🙂

 

 

 

 

 

 

You may also like...