Często w jednej operacji biznesowej musimy wykonać zapis do bazy danych oraz komunikację z jakimś zewnętrznym komponentem. Tym komponentem może być jakaś zewnętrzna usługa, broker wiadomości (np. Apache Kafka), serwer pocztowy, i tak dalej. Tu pojawia się pytanie: co powinno wykonać się pierwsze, zapis do naszej bazy danych, czy komunikacja z zewnętrznym komponentem? Zawsze istnieje ryzyko, że jedno z działań się nie powiedzie. Na przykład, możemy napotkać sytuację, w której udaje nam się prawidłowo zapisać dane do bazy, ale próba wyemitowania eventu na Kafkę kończy się niepowodzeniem. Może również wystąpić sytuacja odwrotna — uda nam się wysłać wiadomość na Kafkę, ale zapis do bazy danych nie powiedzie się. Wobec takiego ryzyka, jak można zabezpieczyć procesy w naszym systemie, aby zapewnić ich niezawodność i spójność? Zanim spróbuję odpowiedzieć na to pytanie, zobaczmy ten problem na przykładzie jakiegoś procesu biznesowego.
Problem ze spójnością w procesie zamawiania jedzenia
Za przykład posłuży nam aplikacja do zamawiania jedzenia, którą zaimplementowałem w architekturze mikroserwisów. Kodzik umieściłem na moim gicie — link znajdziesz na końcu wpisu.

Weźmy na tapet proces składania zamówienia. Podczas składania zamówienia usługa Order Service
powinna zapisać zamówienie w bazie danych i wyemitować zdarzenie orderCreated
, na które nasłuchują np. usługa Payment Service
po to, aby rozpocząć płatność. Żeby zachować spójność, oba te działania muszą się udać. Jeśli usługa Order Service
zapisze zamówienie w bazie danych, ale nie wyemituje zdarzenia orderCreated
, usługa Payment Service
nie obsłuży płatności. Jeśli usługa Order Service
wyemituje zdarzenie orderCreated
, ale nie zapisze zamówienia w bazie danych, to zamówienie nie może być przetworzone, mimo tego, że płatność może się dokonać.
Logicznym wydaje się, że event orderCreated
powinien być wyemitowany po zapisaniu zamówienia do bazy danych. Jednak PostgreSQL i Apache Kafka to dwa niezależne narzędzia, co oznacza brak gwarancji ich jednoczesnej dostępności lub niezawodności sieci. W usłudze Order Service
, podczas tworzenia zamówienia, aplikacja zapisuje dane do bazy, a następnie wysyła zdarzenie do Kafki. Gdy baza danych jest niedostępna, proces kończy się wyjątkiem i zdarzenie nie jest wysyłane (stan spójny). Gdy aplikacja nie może się połączyć z brokerem wiadomości, zamówienie zostanie zapisane w bazie danych, ale event się nie wyśle do Kafki. To powoduje niespójność systemu, ponieważ mimo zapisania danych, nigdy nie emitujemy zdarzenia.
No i mamy problem 🙁

Rozwiązanie problemu ze spójnością — Outbox Pattern
Rozwiązaniem powyższego problemu może być zastosowanie Outbox Pattern. Ten wzorzec zapewnia spójność danych, wykorzystując możliwości transakcyjnej bazy danych. W praktyce, zamiast natychmiastowego wysłania zdarzenia do Kafki, informacje o zamówieniu oraz zdarzeniu orderCreated
zapisujemy w dwóch oddzielnych tabelach, a wszystko to w ramach jednej transakcji bazodanowej. Jak wiadomo, taka transakcja jest atomowa, czyli zapisze się do obu tabel, lub do żadnej.

Teraz kiedy mamy już zapisane zdarzenie w bazie danych, aplikacja może je emitować do Kafki, ale w ramach innego procesu. Sposób, w jaki to osiągniemy, sprowadza się do szczegółów implementacyjnych. Możemy wykorzystać schedulera, który co kilka sekund sprawdza zawartość tabeli „outbox”. Inną metodą jest wykorzystanie techniki Change Data Capture (CDC), polegającej na czytaniu logów transakcyjnych bazy danych. Jeszcze inna to skorzystanie z mechanizmu „hook”, który jest uruchamiany po udanej transakcji na bazie danych itd. Warto również zaznaczyć, że niekoniecznie ta sama aplikacja musi emitować ten event — możemy mieć oddzielną usługę publishera. Wybór zależy od naszych potrzeb. Wróćmy do tego, co udało nam się osiągnąć za pomocą Outbox Pattern.
Niezależnie od wybranej metody, teraz gdy nie uda nam się przesłać zdarzenia do Kafki, możemy próbować tyle razy, ile tylko chcemy. Nie musimy martwić się utratą wiadomości, bo mamy ją zapisaną w bazie danych. Gdy zdarzenie trafi do brokera, aplikacja może usunąć event z bazy lub zmienić jego statusu. Dzięki temu, podczas kolejnej próby wysyłki, system nie weźmie go pod uwagę. Oczywiście i tutaj istnieje jakieś ryzyko, że zdarzenie pomyślnie trafia do brokera, ale nie udaje się zmienić jego statusu lub usunąć go z bazy danych w konsekwencji czego zdarzenie może zostać wyemitowane ponownie.
Warto zastosować idempotentnego konsumenta podczas implementacji wzorca Outbox, ponieważ gwarantuje on przetworzenie każdego zdarzenia tylko raz, niezależnie od liczby odbiorów tej samej wiadomości. Zazwyczaj konsument zapisuje unikalny identyfikator wiadomości w bazie danych, co umożliwia mu stwierdzenie, czy przetworzył już dane zdarzenie.
Oczywiście, stosowanie Outbox Pattern wiąże się z dodatkowymi połączeniami do bazy danych. W związku, z tym powinniśmy go używać do kluczowych procesów w naszym systemie, tam gdzie chcemy zapewnić większą niezawodność dostarczania zdarzeń.
Outbox Pattern a bazy nierelacyjne
W przypadku baz nierelacyjnych z reguły nie możemy skorzystać z atomowości transakcji i zapisać naszą wiadomość w osobnej tabeli. Dlatego w takim wypadku nie będziemy mieć dwóch kolekcji, gdzie jedna reprezentuje order
, a druga outbox
. Zazwyczaj podchodzi się do tego tak, że tworzy się jedną kolekcję order
i w niej jest dodatkowa sekcja outbox
.
{
"id": "6597a450-4317-11ee-be56-0242ac120002",
"customer-id": "9341a7e8-4317-11ee-be56-0242ac120002",
...,
"outbox": [
{
"id": "6597a450-4317-11ee-be56-0242ac120002",
"customer-id": "9341a7e8-4317-11ee-be56-0242ac120002",
...,
"published": false
}
]
}
Oczywiście, po wysłaniu wiadomości aktualizujemy ten dokument, aby w późniejszym procesowaniu nie brać go już pod uwagę. Jednak tu też trzeba pamiętać o zaimplementowaniu wzorca idempotentnego konsumenta, tak samo jak w przypadku bazy relacyjnej.
Podsumowanie
Jeśli w Twoim projekcie chcesz zwiększyć niezawodność wysyłania eventów dla kluczowych procesów, warto pomyśleć o wzorcu Outbox Pattern. Często łączymy Outbox Pattern z innymi wzorcami projektowymi stosowanymi w architekturze mikroserwisów. Przykładem jest wzorzec Saga, którego używamy do zarządzania długotrwałymi transakcjami rozproszonymi pomiędzy różnymi usługami. Możesz dowiedzieć się więcej na ten temat tutaj -> wpis o wzorcu Saga.
Linki
Poniżej znajduje się link do kodu aplikacji do zamawiania jedzenia, który posłużył nam jako przykład. Znajdziesz tam między innymi zaimplementowany omawiany wzorzec Outbox.
Jeśli chcesz sprawdzić swoją wiedzę z zakresu Senior Java Developer, zapraszam Cię do mojego quizu link poniżej ⬇.
Jeśli wpis Ci się podobał, daj mi znać w komentarzu i zostaw swój email, aby być na bieżąco. Twój feedback będzie dla mnie motywacją do dalszego blogowania 💪
Zostaw swój e-mail, a raz na jakiś czas wyśle Ci info o nowych postach na blogu. Tylko wartościowe e-mail!