Strona główna / Artykuły

Projektowanie pipeline’u powiadamień bez duplikatów w SQS, SES i Redis

Jak projekt SQS z dwoma kolejkami, rozwiązanie awaryjne SES-to-SendGrid oraz atomowe zamykanie w Redis zapewniają, że dokumenty biznesowe trafiają do odbiorców dokładnie raz, nawet przy dużej obciążeniu.

Wydawca: REACTAPP.TOP7 min czytania1509 słów

Źródła: How I Built a Notification Engine Handling 5M+ Events

Dwóch pracowników otrzymuje to samo zdarzenie w tym samym momencie. Każdy sprawdza rejestr e-maili, nie znajduje żadnego zapisu i wysyła wiadomość. Dostawca otrzymuje dwukrotnie tę samą zamówienie, a zespół dowiaduje się o tym dzięki zgłoszeniu obsługi klienta, a nie poprzez monitorowanie. Ten artykuł przedstawia mechanizm powiadamiania w platformie łańcucha dostaw typu B2B, która rozwinęła się do obsługi ponad pięciu milionów zdarzeń miesięcznie, oraz trzy problemy produkcyjne, które na niego wpłynęły: utracone e-maile, e-maile zablokowane podczas generowania plików PDF oraz duplikaty.

Czego musi dostarczyć ten mechanizm

W oprogramowaniu typu SaaS do zarządzania łańcuchem dostaw żywności, takie operacje biznesowe jak tworzenie lub edycja zamówienia czy przeprowadzanie inwentaryzacji muszą generować dokumenty przeznaczone dla odpowiednich osób: zamówienia zakupowe, zamówienia sprzedaży, listy dostaw, notatki o przyjęciu towarów oraz faktury. Jedno zdarzenie może więc składać się z kilku kroków: wygenerowanie pliku PDF, jego przechowywanie oraz wysłanie go e-mailem do kilku odbiorców. Ten sam mechanizm służy również do wysyłania wiadomości SMS i WhatsApp w ramach kilku innych zastosowań.

Konsekwencje są poważne. Powiadomienie, które nigdy nie przychodzi, to nie jest błąd estetyczny; brak zamówienia zakupowego może zatrzymać rzeczywisty towar w drodze do klienta.

Dwie kolejki: jedna decyduje, druga dostarcza

Konstrukcja opiera się na dwóch kolejkach Amazon SQS z wyraźnie rozdzielonymi zadaniami.

Główna kolejka to miejsce, gdzie podejmowane są decyzje. Jeden konsument czyta każde przychodzące zdarzenie i określa, co ono oznacza: który dokument należy wygenerować, który kanał użyć oraz kto powinien go otrzymać.

Kolejka podrzędna jedynie dostarcza wiadomości. Pracownicy odpowiedzialni za dostawę nie stosują żadnych reguł biznesowych; przenoszą one wiadomości do dostawców.

Jedna wiadomość na odbiorcę

Najważniejszą decyzją w tym układzie jest to, że każdy odbiorca otrzymuje własną wiadomość w kolejce podrzędnej. Zamówienie, które musi dotrzeć do dziesięciu osób, staje się dziesięcioma niezależnymi wiadomościami. Jeśli dostawa do jednej adresy się nie powiedzie, pozostałe dziewięć nadal zostanie wysłanych, a ponowna próba dotyczy tylko tego nieudanego odbiorcy, zamiast wysyłać wiadomości ponownie do wszystkich.

To rozdzielenie pozwala również izolować zmiany. Zastąpienie starego dostawcy lub kanału nowym wpływa jedynie na stronę dostawy; kod odpowiedzialny za decydowanie o tym, co wysłać, nie musi wiedzieć, w jaki sposób przekazywane są wiadomości.

Dlaczego nie jedna kolejka na kanał?

Odrębne kolejki dla e-maili, SMS i WhatsApp zapewniłyby każdemu kanałowi niezależną przepustowość oraz mechanizmy radzenia sobie z awariami. Przy obecnym wolumenie nie było to konieczne, a dodanie kolejki w momencie potrzeby jest tańsze niż utrzymywanie czterech od samego początku.

Awaria numer jeden: e-maile, które nigdy nie dotarły

Dostawcy usług e-mailowych mogą zawieść – czasem wyraźnie, poprzez błąd, a czasem dyskretnie, poprzez odpowiedź bez oczekiwanych pól. Amazon SES jest tutaj głównym dostawcą, a odpowiedź bez identyfikatora wiadomości jest traktowana jako awaria, podobnie jak wyjątek. W obu przypadkach ta sama wiadomość jest wysyłana zamiast tego przez SendGrid:

// simplified
try {
  const res = await ses.send(email);
  if (!res.MessageId) throw new Error("No MessageId from SES");
} catch (err) {
  await sendgrid.send(email); // fallback
}

Zwróć uwagę na wyraźne rzucenie błędu, gdy brakuje MessageId: przekierowuje to niejednoznaczny przypadek do tej samej ścieżki awaryjnej co w przypadku poważnego błędu. Warto zrozumieć ten kompromis. Jeśli SES zaakceptował e-mail, ale odpowiedź została utracona, mechanizm awaryjny może dostarczyć go ponownie, więc ta warstwa faworyzuje opcję „dostarczony przynajmniej raz” zamiast „dostarczony co najwyżej raz”. W przypadku dokumentów biznesowych jest to zazwyczaj właściwy wybór, ale to kolejny powód, dla którego ważna jest poniżej opisana procedura eliminacji duplikatów.

Gdy oba dostawcy zawiodą, wiadomość trafia do kolejki dla e-maili nieudanych i jest wysyłana ponownie po upływie określonego czasu. Ponadto odrębny mechanizm ponownego wysyłania przekierowuje e-maile zarejestrowane jako nieudane, dzięki czemu istnieje droga powrotu dla wszystkich tych, które przeszły niezauważone.

Drugi problem: pliki PDF, które zawiodły pod obciążeniem

E-maile z dokumentami podlegają ścisłej zasadzie: nic nie jest wysyłane, dopóki nie istnieje plik PDF. Odbiorcy potrzebują rzeczywistego zamówienia, a nie linku powracającego z błędem. Klient uruchamia funkcję Lambda do generowania PDF, zapisuje plik w S3 i dopiero wtedy wysyła e-mail zawierający link do niego.

Zasada jest słuszna, ale stworzyła ukrytą zależność. Gdy system miał opóźnienia i generowanie PDF się nie udało, e-mail nie wykazywał widocznego błędu; po prostu nigdy się nie wysyłał, ponieważ czekał na plik, który nigdy nie został stworzony. Rozwiązaniem było zapewnienie generowaniu PDF własnych prób ponownych, aby tymczasowy błąd w renderowaniu nie anulował już w tle powiadomienia.

Błąd trzeci: ten sam e-mail, dwa razy

Duplikaty pochodziły z trzech różnych źródeł, a każde wymagało innego rozwiązania:

  • zwykłe błędy logiczne, które zostały naprawione jako taki błędy;
  • próby ponownego wysłania wiadomości, które faktycznie zostały już dostarczone;
  • wyścig między dwoma konsumentami obsługującymi ten sam zdarzenie jednocześnie.
  • Dlaczego sprawdzenie logu nie wystarcza

    Od samego początku pipeline sprawdzał log e-maili przed wysyłką: jeśli w записie widniało, że wiadomość została wysłana, próba ponownego wysłania była pomijana. To dobrze radzi sobie z sekwencyjnymi próbami. Nie może jednak powstrzymać wyścigu, ponieważ odczytanie logu i zapisywanie w nim to dwie oddzielne operacje. Dwaj pracownicy mogą odczytać informację „nie wysłano” przed tym, jak ktokolwiek z nich coś zapisać, i obaj kontynuować pracę. Kolejki standardowe SQS dostarczają wiadomości przynajmniej raz, więc to, że ta sama wiadomość trafia do dwóch konsumentów, jest normalnym zachowaniem, a nie rzadkim wypadkiem.

    Aтомiczny zamek w Redis

    Rozwiązaniem było zastosowanie blokady atomowej w Redis przed sprawdzeniem logów. Klucz łączy numer zamówienia, adres e-mail odbiorcy oraz typ dokumentu i wygasa po około pięciu minutach:

    // simplified
    const key = `notif:${orderId}:${email}:${docType}`;
    const locked = await redis.set(key, "1", "NX", "EX", 300);
    if (!locked) return; // someone else already has it
    

    SET z opcją NX zapisuje klucz tylko wtedy, gdy jeszcze nie istnieje, a EX 300 ustawia czas wygaśnięcia na 300 sekund w tym samym poleceniu. Ponieważ sprawdzenie istnienia i zapis odbywają się jako jedna operacja na serwerze Redis, dokładnie jeden pracownik otrzymuje wartość prawdziwą; wszyscy pozostali widzą null i wracają wcześniej. Dzięki temu eliminuje się luki pozostawione przez sprawdzenie logów.

    Należy pamiętać o kilku cechach tego podejścia:

    • Ważna jest granularność klucza. Uwzględnienie typu dokumentu zapobiega pomieszaniu zamówienia zakupowego z fakturą dla tego samego zamówienia i odbiorcy jako duplikatów.
  • TTL pełni rolę zaworu bezpieczeństwa. Jeśli proces pracuje awaryjnie po uzyskaniu blokady, klucz wygasa i można spróbować ponownie później. Czas ważności powinien wyraźnie przewyższać czas normalnej wysyłki, a idealnie także czas oczekiwania na widoczność w SQS, aby blokada wolnego procesu mogła wygasnąć, gdy ten nadal wysyła dane.
  • Blokada zapobiega duplikatom powstającym jednocześnie; trwały rejestr nadal rejestruje te pojawiające się później.
  • Ta sama koncepcja występuje w API HTTP jako klucze idempotentne; artykuł na klucze idempotentne w punktach końcowych POST w Node.js omawia ten aspekt.

    Jak skończył się system

    Po tych zmianach silnik przetwarza ponad pięć milionów zdarzeń miesięcznie w związku z zleceniami, zapasami i dokumentami, przy 98,9% udanych dostaw oraz opóźnieniu p95 poniżej 1,2 sekundy.

    Co zrobić inaczej następnym razem

    • Rozpoznawaj awarie przed klientami. Zbyt wiele problemów pojawia się najpierw jako skargi, a dopiero później w dziennikach. Alarmy dotyczące nieudanych dostaw oraz głębokości kolejki wiadomości bez adresata powinny być obecne już w pierwszej wersji, a nie w późniejszej.
    • Zapobiegaj zanikaniu wydarzeń. Niektóre wydarzenia znikały z nieznanych przyczyn. System transakcyjny typu outbox, który rejestruje wydarzenie wraz z operacją biznesową i przekazuje je później, znacznie zmniejsza liczbę utraconych wydarzeń. Artykuł na temat tabel outbox i inbox wyjaśnia ten mechanizm.
    • Zachowuj jedną podkolejkę, dopóki to możliwe. Wspólna kolejka dostaw sprawdziła się; konieczne jest jej podzielenie tylko wtedy, gdy jeden kanał zaczyna ograniczać pozostałe.

    Lista kontrolna przy tworzeniu własnego rozwiązania

    • Zachowaj decyzje dotyczące routingu w jednym miejscu, a dostawę w innym.
    • Wysyłaj jedną wiadomość na adresata, aby jeden błąd nie mógł zablokować reszty.
    • Nie polegaj na jednym dostawcy i traktuj brak identyfikatora wiadomości jako błąd.
    • Przypisz nieudane wiadomości konkretnemu celowi: kolejce dla wiadomości nieodebranych z możliwością ponownych prób.
    • Nigdy nie wysyłaj e-maila z dokumentem, zanim ten jeszcze nie istnieje, i spróbuj ponownie wykonać krok jego tworzenia.
    • Zrób proces usuwania duplikatów atomowym, ponieważ metoda sprawdzenia przed zapisaniem pozostawia okno ryzyka.
    • Ustaw powiadomienia o błędach, aby klienci nie pełnili roli twojego systemu monitoringu.

    Podsumowanie

    Żadna z tych mechanizmów ochronnych nie została zaprojektowana od samego początku; każdy z nich dodano po tym, jak coś się zepsuło w środowisku produkcyjnym, i właśnie w ten sposób rozwijają się większość procesów dostawy. Lekcja polega nie tyle na poszczególnych rozwiązaniach, ile na sposobie myślenia: zakładaj dostawę przynajmniej raz, zakładaj, że dostawcy i zależności będą awaryjnie funkcjonować w tle, oraz upewnij się, że każdy krok jest albo idempotentny, albo chroniony przez coś atomowego.

    Literatura pokrewna

  • Projektowanie backendów do rozmów w rzeczywistym czasie: pomieszczenia, trwałość danych i skalowanie — Dowiedz się, jak zaprojektować backend do rozmów w czasie rzeczywistym przy użyciu Socket.IO, PostgreSQL i Redis, omawiając tematy takie jak pomieszczenia, kolejność przechowywania wiadomości, status obecności użytkowników oraz skalowanie na kilka serwerów.