Czy Twoja aplikacja na pewno będzie ostatecznie spójna ?

Cześć, dziś chciałbym opowiedzieć o pewnym zjawisku, które zauważyłem w projektach, w których brałem udział. W dzisiejszych czasach prym wiedzie dzielenie aplikacji na mikroserwisy. Wraz z podziałem naszej monolitycznej aplikacji na n-mikroserwisów zwykle godzimy się na to, by stan naszej aplikacji nie będzie spójny od razu. Jest to spowodowane tym, że propagacja zdarzeń i komend pomiędzy usługami zwykle dzieje się asynchronicznie oraz sieć nie jest niezawodna. Ma to swoje uzasadnienie w tkzw PACELC theorem. W skrócie mówi on, że w razie podziału sieci P - Parition of network
musimy wybrać pomiędzy dostępnością naszego systemu A - Available
, a jego spójnością C - Consistency
. W przypadku normalnego działania E - Else
musimy także dokonać wyboru, czy chcemy aby nasz system odpowiadał szybko L - Low latency
, czy będzie zawsze spójny C - Consistency
. W większości przypadków decydujemy się na system typu PA
+ EL
. Jego największą zaletą jest user experience
oraz to, że nasz system jest “zawsze” dostępny. Prowadzi to do sytuacji, gdzie nawet przy wewnętrznych problemach z siecią pomiędzy naszymi usługami użytkownik może np. nadal dokonać zakupu produktu, co może być kluczowe z biznesowego punktu widzenia. Godzimy się także na to, że stan naszej aplikacji po jakimś czasie będzie ostatecznie spójny (Eventual consistency).
Nawiązując do modelu ostatecznej spójności, opiszę tutaj z czym bardzo często się spotykam ostatnimi czasy w aplikacjach rozproszonych. Załóżmy, że nasz biznes zajmuje się sprzedażą produktów. Przykład nie ma tutaj większego znaczenia. Gdy użytkownik dokona płatności, chcemy zapisać szczegóły tej płatności oraz zmienić status zamówienia na opłacony. Kod monolitycznej aplikacji zwykle wygląda mniej więcej następująco:
public async Task SavePayment(Payment payment) { using (var tran = context.Database.BeginTransaction()) { context.Payments.Add(payment); var order = new Order { Id = payment.OrderId }; context.Orders.Attach(order); order.Status = OrderStatus.Paid await context.SaveChangesAsync(); tran.Commit(); } }
Powyższy kod jest dosyć trywialny.
- Otwieramy transakcje bazodanową
- Dodajemy płatność
- Zmieniamy status zamówienia
- Zapisujemy zmiany
- Zatwierdzamy transakcje
Jak pewnie każdy już wie, transakcje na bazie danych są operacjami atomowymi. Wszystkie operacje w transakcji muszą zakończyć sukcesem. W innym przypadku nie zostanie ona zatwierdzona. Daje nam to natychmiastową spójność bazy danych i aplikacji. Co gdy chcemy naszą aplikacje z jakiegoś powodu rozproszyć ? Załóżmy, że sprzedajemy tak dużo produktów, że chcemy nasz mechanizm płatności skalować horyzontalnie. Wydzielamy więc kod odpowiedzialny za płatności i zamówienia do oddzielnych usług, które mają swoje oddzielne bazy danych. Komunikacja między usługami będzie asynchroniczna, by zredukować opóźnienia dla naszych użytkowników. Klasyczny schemat przepływu wyglądałby mniej więcej tak:
Poprzednia metoda zostanie teraz rozbita na dwie aplikacje. Kod w usłudze płatności wygląda teraz tak:
public Task SavePayment(Payment payment) { context.Payments.Add(payment); await context.SaveChangesAsync(); await bus.PublishEventAsync(new PaymentCompletedSuccessfully(payment.OrderId)); }
Natomiast kod w usłudze zamówień tak:
public async Task Handle(PaymentCompletedSuccessfully @event) { var order = new Order { Id = @event.OrderId }; context.Orders.Attach(order); order.Status = OrderStatus.Paid; await context.SaveChangesAsync(); }
Jak widzimy pozbyliśmy się transakcji, a stan naszej aplikacji jest propagowany do innych usług poprzez szynę danych. Jest to operacja asynchroniczna i nie wiemy kiedy serwis zamówień przetworzy zdarzenie potwierdzenia płatności. Co w powyższym przypadku może pójść nie tak ? To zależy jak publikujemy nasze zdarzenie, a konkretnie chodzi o tę linie:
await bus.PublishEventAsync(new PaymentCompletedSuccessfully(payment.OrderId));
Co się stanie gdy np. przy publikowaniu zdarzenia serwer straci połączenie z internetem ? Lub co gorsza nasz serwer straci dopływ prądu? W pierwszym przypadku jeszcze jesteśmy w stanie zalogować błąd (tylko lokalnie, ponieważ nie ma internetów :)). W drugim straciliśmy kompletnie informacje o błędzie. W powyższych przypadkach nasza architektura nigdy nie doprowadzi aplikacji do stanu spójnego. Po takiej awarii będzie wymagana bardzo żmudna i kosztowna ręczna analiza przypadków niespójności danych na produkcyjnym systemie. Jak zatem oszczędzić sobie takich problemów i dodatkowego stresu? Jest na to dosyć proste rozwiązanie.
Outbox pattern
Outbox pattern jest prostym rozwiązaniem i pomaga zapobiec bólowi głowy podczas niespodziewanych/spodziewanych awarii. Opiera się on na dodaniu zdarzenia wraz z danymi w jednej transakcji do bazy danych. Następnie osobny proces będzie to zdarzenie próbował pobrać z bazy i opublikować.
public Task SavePayment(Payment payment) { using (var tran = context.Database.BeginTransaction()) { context.Payments.Add(payment); context.Outbox.Add(new PaymentCompletedSuccessfully(payment.OrderId)); await context.SaveChangesAsync(); await tran.Commit(); } }
public Task Publish() { var message = await context.Outbox.FirstOrDefaultAsync(); if (message == null) return; await bus.PublishAsync(message); context.Outbox.Remove(message); await context.SaveChangesAsync(); }
Proces będzie wyglądał następująco:
- Otwieramy transakcje bazodanową
- Dodajemy płatność
- Dodajemy zdarzenie które chcemy opublikować
- Zapisujemy zmiany
- Zatwierdzamy transakcje
- Oddzielny proces w tle próbuje opublikować zdarzenie i usunąć zdarzenie z tabeli.
Niektóre frameworki wspierają powyższy wzorzec out of the box, np: NServiceBus.
Edit 05.10.2020: Ta implementacja ma pewną wadę. Więcej o tym tutaj.
Podsumowanie
Outbox pattern moim zdaniem jest przydatnym narzędziem do imitacji transakcji rozproszonej, a jest za razem o wiele bardziej wydajny. Myślę, że warto się zastanowić, czy nasza operacja nie powinna być odporna na ewentualne awarie, które prędzej czy później i tak się pojawią. Polecam także prezentację Szymona Kulca na temat transakcji: https://www.youtube.com/watch?v=-2JPfHzyig4