Blog
Symfony Messenger — co się dzieje, gdy wiadomość zostanie przetworzona dwa razy?
Worker może wykonać operację, a mimo to wiadomość wróci do kolejki. To nie musi być błąd Messengera — kluczowe jest zaprojektowanie bezpiecznego ponownego wykonania.
Zamówienie obsłużone. Dlaczego komunikat wrócił?
Wyobraźmy sobie aplikację, która po otrzymaniu OrderCreated wysyła potwierdzenie, tworzy fakturę i aktualizuje zamówienie. Dostawca poczty przyjmuje wiadomość, ale proces roboczy kończy działanie, zanim potwierdzi transportowi zakończenie obsługi. Komunikat może wrócić, choć część pracy została już wykonana.
To przykład ilustracyjny, nie opis incydentu w projekcie GiSoft. Pokazuje istotną trudność: dostawca usługi, baza aplikacji i transport komunikatów mogą znać różne fragmenty wyniku. Brak potwierdzenia nie cofa wysłanego e-maila ani zatwierdzonego zapisu faktury.
W transporcie asynchronicznym, który ponownie dostarcza niepotwierdzone komunikaty, może wyglądać to tak:
Moment Skutek biznesowy Transport
A Dostawca przyjął mail Trwa obsługa
B Proces się zatrzymał Brak potwierdzenia
C Skutek nadal istnieje Ponowne dostarczenie
możliweSzczegóły zależą od transportu i konfiguracji. Utrata połączenia, upływ okresu ukrycia komunikatu przed innymi odbiorcami czy limitu czasu obsługi mogą mieć różne konsekwencje. Messenger nie zapewnia jednej wspólnej gwarancji dostarczania dla wszystkich transportów, w tym obsługi synchronicznej.
Ponowienie po wyjątku w handlerze i ponowne dostarczenie po utracie procesu roboczego to również różne mechanizmy. Oba mogą jednak ponownie uruchomić tę samą operację biznesową.
Częściowy sukces: e-mail i eksport faktury
Poniższe fragmenty korzystają z hipotetycznych usług projektu. Pominięto konstruktory, deklaracje właściwości, definicje komunikatów i rejestrację handlerów w Messengerze. Metody takie jak sendOrderConfirmation() i transaction->run() są kontraktami aplikacji, nie API Symfony ani SDK dostawcy.
Ten handler wysyłki potwierdzenia nie chroni przed duplikatami:
final class SendOrderConfirmationHandler
{
public function __invoke(SendOrderConfirmation $message): void
{
$order = $this->orders->get($message->orderId);
$this->mailer->sendOrderConfirmation($order);
}
}Zakładamy, że orders->get() zwraca istniejące zamówienie albo zgłasza wyjątek, a adapter pocztowy wysyła żądanie bezpośrednio do dostawcy. Jeżeli jedynie dodaje kolejny komunikat do kolejki, problem przenosi się do jego odbiorcy.
Lokalna flaga „wysłano” nie zamyka tej luki. Zapisana przed wysyłką może spowodować pominięcie e-maila, którego nigdy nie nadano; zapisana po niej pozostawia miejsce na duplikat. Mechanizm idempotencji dostawcy może usuwać powtórzone żądania wysyłki na określonych przez niego warunkach. Nie jest to uniwersalna gwarancja, że odbiorca otrzyma dokładnie jeden e-mail.
Podobna sytuacja występuje przy eksporcie faktury:
public function __invoke(CreateInvoice $message): void
{
$invoice = $this->invoiceService->create($message->orderId);
$this->externalAccounting->send($invoice);
$this->repository->markAsExported($invoice);
}System księgowy może przyjąć fakturę, zanim nie powiedzie się markAsExported(). Jeżeli skonfigurowana polityka przewiduje ponowienie, handler ruszy od początku, a nie od ostatniego wiersza. Odnalezienie faktury w lokalnej bazie może zapobiec utworzeniu drugiego lokalnego rekordu, ale nie chroni samo w sobie przed drugim dokumentem w księgowości. Eksport potrzebuje własnego stabilnego identyfikatora i możliwości sprawdzenia wyniku po stronie zewnętrznej.
Identyfikuj operację, nie numer ponowienia
Warto rozróżnić cztery rzeczy: komunikat transportu, zamierzoną operację biznesową, próbę jej wykonania i wynikający z niej skutek. Dwa komunikaty mogą zlecać tę samą operację. Z kolei jedno zamówienie może wymagać kilku różnych, uzasadnionych operacji.
Idempotencja oznacza tutaj, że powtórzenie tej samej operacji nie wywoła dodatkowych, niezamierzonych skutków biznesowych. Nie wymaga fizycznego wykonania kodu PHP tylko raz.
Rozważmy niepełny przykład płatności:
$paymentGateway->charge($order->getAmount());To metoda hipotetycznego adaptera. Fragment nie pokazuje waluty, autoryzacji ani ochrony przed duplikatami. Kluczem zatwierdzonej operacji płatniczej może być charge-OP-2026-00045. Jej identyfikator zapisujemy przy tworzeniu operacji i zachowujemy podczas ponowień, kolejnych dostarczeń oraz ręcznego wznowienia. Licznik ponowień transportu nie powinien stawać się częścią nowego klucza.
Nowa, odrębnie zatwierdzona próba płatności wymaga innego identyfikatora operacji. Sam brak pewności co do wyniku poprzedniej nie uzasadnia jego zmiany. Podobnie invoice-order-123 identyfikuje właściwą fakturę tylko wtedy, gdy model dopuszcza jedną taką fakturę na zamówienie; reservation-928 może wskazywać konkretną rezerwację towaru.
Znaczenie mają zakres klucza u dostawcy, czas jego przechowywania, kontrola parametrów i dostępne metody sprawdzania wyniku. Losowy identyfikator może się sprawdzić, jeśli zostanie raz zapisany przy operacji i będzie ponownie używany. Generowanie go przy każdym wysłaniu komunikatu niweczy deduplikację. Również lokalne zapisy trzeba przechowywać przez okres odpowiadający możliwości ponownego wykonania operacji. Identyfikator należy powiązać z rodzajem operacji, encją i istotnymi parametrami. Ten sam identyfikator przy innym żądaniu oznacza konflikt, nie poprawnie obsłużony duplikat.
Transakcja lokalna potrzebuje ochrony przed powtórzeniem
Dwukrotnie można wykonać także operację dotyczącą wyłącznie bazy:
$stock->decrease(1);
$entityManager->flush();Tutaj $stock jest zarządzaną encją stanu magazynowego, a decrease(1) zmniejsza liczbę sztuk. Przy początkowym stanie dziesięciu sztuk dwa zatwierdzone wykonania mogą pozostawić osiem zamiast dziewięciu. Każda transakcja z osobna może być poprawna. flush() przekazuje zmiany do bazy; jeśli istnieje nadrzędna transakcja, jej końcowe zatwierdzenie jest osobnym krokiem.
Poniższy GenerateInvoiceHandler pokazuje sprawdzanie duplikatu przy wykonaniach sekwencyjnych. Nie jest kompletną implementacją bezpieczną przy współbieżności:
final class GenerateInvoiceHandler
{
public function __invoke(GenerateInvoice $message): void
{
$this->transaction->run(function () use ($message): void {
$operationId = $message->operationId;
if ($this->processedOperations->exists($operationId)) {
return;
}
$this->invoiceService->generate($message->orderId);
$this->processedOperations->markProcessed($operationId);
});
}
}W tym wariancie generate() może wykonywać wyłącznie lokalne zmiany w bazie. Faktura i zapis zakończonej operacji muszą zostać zatwierdzone albo wycofane razem, na tym samym połączeniu transakcyjnym. Pominięta implementacja run() musi rzeczywiście zapewniać tę granicę i zapisywać oczekujące zmiany ORM przed zatwierdzeniem.
Dwa procesy mogą jednocześnie nie znaleźć zapisu operacji i wejść do generate(). Wstępne exists() jest więc optymalizacją, a nie gwarancją. Jedno z rozwiązań wymaga unikalnego klucza operacji w kolumnie bez wartości NULL. Przy konflikcie podczas zapisu zakończenia przegrywająca transakcja musi wycofać również własne zmiany faktury. Obsługa duplikatu powinna nastąpić poza nieudaną transakcją, ze sprawnym kontekstem persystencji i sprawdzeniem już zatwierdzonej operacji. Innego błędu ograniczenia nie wolno uznać za poprawnie obsłużony duplikat.
Alternatywą może być atomowe zarezerwowanie operacji albo odpowiednia blokada. Fragment nie dostarcza żadnego z tych mechanizmów. Wycofanie lokalnych zmian nie cofnęłoby też zewnętrznego żądania wykonanego wewnątrz generate().
Jeżeli model rzeczywiście wymaga jednej faktury na zamówienie, ograniczenie takie jak invoice.order_id UNIQUE stanowi drugą, odrębną ochronę. Kolumna nie może dopuszczać NULL, a zakres unikalności musi odpowiadać regule biznesowej. Faktury częściowe czy korekty mogą wymagać innej reguły. Ograniczenie w bazie chroni lokalne rekordy, nie e-mail ani płatność już przyjętą przez dostawcę.
Równoległe płatności i niepewny wynik
Sprawdzenie obiektu w pamięci nie koordynuje procesów:
if ($order->isPaid()) {
return;
}
$order->markAsPaid();Ten fragment zmienia stan lokalny; nie obciąża klienta ani nie potwierdza płatności. Jeśli podobny warunek osłania wywołanie bramki, dwa procesy mogą odczytać „nieopłacone”, zanim którykolwiek zapisze zmianę, i oba zlecić obciążenie.
Atomowa aktualizacja warunkowa może pozwolić jednemu procesowi zarezerwować pracę:
UPDATE orders
SET payment_started = true
WHERE id = :id
AND payment_started = false;Ten parametryzowany SQL zakłada unikalny identyfikator zamówienia i kolumnę logiczną bez NULL, początkowo równą false. W takim protokole tylko proces, którego aktualizacja zmieniła jeden wiersz, może przejść dalej. Zero zmienionych wierszy nie oznacza „płatność udana”. Rezerwacja musi zostać zatwierdzona, zanim oprzemy na niej dalsze działanie; pozostałe reguły płatności nadal obowiązują.
Flaga nie zapisuje wyniku zewnętrznego ani nie przywraca pracy po awarii. Trzeba odróżnić przerwanie przed wysłaniem żądania od przerwania po przyjęciu płatności przez dostawcę. Samo wyzerowanie flagi i kolejne obciążenie odtwarza pierwotne ryzyko. Nadal potrzebne są trwały zapis operacji, ten sam klucz u dostawcy i ustalona procedura uzgadniania wyniku.
Także przekroczenie limitu czasu HTTP oznacza, że wynik może być nieznany. Dostawca mógł wykonać operację, zanim zaginęła odpowiedź. Jeśli API na to pozwala, sprawdzamy wynik po referencji zewnętrznej lub identyfikatorze operacji i stosujemy zasady ponawiania tego dostawcy. Gdy nie da się bezpiecznie ustalić wyniku, należy zatrzymać automatyczne próby i przekazać sprawę do wyjaśnienia.
Zwykła transakcja Doctrine obejmuje uczestniczące w niej zmiany w bazie. Nie sprawia, że razem z nimi wycofa się operacja bramki płatniczej, serwera pocztowego czy brokera. Takiej gwarancji nie daje również utrzymywanie blokady bazy podczas żądania HTTP.
Publikacja po zatwierdzeniu czy przez outbox?
Niezależny transport asynchroniczny może udostępnić komunikat przed zatwierdzeniem powiązanej transakcji. Przykładowo producent wykonuje persist() i flush() dla Order w otwartej transakcji, po czym wysyła OrderCreated. Inny proces odpytuje bazę przez osobne połączenie i jeszcze nie widzi zamówienia; producent zatwierdza transakcję dopiero później. Widoczność zależy od transportu, izolacji i faktycznego udziału zapisów w transakcji.
Odroczenie wysyłki do czasu zatwierdzenia rozwiązuje problem kolejności, ale komunikat przechowywany tylko w pamięci nadal może zginąć przy awarii między zatwierdzeniem a publikacją. „Po obsłudze bieżącej magistrali” nie oznacza automatycznie „po każdej transakcji bazy”: znaczenie mają kolejność middleware i właściciel transakcji. Dokumentacja Symfony wskazuje wymaganą kolejność dispatch_after_current_bus oraz doctrine_transaction.
Transactional outbox zapisuje zamiar publikacji trwale, razem ze zmianą biznesową. Poniżej pokazano granicę operacji, nie wykonywalny SQL:
Jedna lokalna transakcja bazy
Zapis Order
Zapis OutboxMessage(OrderCreated)
COMMIT
--------------------------------------------
Po zatwierdzeniu
Publikator -> transport -> odbiorca
Publikacja lub dostarczenie może się powtórzyć
Odbiorca chroni własną operację biznesowąOba zapisy muszą uczestniczyć w tej samej odpowiedniej transakcji bazy. Osobny publikator odczytuje zatwierdzone wpisy i zapisuje postęp publikacji. Jeśli wyśle komunikat, lecz zatrzyma się przed zapisaniem sukcesu, może wysłać go ponownie. Outbox zabezpiecza wspólny zapis zmiany i zamiaru publikacji; nadal wymaga monitorowania publikatora oraz odbiorców odpornych na duplikaty, a nie założenia wykonania dokładnie raz.
Czytelne granice awarii
Handler z kilkoma skutkami jest trudny do wznowienia, gdy zawiedzie ostatni krok:
public function __invoke(OrderCompleted $message): void
{
$this->stock->decrease($message->orderId);
$this->invoice->create($message->orderId);
$this->mailer->sendConfirmation($message->orderId);
$this->crm->notify($message->orderId);
}Tutaj $this->stock jest usługą obsługującą całe zamówienie, a nie wcześniejszą encją stanu magazynowego: przyjmuje identyfikator zamówienia. Jeśli wywołanie CRM nie powiedzie się po udanych operacjach magazynu, faktury i poczty, ponowienie może objąć wszystkie cztery kroki.
Jedną z możliwości jest wspólne zatwierdzenie wymaganych zmian lokalnych i zapisów dalszych operacji, a następnie ich niezależna obsługa. Nie wymaga to podziału na mikroserwisy. Każdy krok nadal potrzebuje własnej tożsamości operacji, ochrony przed duplikatami i jawnych zależności tam, gdzie liczy się kolejność. Sam podział klas nie zapewnia bezpieczeństwa.
Ponowienia powinny być ograniczone i dopasowane do błędu. Chwilowa niedostępność może uzasadniać kolejną próbę; niepoprawne dane czy odmowa autoryzacji zwykle wymagają korekty. Niepewny wynik zewnętrzny wymaga uzgodnienia. Warto sprawdzić zainstalowaną wersję Messengera i obsługę wyjątków: nie każdy mechanizm ponawiania musi respektować ten sam limit prób.
Jeżeli skonfigurowano transport błędów, trafiają do niego komunikaty zgodnie z polityką obsługi niepowodzeń. Nie należy zakładać, że takie miejsce istnieje automatycznie. Ręczne wznowienie to kolejne wykonanie handlera, nie kontynuacja od nieudanego wiersza. Przed nim trzeba ustalić, które wcześniejsze skutki już wystąpiły, i zachować dotychczasowy identyfikator operacji biznesowej.
Diagnoza operacji w kilku systemach
Zacznijmy od identyfikatora operacji, nie tylko od wyjątku. Powiązanie wykonań z zatwierdzonym stanem bazy, referencjami dostawcy i dostępną historią transportu pozwala ustalić, co zakończyło się przed awarią. Dopiero wtedy można zdecydować o ponowieniu.
Przydatne pola logów to typ komunikatu, identyfikator operacji i encji, dostępne metadane próby lub ponowienia, identyfikator procesu, początek i koniec obsługi, wynik transakcji oraz referencja zewnętrzna. Licznik ponowień Messengera nie musi obejmować każdego ponownego dostarczenia przez broker ani ręcznego wykonania. Dane o zatwierdzeniu, wycofaniu i potwierdzeniu odbioru wymagają odpowiedniej instrumentacji; nie pojawiają się automatycznie w każdym logu.
Warto sprawdzić restarty procesów, limity pamięci, równoległe działanie wersji podczas wdrożenia i limity czasu transportu. Zbyt krótki czas do ponownego dostarczenia może dopuścić równoległą obsługę w transportach korzystających z tego mechanizmu. Kontrolowane zatrzymywanie procesów i obsługiwane ustawienia keepalive mogą ograniczyć to ryzyko, ale nie zastępują deduplikacji. Opcje trzeba sprawdzić dla używanej wersji i transportu.
Nie należy logować całych encji, treści komunikatów, danych uwierzytelniających, pełnych danych płatniczych ani zbędnych danych osobowych.
Testuj powtórzenie i częściowy sukces
Poniższy fragment testu Pest wykonuje jedną operację dwukrotnie, sekwencyjnie. Pominięte przygotowanie testu musi zdefiniować $orderId, $operationId, $handler i $invoiceRepository wewnątrz funkcji testowej, z istniejącym zamówieniem i odizolowaną bazą. Klasa komunikatu oraz metoda repozytorium należą do projektu, nie są funkcjami pomocniczymi Pest. Argumenty nazwane wymagają PHP 8 lub nowszego; używana wersja Pest ma odrębne wymagania.
it(
'nie tworzy drugiej faktury przy ponowieniu operacji',
function (): void {
// Pominięto przygotowanie danych i zależności testu.
$message = new GenerateInvoice(
orderId: $orderId,
operationId: $operationId,
);
$handler($message);
$handler($message);
$invoices = $invoiceRepository->findByOrderId($orderId);
expect($invoices)->toHaveCount(1);
},
);Test sprawdza istnienie jednej faktury w badanym przypadku sekwencyjnym. Nie obejmuje potwierdzeń transportu, dwóch jednoczesnych transakcji ani rzeczywistego zachowania dostawcy. Przygotowanie persystencji musi zapewniać zapis zmian zgodny z kontraktem handlera i odczyt z bazy, a nie tylko sprawdzenie kolekcji w pamięci.
Dodatkowe testy powinny obejmować:
- Dwa procesy z osobnymi połączeniami do bazy i tym samym identyfikatorem operacji, w tym konflikt unikalności oraz wycofanie transakcji.
- Przyjęcie operacji przez atrapę dostawcy, po którym występuje błąd lokalny. Ponowienie musi zachować klucz i użyć ustalonej ścieżki sprawdzenia wyniku lub deduplikacji.
- Niepewny wynik po przekroczeniu limitu czasu oraz ręczne wznowienie z pierwotnym identyfikatorem operacji.
- Właściwą regułę biznesową: jedną fakturę, jedno zamierzone zmniejszenie stanu albo jedną zatwierdzoną operację płatniczą.
Atrapa sprawdza protokół aplikacji, nie gwarancje dostawcy. Testy kontraktowe w odpowiednim środowisku testowym dostawcy mogą zweryfikować założenia integracji, ale nie są dowodem dostarczenia dokładnie raz.
Celem jest poprawny wynik biznesowy mimo powtórzeń. Pomagają w tym tożsamość operacji, lokalne ograniczenia danych i możliwość wyjaśnienia niepewnych wyników zewnętrznych. Dwukrotna obsługa komunikatu nie musi oznaczać dwukrotnego obciążenia klienta.
Dokumentacja techniczna
- Symfony Messenger 7.4: transporty, ponowienia i middleware transakcyjne — https://symfony.com/doc/7.4/messenger.html
- Doctrine ORM: transakcje i współbieżność — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/reference/transactions-and-concurrency.html
- AWS: transactional outbox, atomowy zapis i powtórzona publikacja — https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html
- Stripe: przykład zasad idempotencji konkretnego dostawcy, nie uniwersalny kontrakt API — https://docs.stripe.com/api/idempotent_requests
