Saga, czyli rozproszone procesy w praktyce
Cześć!
Co może pójść nie tak w rozproszonych systemach?
Wszystko!
Pewnie już o tym pisałem, ale lubię porównywać systemy rozproszone do Rocky’ego Balboa w ostatniej rundzie walki z Apollo Creedem. Słaniają się na nogach, są poobijane, ręka wisi, oko podbite, ale ciągle walczy i prze do przodu. I to jest pochwała! Monolity z kolei są często jak Najman, póki walka się nie zacznie to stoi, ale potem jak padnie to się już nie podnosi.
Sam miałem w tym temacie pewne złudzenia. Kiedyś próbowałem skonfigurować transakcje rozproszone przy pomocy WCF i MSDTC (jeśli nie znasz tych, któregoś z tych skrótów to ciesz się i ich nawet nie google’aj). Udało mi się osiągnąć całkiem dużo, a mianowicie tyle, że czasem to działało. Zwykle nie.
Dlaczego rozproszone transakcje są tak trudne lub wręcz niemożliwe do osiągnięcia? Wynika to z tego, że w danym konkretnym momencie czasu:
- wszystkie usługi muszą być dostępne i odpowiadać,
- koordynator, który będzie zarządzał transakcją będzie cały czas dostępny i będzie w stanie procesować poszczególne operacje,
- będzie to się działo na tyle szybko i granularnie, że nie będziemy tworzyć dużej kolejki oczekujących.
Jeśli dodamy do tego, że:
- deadlocki bywają głównymi problemami w normalnych transakcjach,
- zachowanie odpowiednich poziomów transakcyjności jest trudne nawet na jednej bazie,
- w przypadku błędu w jednej usłudze musimy zrobić rollback we wszystkich pozostałych (a wiemy ile rollbacki potrafią trwać).
Całość tego powoduje, że transakcje rozproszone są czymś tak delikatnym i podatnym na uderzenia jak… Najman. Ogólnie nie ma co próbować z nimi iść do poważnej, produkcyjnej walki.
Jakie mamy zatem alternatywy? Mamy w zasadzie 3:
- Sagę,
- Process Managera,
- Choreografię.
Wszystkie trzy w zasadzie sprowadzają się do jednej zasady: “zrób to sam”. Nie używamy bowiem żadnej konkretnej technologii do zarządzania transakcjami. Jak to zatem działa?
Pierwszym krokiem jest rozpisanie sobie procesu. W przykładach, które wysłałem w zeszłym tygodniu wygląda on następująco:
- Klient inicjuje koszyk zakupowy.
- Dodaje do niego produkty.
- Potwierdza chęć zakupu.
- W tym momencie rozpoczyna się proces zamówienia.
- Pierwszym krokiem jest zapłacenie za wybrane produkty.
- Jak to się uda to zamówienie zmienia status na opłacony i rozpoczynamy proces wysyłki.
- Jeśli wysyłka się powiodła to oznaczamy zamówienie jako zrealizowane.
Co jeśli jakaś operacja się nie powiedzie?
- Jeśli nie powiedzie się opłata to anulujemy zamówienie.
- Gdy nie powiedzie się wysłanie bo produktu już nie ma to wycofujemy opłatę - zwykle możemy, przelewy zwykle nie idą w tym samej chwili.
Oczywiście proces jest nieco uproszony, ale chyba kumasz o co chodzi?
Kolejnym krokiem jest określenie który moduł/serwis powinien obsłużyć poszczególne kroki procesu. Możemy go podzielić np. pomiędzy moduły:
- zarządzania koszykiem,
- zamówień,
- płatności,
- wysyłki.
No i teraz tak naprawdę musimy:
- zdefiniować zdarzenia, które zachodzą w procesie - np. potwierdzono koszyk, opłacono produkty, wysłano produkty,
- wyciągnąć żądania (komendy) które je powodują - np. typu rozpocznij zamówienie, opłać produkty, wyślij produkty).
Mając komendy oraz odpowiadające im zdarzenia możemy sobie zbudować taką “kanapkę” - akcja => reakcja, komenda => zdarzenie.
Mając tak rozpisany proces, podzielony na moduły jesteśmy w stanie określić gdzie możemy zapewnić lokalne transakcje. Zwykle w ramach jednego modułu - szczególnie jeśli używamy bazy relacyjne jesteśmy w stanie transakcję zapewnić. Dlaczego? Bo mamy zwykle jedną bazę danych.
Dlatego właśnie de facto zamiast zrobić jedną wielką rozproszoną transakcję tworzymy flow składający się z wielu małych transakcji. Każdy moduł powinien zadbać o poprawną obsługę i powiadomienie innych o tym, że operacja się powiodła lub nie.
Jak to robi? Najlepiej poprzez zdarzenie. Czyli jeśli udało się wysłać przesyłkę to puszczamy zdarzenie, jeśli brakuje produktu w magazynie to wysłanie zdarzenia o tym, że nie udało się wysłać przesyłki z takiego właśnie powodu. Dlaczego w ten sposób? Bo dzięki temu jeśli zdarzenia wysyłamy przez szynę, która ma pamięć (jak Kafka czy też RabbitMQ) dostajemy jeszcze większą odporność na wpadki. Jeśli dany serwis chwilowo jest niedostępny to proces nie zostanie przerwany, bo jak serwis wróci do życia to obsłuży zdarzenie.
Oczywiście można iść na ustępstwa i wysyłać komendy synchronicznie i opakowywać ich wysyłanie w try/catch. Tak się oczywiście robi. To od nas zależy jak silne gwarancje niezawodności chcemy zapewnić.
To czego musimy koniecznie uniknąć to tego, że proces zawiesi się nam w trakcie. Jak sobie z tym radzić? Najprostsze porady to:
- opakowywać cały command handler w try/catch, i w catchu wysyłać zdarzenie o błędzie,
- to nas nie uchroni oczywiście w 100% dlatego zawsze warto mieć w tle proces, który wyśle nam zdarzenie o maksymalnym czasie obsłudze procesu i go anuluje,
- a żeby być pewnym na 99,99% to jeszcze możliwość ręcznego jego zakończenia.
No dobra, ale co z tymi Sagami itd. Ględzę o teorii, a to przecież słowa klucze najważniejsze, czyż nie?
Sposoby koordynacji możemy podzielić na dwa główne podejścia:
- orkiestrację - gdzie jeden serwis jak dyrygent zarządza innymi przy pomocy batuty,
- choreografię - gdy zespoły wzajemnie reagują na swoje operacje.
Plusem choreografi jest to, że nie mamy tzw. “single point of failure”. Jeśli dyrygent zachoruje, to orkiestra może grać dalej. Minusem jest to, że mamy rozproszony proces, który jest trudno ogarnąć. Z perspektywy programisty developera jednego modułu to co wiemy, to że jak dostaniemy taką komendę to musimy wysłać takie i takie zdarzenie itd. Niby fajnie, ale wg mnie jednak warto znać całościowy obraz procesu. Bez tego jest trudno to wszystko posklejać. Pamiętajmy, że biznes nie interesują nasze wewnętrzne techniczne podziały - ich interesuje tak naprawdę efekt końcowy czyli sprawne działanie procesu. Dodatkowo tworzymy też (wg mnie) niejawny “coupling” między serwisami. No i czy jest coś bardziej nieefektywnego niż rozmyta odpowiedzialność?
Przykładami orkiestracji jest właśnie Saga i Process Manager.
Saga nie musi i nawet nie powinna wiedzieć skąd pochodzi zdarzenie i dokąd wychodzi komenda. Ona jest w zasadzie takim “głupim” dyspozytorem, który wie, że jak dostanie jakieś zdarzenie to na podstawie jego danych (i tylko tych danych) przesyła żądanie (komendę) gdzieś dalej.
Dzięki temu tak naprawdę taka Saga obojętnie gdzie się znajduje. Może być nawet wyciągnięta do osobnego modułu czy mikroserwisu.
To różni Sagę od Process Managera. Saga sama w sobie nie posiada stanu. Process Manager to z kolei taka maszyna stanu. Podejmuje decyzje nie tylko na podstawie przychodzących zadań, ale również stanu procesu, którym zarządza.
Ja osobiście wolę te rzeczy rozdzielić, wg mnie “głupia” Saga jest dużo bardziej elastyczna i też daje mniejszy narzut niż Process Manager. Jest po prostu mniej miejsc gdzie może się coś wywalić.
W swoim przykładzie pokazałem jak można przy pomocy agregatu (Order, który i tak musimy mieć) zapewnić, że będziemy mieć wszystko co potrzeba do procesowania na podstawie zdarzeń. Np. po zakończonej płatności powinniśmy wysłać zamówienie, a płatność nie ma żadnych informacji o konkretnym produkcie - niesie tylko informacje o tym ile trzeba zapłacić. Te dane mamy w agregacie zamówienia, dlatego tam leci komenda o zmianę stanu zamówienia, a Saga nasłuchuje na zdarzenie potwierdzające jego sukces. Oczywiście jest to nieco “sztuczka”. Ktoś pewnie by mógł powiedziec, że to niejawny Process Manager, ale wg mnie to jest prosta pragmatyczna zasada pokazująca jak można tworzyć bezstanowe Sagi.
Implementacja tutaj: https://github.com/oskardudycz/EventSourcing.NetCore/blob/main/Workshops/PracticalEventSourcing/Orders/Orders/Orders/OrderSaga.cs.
Dodatkowo przygotowałem zestaw rekomendowanych materiałów o rozproszonych procesach: https://github.com/oskardudycz/EventSourcing.NetCore#105-distributed-processes.
Ufff, ale się rozpisałem, ale mam nadzieję, że pomogłem.
Daj znać czy Ci się to podobało, czy masz jakieś pytania lub zastrzeżenia.
Pozdrawiam! Oskar
p.s. przypominam, że jutro (we wtorek 20.10) o 18:00 będę znowu próbował zrobić Event Store w godzinę, na spotkaniu Bydgoskiej Grupy .NET: https://www.meetup.com/pl-PL/Net-User-Group-Bydgoszcz/events/273745412/. Gorąco zapraszam!