Tabele Outbox i Inbox: projektowanie webhooków odpornych na awarie
Dowiedz się, w jaki sposób transakcyjna skrzynka wyjściowa, idempotentna skrzynka przychodząca, odroczenie z uwzględnieniem stanu oraz kolejki listów uszkodzonych zapewniają niezawodną dostawę webhooków w AWS, Azure i GCP.
Webhooki wydają się najprostszą formą integracji: jedna strona wysyła żądanie HTTP POST, a druga je obsługuje. W praktyce niosą ze sobą wszystkie zagrożenia charakterystyczne dla systemów rozproszonych, ponieważ sieć łącząca dwa usługi może odrzucać żądania, wywoływać timeout w połowie procesu, dostarczać ten sam payload dwa razy lub przestawiać kolejność zdarzeń. Traktując webhooka jak zwykłe żądanie CRUD, w końcu stracisz powiadomienia, dwa razy uruchomisz skutki uboczne i masz do czynienia z dwoma systemami, które nie są zgodne co do tego, co się wydarzyło.
To przewodnik opisuje projekt, który sprawdza się w takich warunkach. Zobaczysz, dlaczego proste podejście zawodzi, jak skrzynka wysyłkowa typu transakcyjna zapewnia niezawodność webhooków wysyłanych, jak skrzynka odbiorcza idempotentna umożliwia bezpieczne ponawianie prób obsługi webhooków przychodzących, jak radzić sobie z wydarzeniami przychodzącymi w niewłaściwej kolejności, jak izolować dane, które nigdy nie mogą zostać przetworzone pomyślnie, oraz jakie usługi zarządzane w AWS, Azure i Google Cloud pasują do poszczególnych elementów tego rozwiązania.
Dlaczego oczywista implementacja powoduje utratę danych
Rozważmy backend typu SaaS obsługujący istotną zmianę stanu, np. realizację zamówienia lub aktywację subskrypcji. Partner dzwoni do twojej API, aby potwierdzić tę czynność, a twój serwis ma teraz dwa zadania:
- Zapisywanie nowego stanu, na przykład ustawienie statusu entity na
Active. - Poinformowanie usługi poniżej w łańcuchu o tym, że entity jest gotowa, poprzez wysłanie do niej webhooka.
Intuicyjny kod zapisuje dane do bazy danych, a następnie w kolejnym wierszu wysyła żądanie HTTP. Jest to dwukrotne zapisywanie: dwa niezależne systemy są aktualizowane jeden po drugim, bez żadnych powiązań między nimi.
Z tego wynikają bezpośrednio dwa scenariusze awarii:
- Proces zostaje przerwany pomiędzy tymi dwoma krokami. Baza danych nadal podaje, że entytet jest aktywny, ale żądanie nigdy nie zostało wysłane. Twoje dane są poprawne, usługa poniżej w łańcuchu nie wie o niczym, a nikt tego nie zauważy, dopóki klient się nie poskarży.
- Żądanie zostaje wysłane, a następnie transakcja się nie powiedzie. Usługa poniżej w łańcuchu otrzymała informację, że entytet jest aktywny, ale twoja baza danych cofnęła operację i nadal uważa ją za nieudaną.
Ani jedno z tych rozwiązań nie naprawia problemu. Jeśli umieścisz wywołanie HTTP na początku, pojawi się druga awaria; jeśli na końcu – pierwsza. Podstawową przyczyną jest to, że zatwierdzenie zmian w bazie danych oraz wywołanie sieciowe nie mogą zostać przeprowadzone atomowo jednocześnie, więc każdy awarii lub błąd w trakcie powoduje rozbieżność pomiędzy obiema stronami.
Nadawanie webhooków w sposób niezawodny za pomocą transakcyjnego systemu wysyłki
Wzorzec outbox eliminuje konieczność dwukrotnego zapisywania danych, ponieważ w ogóle nie przeprowadza się wywołania HTTP z ścieżki żądania. Zamiast tego intencja wysłania webhooka staje się danymi, które są zapisywane w tej samej transakcji bazodanowej co zmiana biznesowa. Albo obie operacje zostaną zatwierdzone, albo żadna.
Cztery kroki procesu outbox
- Rozpocznij transakcję. Operacja biznesowa uruchamia standardową transakcję bazodanową.
entities (na przykład ustawiając status na Active) i wstawia wiersz do tabeli outbox_events, który zawiera dokładny payload, który powinna otrzymać usługa docelowa.Tabela outbox
Tabela poniżej przechowuje jedną wiersz na każdą oczekującą powiadomienie. aggregate_type i aggregate_id wskazują, o który obiekt biznesowy chodzi, event_type określa, co się wydarzyło, payload zawiera treść do dostarczenia, a processed_at pozostaje pusty dopóki system przekazowy nie potwierdzi dostarczenia. Zapytanie dotyczące wierszy, w których processed_at ma wartość null, dostarcza systemowi przekazowemu listę zadań do wykonania. Należy pamiętać, że komentarze wewnątrz tekstu używają pojedynczego myślnika; w PostgreSQL komentarz wymaga dwóch myślników (--), więc należy to poprawić przed uruchomieniem zapytania.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(50), - e.g., 'Order' or 'User'
aggregate_id UUID, - e.g., Entity ID
event_type VARCHAR(100), - e.g., 'order.activated'
payload JSONB NOT NULL, - The exact webhook payload
created_at TIMESTAMP DEFAULT NOW(),
processed_at TIMESTAMP - Null until successfully sent
);
Co gwarantuje skrzynka wyjściowa, a czego nie
Jeśli serwer ulegnie awarii po zapisaniu zmian, nic się nie straci: wiersz nadal znajduje się w tabeli, a relay odnajdzie go podczas następnej próby. Jeśli końcowy punkt dostępu jest niedostępny, relay po prostu spróbuje ponownie, najlepiej z wykorzystaniem eksponencjalnego opóźnienia, aby nie obciążać problematycznego odbiorcy. Zmiana stanu i intencja powiadomienia nie mogą już się rozchodzić.
Kompromis polega na tym, że dostawa odbywa się przynajmniej raz. Przełącznik może pomyślnie wysłać żądanie, a następnie ulec awarii zanim zaktualizuje pole processed_at; w takim przypadku to samo zdarzenie zostanie ponownie wysłane podczas następnego uruchomienia. Jest to do przyjęcia tylko wtedy, gdy odbiorcy usuwają duplikaty, co dokładnie zapewnia wzorzec skrzynki odbiorczej po drugiej stronie. Włączenie wartości id z wiersza skrzynki wyjściowej do treści przesyłanej lub nagłówka daje odbiorcom stabilny klucz do usuwania duplikatów. Jeśli uruchamiasz kilka instancji przełącznika, upewnij się, że dwa procesy nie mogą jednocześnie przejąć tego samego wiersza; w PostgreSQL powszechnym sposobem na to jest wybieranie wierszy za pomocą opcji FOR UPDATE SKIP LOCKED.
Bezpieczne odbieranie webhooków za pomocą idempotentnej skrzynki odbiorczej
Teraz przesuńmy perspektywę na webhooki, które twoja usługa otrzymuje od partnerów lub systemów wyższego poziomu.
Załóżmy, że obsługa zdarzenia zajmuje pięć sekund, ponieważ wykonywa ona skomplikowane obliczenia lub czeka na blokadę utrzymywaną przez inny serwis. Klient HTTP nadawcy może poddać się, zanim udzielimy odpowiedzi, uznać, że nigdy nie otrzymaliśmy zdarzenia, i wysłać je ponownie. Wtedy to samo zdarzenie przychodzi dwa razy. Jeśli nasza obsługa za każdym razem wysyła e-mail lub tworzy rekord, klient otrzymuje dwa e-maile, a my mamy duplikat w bazie danych.
Wzorzec skrzynki odbiorczej oddziela przyjęcie webhooka od podjęcia wobec niego działań.
Cztery kroki procesu w skrzynce odbiorczej
- Otrzymanie i weryfikacja. Gdy tylko przychodzi żądanie, sprawdzamy jego podpis HMAC, aby upewnić się, że rzeczywiście pochodzi od partnera i nie został sfałszowany ani zmodyfikowany.
webhook_inbox, używając jako klucza unikalnego identyfikatora zdarzenia dostawcy oraz chroniąc ją za pomocą ograniczenia unikalności w bazie danych.200 OK, zanim zostanie uruchomiona jakakolwiek logika biznesowa.Tabela inbox
Tutaj każdy wiersz rejestruje osobę, która wysłała zdarzenie (partner_name), identyfikator nadawcy (partner_event_id), treść przesyłanej informacji, czy podpis został sprawdzony, oraz status, który może przyjmować wartości PENDING, PROCESSED lub QUARANTINED. Kluczową zasadą jest złożone ograniczenie UNIQUE(partner_name, partner_event_id) – to ono sprawia, że duplikaty stają się bezpiecznymi operacjami bez żadnych skutków. Podobnie jak w tabeli outbox, komentarze z pojedynczą kreską muszą zostać zamienione na --, aby PostgreSQL mógł przyjąć to zdanie.
CREATE TABLE webhook_inbox (
id UUID PRIMARY KEY,
partner_name VARCHAR(50), - e.g., 'Stripe' or 'GitHub'
partner_event_id VARCHAR(100), - The unique ID from the sender
payload JSONB NOT NULL,
signature_verified BOOLEAN,
status VARCHAR(20), - 'PENDING', 'PROCESSED', 'QUARANTINED'
received_at TIMESTAMP DEFAULT NOW(),
processed_at TIMESTAMP,
UNIQUE(partner_name, partner_event_id) - Prevents duplicate inserts
);
Dlaczego to ograniczenie pełni kluczową rolę
Ponieważ obsługa ta jedynie weryfikuje, wprowadza dane i zwraca odpowiedź, reaguje szybko, a nadawca rzadko doświadcza problemów z czasem oczekiwania. Gdy nadawca próbuje ponownie, nawet dziesięć razy z rzędu, ograniczenie dotyczące unikalności pozwala na udane wprowadzenie dokładnie jednej rekordu. Twój mechanizm obsługi powinien traktować błąd związaný z naruszeniem unikalności (lub wynik ON CONFLICT DO NOTHING) jako sukces i nadal zwracać 200 OK; w przeciwnym razie nadawca będzie dalej próbował wysłać zdarzenie, które już posiadasz. Ponieważ istnieje tylko jedna rekord, proces wykonywany jest tylko raz.
Dwa aspekty wymagają dokładnego potraktowania. Po pierwsze, usunięcie duplikatów zależy od tego, czy partner dostarcza stabilny identyfikator zdarzenia; większość dostawców webhooków go zawiera, ale należy to sprawdzić dla każdej integracji. Po drugie, procesor może ulec awarii po wykonaniu działań ubocznych, ale przed oznaczeniem wiersza jako przetworzonego, dlatego tam, gdzie to możliwe, należy wykonać zmianę biznesową oraz aktualizację stanu w jednej transakcji, a także sprawić, by działaania uboczne zewnętrzne były idempotentne. Aby dowiedzieć się więcej na temat usuwania duplikatów żądań za pomocą kluczy, zapoznaj się z kluczami idempotentności w punktach końcowych POST w Node.js.
Zarządzanie zdarzeniami przychodzącymi w niewłaściwej kolejności
Nawet gdy duplikaty są pod kontrolą, nie ma gwarancji, że zdarzenia przyjdą w takiej kolejności, w jakiej zostały wygenerowane. Twoja usługa może otrzymać entity.completed przed entity.started. Obsługa, która ślepo przetwarza każde zdarzenie, będzie wtedy próbować przenieść entity bezpośrednio z stanu draft do completed, co albo uszkodzi jego stan, albo spowoduje błąd typu 409 Conflict.
Kontrola każdej transformacji w oparciu o maszynę stanów
Rozwiązaniem jest przestanie traktowania zdarzeń jako poleceń do modyfikacji stanu i zaczęcie traktowania ich jako proponowanych transformacji, które muszą zostać zweryfikowane. Czasami określa się to jako silnik rozrachunku stanów, zgodnie z zasadami event sourcing: procesor porównuje przychodzące zdarzenie ze aktualnym stanem entity i decyduje, czy transformacja jest dopuszczalna.
Rysunek poniżej ilustruje tę decyzję. Jeśli zdarzenie ukończenia przychodzi, gdy obiekt jest nadal w stanie projektu, warunek wstępny nie został jeszcze spełniony, więc funkcja zgłasza to zdarzenie jako odroczone zamiast je zastosować. Komentarze wskazują dwa sposoby radzenia sobie z odroczeniem: pozostawienie wiersza w skrzynce odbiorczej i ponowna próba później, lub zapisanie przewidywanego stanu i czekanie na brakujące zdarzenie. Zdarzenie rozpoczęcia dla obiektu w stanie projektu jest ważną transycją i zostaje zastosowane. Traktuj to jako pseudokod: return status: 'DEFERRED'; nie jest prawidłowym JavaScriptem i powinno być return { status: 'DEFERRED' };, a rzeczywista implementacja musiałaby również obsłużyć pozostałe kombinacje zdarzenia i stanu.
function processWebhookEvent(event, currentEntityState) {
if (event.type === 'entity.completed' && currentEntityState === 'draft') {
// The 'started' event hasn't arrived yet!
// We cannot transition from 'draft' directly to 'completed'.
// Option A: Leave it in the inbox and retry in 5 minutes.
// Option B: Store a "Projected State" and wait for the missing piece.
return status: 'DEFERRED';
}
if (event.type === 'entity.started' && currentEntityState === 'draft') {
return transitionTo('started');
}
}
Odroczenie jako pętla samonaprawcza
Weź zamówienie, w którym zdarzenie „shipped” dociera do ciebie przed zdarzeniem „paid”. Natychmiastowe zastosowanie statusu „shipped” wprowadziłoby zamówienie w stan, który twój model nie dopuszcza. Dzięki procesorowi świadomemu stanu sekwencja wygląda następująco:
- Dociera zdarzenie „Shipped”, a system sprawdza, że brakuje płatności, więc zdarzenie jest odroczone.
- Dociera zdarzenie „Paid”, jest ważne i aktualizuje stan zamówienia.
- Odroczone zdarzenie „shipped” jest ponownie spróbowane, tym razem stwierdza, że jego warunek wstępny został spełniony, i zostaje zastosowane.
Zdarzenia odroczone mogą znajdować się w dedykowanej kolejce do ponownych prób, na przykład Amazon SQS lub kolejce opartej na Redis, a proces w tle okresowo je ponawia. W rezultacie powstaje przepływ pracy, który odrzuca nieważne przejścia, ale ostatecznie osiąga prawidłowy stan bez pomijania żadnego zdarzenia. Należy jednak ustalić limit czasu, przez jaki zdarzenie może pozostawać odroczone: jeśli warunek wstępny nigdy nie nadejdzie, zdarzenie powinno ostatecznie zostać potraktowane jako błąd, a nie być ponawiane w nieskończoność, co prowadzi do następnej sekcji.
Izolacja problematycznych zdarzeń za pomocą ponownych prób i kolejki listów nieudanych
Część zdarzeń nigdy nie odniesie sukcesu, bez względu na to, jak często je próbujesz ponownie wysłać: błędny payload lub odniesienie do ID, którego nie ma w twojej bazie danych. Nazywa się je truciznami. Prosty procesor próbuje je wysyłać w nieskończoność, a jeśli kolejka jest przetwarzana w porządku, jedna błędna wiadomość może zablokować wszystkie poprawne zdarzenia znajdujące się za nią.
Standardowym rozwiązaniem jest polityka ponawianych prób z ograniczeniami i rosnącymi opóźnieniami, po której następuje kolejka wiadomości uszkodzonych (DLQ). Typowy harmonogram wygląda następująco:
- Pierwsza próba kończy się niepowodzeniem; czekaj jedną minutę.
- Druga próba kończy się niepowodzeniem; czekaj pięć minut.
- Trzecia próba kończy się niepowodzeniem; czekaj piętnaście minut.
- Czwarta próba kończy się niepowodzeniem; przenieś zdarzenie do DLQ.
DLQ może być tabelą w twojej własnej bazie danych lub funkcją zarządzanej kolejki. Ważne jest to, co dzieje się dalej: zdarzenia w DLQ powinny pojawiać się na wewnętrznym widoku administracyjnym i wywoływać alert o wysokim priorytecie, ponieważ każde z nich reprezentuje dane, których system nie był w stanie przetworzyć. Inżynier bada sytuację, naprawia błąd mapowania lub uszkodzone dane, a następnie odtwarza zdarzenie, aby mogło przejść normalną ścieżką przetwarzania. Należy jak najszybciej zaprojektować taką funkcję odtwarzania; bez niej odzyskiwanie stanu po problemach w DLQ staje się manualną edycją bazy danych pod presją.
Zmapowanie projektu na AWS, Azure i Google Cloud
Korzyści i skrzynki pocztowe znajdują się w twojej bazie danych relacyjnej, ale otaczające je elementy (wejścia, kolejki, procesory, DLQ) dobrze pasują do zarządzanych usług chmurowych, co znacznie zmniejsza obciążenie operacyjne. Struktura jest taka sama u wszystkich dostawców; zmieniają się jedynie nazwy produktów.
AWS
- Wejście: Amazon API Gateway przyjmuje napływające webhooki, a mechanizm autoryzacji Lambda sprawdza podpis HMAC przed tym, jak żądanie dotrze do serwera backendowego.
- Baza danych: Amazon Aurora PostgreSQL przechowuje tabele biznesowe wraz z tabelami
webhook_inboxioutbox_events, dzięki czemu obowiązują gwarancje transakcyjne. - Kolejki i DLQ: Standardowa kolejka SQS zarządza przetwarzaniem asynchronicznym, a skonfigurowana kolejka dead letter SQS odbiera wiadomości, gdy przekroczą maksymalną liczbę odbiorów. Standardowe kolejki gwarantują przynajmniej jeden odbiór, ale nie zachowują kolejności, co jest kolejnym powodem, dla którego ważne są wymienione wcześniej sprawdzenia idempotencji i stanu.
processed_at.Azure
- Wejście: Azure API Management odbiera webhooki, weryfikuje podpisy i przekazuje żądania do backendu.
- Baza danych: Azure Database for PostgreSQL Flexible Server przechowuje stan aplikacji oraz tabele skrzynki przychodzącej i wyjściowej.
- Kolejki i DLQ: Azure Service Bus zarządza wiadomościami i posiada wbudowany mechanizm przekierowywania nieudanych wiadomości, automatycznie odkładając je na bok po zakończeniu określonej liczby prób dostarczenia.
- Pracownicy: Azure Functions z wyzwalaczami Service Bus przetwarzają treści z skrzynki odbiorczej. Przełącznik do skrzynki wysyłkowej działa jako pętla w tle w Azure Container Apps lub jako Kubernetes CronJob w przypadku użycia AKS – sprawdza PostgreSQL pod kątem niewysłanych zdarzeń i dostarcza je przez HTTP.
Google Cloud
- Wejście: Google Cloud API Gateway obsługuje przychodzące HTTP webhooki oraz procesy autoryzacji.
- Baza danych: Cloud SQL dla PostgreSQL przechowuje dane relacyjne, w tym obie tabele.
- Kolejki i DLQ: Pub/Sub kieruje wiadomości asynchronicznie. Główna subskrypcja przetwarza zdarzenia, a temat na wiadomości nieodebrane przechowuje komunikaty, które nadal nie zostały potwierdzone po zakończeniu ustalonej liczby prób dostarczenia.
- Pracownicy: Usługi Cloud Run, które mogą zmniejszyć liczbę instancji do zera pomiędzy falami obciążenia, otrzymują wiadomości typu Pub/Sub w celu przetworzenia skrzynki odbiorczej. Przełącznik do skrzynki wysyłkowej to albo zadanie Cloud Run, albo usługa Cloud Run wywoływana według harmonogramu przez Cloud Scheduler, która sprawdza stan Cloud SQL i wysyła zaległe wydarzenia.
Aby zapoznać się z dodatkowymi wzorcami łączenia usług, takimi jak OAuth i odpornie działające wywołania API, zobacz sześć wzorców integracji do niezawodnego łączenia usług Node.js.
Główne wnioski
- Niezawodny system webhook to pipeline do przetwarzania zdarzeń, a nie para punktów końcowych HTTP.
- Nigdy nie aktualizuj bazy danych i nie wywołuj zdalnej usługi jako dwa niepowiązane kroki; zapisz wiersz do skrzynki wysyłkowej w tej samej transakcji i pozwól przełącznikowi go dostarczyć.
- Kolejka wysyłkowa zapewnia dostawę przynajmniej raz, więc każdy odbiorca musi usunąć duplikaty.
- Z poziomu odbiorcy należy zweryfikować dane, przechować je z ograniczeniem unikalności identyfikatora wydarzenia nadawcy, natychmiast potwierdzić odbiór i wykonać rzeczywistą pracę w procesorze zadaniowym.
- Waliduj każde wydarzenie w odniesieniu do swojej maszyny stanów i odkładaj te, których brakuje warunków wstępnych, określając maksymalny czas oczekiwania.
- Ogranicz liczby prób ponownych wysyłek, kieruj trwałe błędy do kolejki błędów z powiadamianiami i upewnij się, że odtwarzanie jest operacją priorytetową.
- Zarządzane kolejki takie jak SQS, Service Bus i Pub/Sub zapewniają funkcje ponownych prób oraz obsługę wiadomości błędnych, podczas gdy tabele bazodanowe gwarantują istotne zasady.
Literatura pokrewna
- Dlaczego kod backendu, który działa lokalnie, zawodzi przy rzeczywistym obciążeniu produkcji — Praktyczny przegląd założeń dotyczących środowiska, bazy danych, bezpieczeństwa i niezawodności, które sprawnie funkcjonują na localhost, ale powodują przerwy w działaniu, gdy napływa rzeczywisty ruch do środowiska produkcyjnego.
- Potknięcia w architekturze backendu, które utrudniają pracę zespołom używającym Reacta — Wyjaśnia pięć typowych błędów w projektowaniu backendu spotykanych w projektach opartych na Reactie – od niewłaściwego wykorzystania paradygmatu API po kruche implementacje – oraz rozwiązania architektoniczne zapewniające niezawodność na poziomie produkcyjnym.