Jak przetworzyć operacje tylko raz ? Outbox i deduplikacja

Jakiś czas temu w poście Czy Twoja aplikacja na pewno będzie ostatecznie spójna ? opisywałem problem pojawiający się przy integracji pomiędzy usługami. Może on występować gdy używamy narzędzi, które nie mają możliwości z pudełka być spięte w atomową transakcję np. baza danych mssql i szyna komunikatów rabbitmq. Połowicznym rozwiązaniem tego problemu jest oubox pattern. Zapewnia on jednak dostarczenie wiadomości do usługi przynajmniej raz. Jak słusznie zauważono w komentarzach usługa odbierająca wiadomość, aby przetworzyć ją idempotentnie, musi zastosować deduplikacje wiadomości. Dzięki wzorcowi outbox i implementacji idempotenego przetworzenia wiadomości z użyciem de duplikacji uzyskujemy przetworzenie operacji dokładnie raz. Jednak jak i w moim poprzednim poście jak i w wielu implementacjach tego podejścia w internecie pojawia się miejsce na błąd, który może złamać założenia przetworzenia żądania tylko raz.
Idempotentność – właściwość pewnych operacji, która pozwala na ich wielokrotne stosowanie bez zmiany wyniku.
Outbox i deduplikacja
Wygląda to mniej więcej tak. Użytkownik wysyła żądanie do serwera. Nasz kod przed opublikowaniem zdarzenia na kolejkę dodaje w transakcji nowe/zmienione dane oraz samo zdarzenie do bazy danych. Następnie proces działający w tle publikuje zdarzenie na szynę komunikatów i oznacza wiadomość jako wysłaną. Tak pokrótce działa outbox pattern. Jeśli przy oznaczaniu wiadomości jako wysłanej, baza danych z jakiegoś powodu nie zapisze danych, to nasz kod opublikuje wiadomość ponownie (at least once delivery). Gdy opublikowana wiadomość dotrze do usługi, która ma ją przetworzyć, musi ona w jakiś sposób sprawdzić, czy wiadomość nie została już kiedyś przetworzona.
https://docs.particular.net/nservicebus/outbox/
Szerszy kontekst
Z technicznego punktu widzenia wszystko wygląda dobrze. Jednak jak zwykle diabeł tkwi w szczegółach. Zastanówmy się jak odróżnić jedną operację od drugiej? Co czyni operację unikalną ? Czy z biznesowego punktu widzenia każde żądanie wysłane do naszego systemu przez użytkownika to zawsze nowa operacja ? Spójrzmy na przykładową implementację wzorca outbox z mojego poprzedniego postu.
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(); } }
Identyfikatorem operacji jest tutaj Id zamówienia. Po stronie usługi odbierającej sprawdzimy, czy zdarzenie “`PaymentCompletedSuccessfully“` dla zamówienia o danym Id zostało już przetworzone. Jeśli tak to je po prostu pomijamy. Jednak czy na pewno Id zamówienia pozwoli nam określić, czy dana operacja została już kiedyś przetworzona ? Spójrzmy na trochę inną implementację.
public Task SavePayment(Payment payment) { using (var tran = context.Database.BeginTransaction()) { context.Payments.Add(payment); context.Outbox.Add(new PaymentCompletedSuccessfully(Guid.NewGuid(), payment.OrderId)); await context.SaveChangesAsync(); await tran.Commit(); } }
Tutaj identyfikatorem operacji będzie zawsze nowy Guid. Ponownie po stronie odbierającej sprawdzimy, czy wiadomość o danym Id już kiedyś do nas dotarła. Jednak czy to wystarczy ? Odpowiedź brzmi nie. Dlaczego ? Rozważmy następujący przykład awarii w naszym systemie:
- Klient tworzy zamówienie w systemie
- Aplikacja dodaje zamówienie do tabeli zamówień oraz tabeli outbox nadając nowy guid (5f78) wiadomości.
- Klient traci połączenie z internetem kiedy ma dostać odpowiedź od serwera lub nasz serwer ulega awarii i nie wysyła potwierdzenia przetworzenia żądania (timeout).
- Proces w tle publikuje wiadomość na kolejke
- Usługa odbierająca zdarzenie sprawdza, czy przetworzyła już wiadomość z takim id (5f78) i pomniejsza saldo konta o wartość zamówienia.
- Klient ponawia żądanie, myśląc, że nie zostało ono przetworzone ze względu na błąd.
- Aplikacja dodaje zamówienie do tabeli zamówień oraz tabeli outbox nadając nowy guid (6g73) wiadomości.
- Klient dostaje potwierdzenie od serwera, że operacja się udała.
- Proces w tle publikuje wiadomość na kolejke
- Usługa odbierająca zdarzenie sprawdza, czy przetworzyła już wiadomość z takim id (6g73) i pomniejsza saldo konta o wartość zamówienia.
W powyższym przykładzie klient kończy z dwoma zamówieniami i podwójną opłatą pobraną z jego konta. Zabezpieczyliśmy się przed awarią po stronie serwera, ale awaria wystąpiła po stronie klienta i nasz proces przetworzenia operacji na to pozwolił.
Odporność na awarie
Jeśli naszym zadaniem jest zbudowanie systemu odpornego na awarie i nie możemy pozwolić na duplikacje operacji, to musimy jakoś przeciwdziałać takiej sytuacji. Aby temu zapobiec, musimy generować identyfikator wiadomości po stronie klienta lub policzyć sumę kontrolną z przychodzącego żądania. Następnie, deduplikacja powinna odbywać się już przy próbie stworzenia zamówienia np. poprzez unique constraint w bazie danych. W ten sposób zapewnimy poprawną deduplikacje i zarazem idempotencje operacji z punktu widzenia biznesowego.
Podsumowanie
Jeśli przy wysłaniu żądania nasz klient otrzymał błąd, wcale nie oznacza to, że żądanie nie zostało przetworzone. Oczywiście powyższy problem występuje również przy tradycyjnej monolitycznej aplikacji. Jednak jeśli zdecydowaliśmy się na użycie wzorca outbox i deduplikacje operacji to znaczy, że nie chcemy przetwarzać wiadomości więcej niż jeden raz. Przy budowie systemu odpornego na awarie, musimy także wziąć pod uwagę, że aplikacja front-endowa może stracić połączenie z naszym serwerem i użytkownik ponowi to samo żądanie kilka razy. Nasza implementacja wzorca outbox musi pozwalać na przekazanie wartości, która jednoznacznie zidentyfikuje unikalność operacji i pozwoli na przetworzenie jej idempotentnie.