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.
Ź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;
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.
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
- Wewnątrz usługi powiadamiania z wieloma dostawcami: kolejki, mechanizmy backupowe i zaufanie — Przegląd projektu niezawodnej usługi powiadamiania: przechowywanie danych uwierzytelniających, mechanizmy backupowe dostawców, ponowne próby wysyłki przez BullMQ i obsługa błędów, sprawiedliwość wobec użytkowników oraz weryfikacja podpisów webhooków.