Oskar Dudycz

Pragmatic about programming

Jak nie zgubić zdarzenia, czyli wzorce Outbox i Inbox

2020-10-05 oskar dudyczWzorce

Cześć!

Cześć!

Mój Dziadek powiedział mi kiedyś, że ja będę już całe życie ciężko pracował. Do dziś nie wiem do końca czy w jego ustach to był komplement czy raczej obawa. Zapewne jedno i drugie. Mam tendencje do brania zbyt dużo na siebie (Czy Ty też tak masz?).

Tak też chyba jest i tym razem. Pracuję właśnie z kolegą nad szkoleniem zamkniętym z praktycznego Event Sourcingu. Jeszcze chyba w życiu nie naklepałem tyle slajdów (mimo, że wolę raczej prezentacje z ich małą ilością). No ale nie ma tego złego. Posprzątałem już aktualnie moje repozytorium o Event Sourcing w .NET(https://github.com/oskardudycz/EventSourcing.NetCore/) - ujednoliciłem przykłady Core’owej architektury.

Tak też chyba jest i tym razem. Pracuję właśnie z kolegą nad szkoleniem zamkniętym z praktycznego Event Sourcingu. Jeszcze chyba w życiu nie naklepałem tyle slajdów (mimo, że wolę raczej prezentacje z ich małą ilością). No ale nie ma tego złego. Posprzątałem już aktualnie moje repozytorium o Event Sourcing w .NET(https://github.com/oskardudycz/EventSourcing.NetCore/) - ujednoliciłem przykłady Core’owej architektury.

Znajdziesz tam gotowe przykłady jak zacząć działać z CQRS, Event Sourcing ale też Kafką. Zachęcam do obejrzenia i pytań.

Tak jak mam w zwyczaju, nawet z zamkniętych szkoleń i warsztatów kody źródłowe udostępniam publicznie, także można się spodziewać niedługo nowych materiałów na temat:

  • sag,
  • outbox i inbox pattern,
  • ćwiczeń do przerobienia samemu.

Pierwszy PR z kontraktami już się grzeje: https://github.com/oskardudycz/EventSourcing.NetCore/pull/27.

Właśnie o outbox i inbox pattern chciałbym się dzisiaj podzielić. W zeszłym tygodniu napisałem na grupie .NET Developers Dev Help odpowiedź na pytanie o tym jak sprawić, żeby wiadomości na kolejce się nam nie zgubiły. Wbrew pozorom jest to jeden z powszechnych problemów systemów rozproszonych. Pisałem już nieco o tym we wcześniejszych newsletterach, ale mamy 3 semantyki gwarancji dostarczalności:

  1. At-most once - jest to najprostsza gwarancja. Można ją mieć w komunikacji nawet in memory/in process (np. przy pomocy wzorca mediator, o którym niedawno pisałem). Polega na tym, że jak wyślemy request/zdarzenie to zostanie dostarczone maksymalnie raz. Zaletą tego jest to, że nie musimy się mierzyć z idempotencją, ale minusem jest to, że nie mamy żadnej gwarancji dostarczenia wiadomości. Jeśli poleci błąd (albo pakiet zostanie na sieci zgubiony, serwer padnie, lub w ogóle nie będzie działał) to po prostu wiadomość zostanie zgubiona i nie będzie powiadomiona. Stąd nazwa semantyki, bo będzie dostarczona 0 albo 1.
  2. At-least once - jest to gwarancja, że wiadomość zostanie dostarczona co najmniej raz. Dzięki temu mamy pewność, że wysłany event zostanie zawsze dostarczony. Nie mamy jednak pewności ile razy zostanie dostarczony. Można to osiągnąć poprzez ponawianie publikowania po stronie producenta (np. przy pomocy outbox pattern) lub ponowienie obsługi po stronie konsumenta (np. przy pomocy inbox pattern). Wadą tego jest, że wiadomość może zostać obsłużona kilkukrotnie i jak nie bronimy się przed tym (poprzez poprawną obsługę idempotencji) to możemy mieć zepsute dane (np. dodamy fakturę kilkukrotnie). Może tak nastąpić np. gdy faktura została poprawnie zapisana do bazy, ale poleciał timeout i zostało ponowione procesowanie.
  3. Exactly-once - jest to semantyka, która mówi, że raz wysłana wiadomość zostanie obsłużona dokładnie raz. Jest bardzo trudne (czasem wręcz niemożliwie) do osiągnięcia. Do tego jest potrzebne poprawne wsparcie idempotency tak aby nigdy operacja wykonana kilkukrotnie nie spowodowała skutków ubocznych.

No i spieszę się wyjaśnić słowo klucz - Idempotencja (poświęciłem kiedyś jej nawet cały newsletter). Tak jak opisałem powyżej chodzi o to, żeby niezależnie od tego ile razy akcja została wykonana to efekt był taki jakby była wykonana raz. Można to np. osiągnąć jeśli jedna wysłana wiadomość będzie miała swoje unikalny identyfikator i będziemy mieć storage, który pozwoli nam zapisywać idki przeprocesowanych zdarzeń. Dzięki temu jeśli już wiadomość była przeprocesowana to ją zignorować. Jest to jednak zachowanie kosztowne, bo zawsze trzeba weryfikować i zapisywać id, dodatkowo nie zawsze jest to problem jak coś zostanie kilka razy przetworzone (np. update rekordu). Zwykle realizuje się to poprzez weryfikację logiki biznesowej (np. sprawdzenie czy faktura dla danej rezerwacji już została wygenerowana). Należy pamiętać, że żeby zapewnić poprawnie idempotencję musimy mieć storage, który pozwala na weryfikację unikalności.

Do poprawnej obsługi semantyki at-least once- czyli właśnie dostarczenia zawsze wiadomości używane są dwa wzorce:

  1. Outbox Pattern - Jest to wzorzec pozwalający zapewnić, że wiadomość zostanie wysłana (np. na kolejkę) z sukcesem co najmniej raz. Osiąga się to poprzez opakowanie Unit of Work (transakcją) wszystkich zapisów i niepozwolenie na zapisanie danych (np. danych agregatu) bez zapisu zdarzenia w tabeli ze zdarzeniami. Mając zapisane takie zdarzenia możemy zrobić proces działający w tle, który będzie sprawdzał czy w tabeli są jakieś zdarzenia, które nie zostały jeszcze wysłane. Jeśli takie znajdzie to próbuje je wysłać i po tym jak dostanie potwierdzenie o sukcesie wysłania (np. ACK z kolejki) to oznacza zdarzenie jako wysłane. Dlaczego więc zapewnia at-least-once, a nie exactly-once? Bo zapis do bazy może się nie powieźć (np. nie będzie odpowiadała), wtedy proces obsługujący outbox pattern spróbuje za jakiś czas ponownie wysłać zdarzenie i będzie próbował to robić dopóki nie zostanie zapisana poprawnie zdarzenie oznaczone jako wysłane. Można też ten wzorzec zaimplementować poprzez użycie offsetu (czyli numeru zdarzenia, które zostało ostatnie wysłane). Jest to rozwiązanie prostsze i efektywniejsze. Podobnie działa Kafka
  2. Inbox Pattern - Jest to wzorzec analogiczny jak Outbox Pattern tylko używany do obsługi wiadomości przychodzących (np. z kolejki). Możemy ponownie mamy tabelę ze zdarzeniami przychodzącymi, do której wpadają zdarzenia po ich otrzymaniu. Tutaj odwrotnie najpierw zapisujemy zdarzenie w bazie i dopiero jak otrzymamy potwierdzenie zapisu to dajemy potwierdzenie do kolejki, że je przeprocesowaliśmy. Mamy ponownie przez to at-least once delivery. Potem działa proces podobny do outbox, który odpala handlery zdarzeń. Można implementację uprościć poprzez od razu wywoływanie handlerów i po zakończeniu pomyślnym procesowania wysyłania ACK do kolejki. Zaletą dodatkowej tabeli jest to, że możemy szybko przyjąć zdarzenia z szyny, a potem w dogodnym tempie i czasie przeprocesować je wewnętrznie nie ryzykując, że szyna padnie itd. Szczególnie przydatne do brokerów, które nie mają pamięci - jak np. RabbitMQ.

Wszystkie handlery muszą być idempotentne, czyli odporne na powtórne procesowanie zdarzeń, wtedy ponawiając jesteśmy pewni, że w końcu to przeprocesujemy.

Za tydzień postaram się podesłać konkretną techniczną implementację.

No dobrze, ale wracając do tezy mojego Dziadka. Oto kilka kolejnych przykładów na to, że za rzadko się nudzę:

A jak u Ciebie? Masz może jakiś przepis na nie dostanie garba?

Pozdrawiam serdecznie! Oskar

p.s. na koniec trochę pochwały, bo bardzo się cieszę - moja Córeczka skończyła dzisiaj rok! Nie wiem kiedy to zleciało. Boję się zamykać oczu, bo jak je otworze to może się okazać, że już ma 2 lata.

p.s.2 Od jakiegoś czasu w czwartki rano wysyłam jeszcze raz newsletter jako przypomnienie dla osób, które go nie otworzyły. Wiadomo jak to bywa z mailami - łatwo je przeoczyć i odłożyć na wieczne nigdy. Sporo osób sobie te przypomnienie chwali i wtedy dopiero je odczytuje. Jeśli jednak nie chcesz, żebym wysyłał Ci w takiej sytuacji maila jeszcze raz to daj znać.

  • © Oskar Dudycz 2019-2020