Aplikacja może poprawnie zapisać zamówienie, a mimo to nie powiadomić systemu płatności, usługi zarządzania zapasami ani kolejki zadań. Może też opublikować powiadomienie, a następnie zakończyć działanie przed zatwierdzeniem zamówienia. W obu przypadkach systemy przestają być zgodne. Wzorzec transactional outbox w PHP rozwiązuje problem podwójnego zapisu: zmiana biznesowa i zapis zdarzenia są zatwierdzane razem w ramach tej samej transakcji bazy danych.
Outbox nie zapewnia natychmiastowego dostarczenia ani sam z siebie nie gwarantuje, że konsument przetworzy zdarzenie tylko raz. Jego celem jest uniezależnienie zatwierdzonego zdarzenia od drugiej operacji zapisu, która mogłaby zakończyć się niepowodzeniem. Aby poprawnie zastosować ten wzorzec, trzeba też zaprojektować obsługę równoczesnych odczytów, ponawianie prób, idempotencję i codzienną eksploatację.
Problem zapisu w bazie danych i publikowania zdarzenia

Załóżmy, że operacja zatwierdza zakup, a następnie publikuje zdarzenie OrderPlaced w kolejce. Jeśli proces PHP zakończy się między tymi działaniami, zakup będzie zapisany, ale nikt nie otrzyma zdarzenia. Jeśli zdarzenie zostanie opublikowane jako pierwsze, a transakcja bazy danych zostanie wycofana, konsument może podjąć działanie dotyczące nieistniejącego zakupu.
Transakcja SQL zwykle nie obejmuje również wywołania kolejki, zewnętrznego API ani brokera. Ręczna próba skoordynowania obu operacji nie eliminuje okna awarii — zmienia jedynie kolejność, w której ono występuje. Wzorzec transactional outbox eliminuje problem nieatomowego podwójnego zapisu, obejmując jedną transakcją zarówno zmianę biznesową, jak i wiersz reprezentujący zdarzenie do opublikowania.
Minimalny projekt tabeli outbox
Tabela powinna przechowywać wystarczające informacje, aby inny proces mógł zidentyfikować, dostarczyć i śledzić zdarzenie. Początkowy projekt może obejmować:
- Unikalny identyfikator: stabilny i możliwy do wykorzystania jako klucz idempotencji, na przykład UUID.
- Typ zdarzenia: na przykład
OrderPlaced, najlepiej z wersją schematu, jeśli format ewoluuje. - Ładunek: zserializowane dane potrzebne konsumentowi, bez zbędnych informacji wrażliwych.
- Data utworzenia: przydatna do ustalania kolejności, mierzenia wieku zdarzeń i diagnozowania opóźnień.
- Status i dane śledzenia: na przykład oczekujące, przejęte lub opublikowane; liczba prób, ostatni błąd i data ostatniej próby.
- Dane przejęcia: identyfikator workera i termin wygaśnięcia dzierżawy, jeśli do koordynowania konsumentów używany jest mechanizm lease.
Konkretny schemat zależy od silnika bazy danych i wolumenu danych. Dodaj indeksy wspierające wyszukiwanie oczekujących zdarzeń i unikaj skanowania całej tabeli w poszukiwaniu zadań. Określ również, jak długo przechowywać opublikowane wiersze i błędy — domyślnie nie przekształcaj outboxa w bezterminowe archiwum.
Zapisanie danych biznesowych i zdarzenia w jednej transakcji
W PHP kod zmieniający stan biznesowy i dodający zdarzenie musi korzystać z tego samego połączenia i tej samej transakcji. Ten przykład pokazuje sekwencję działań, a nie uniwersalny schemat SQL:
$pdo->beginTransaction();
try {
// Wstaw lub zaktualizuj zamówienie, korzystając z tego połączenia.
$orderId = saveOrder($pdo, $orderData);
$eventId = generateUuid();
$payload = json_encode([
'event_id' => $eventId,
'order_id' => $orderId,
], JSON_THROW_ON_ERROR);
$statement = $pdo->prepare(
'INSERT INTO outbox_events (id, event_type, payload, status, created_at)
VALUES (:id, :type, :payload, :status, CURRENT_TIMESTAMP)'
);
$statement->execute([
'id' => $eventId,
'type' => 'OrderPlaced',
'payload' => $payload,
'status' => 'pending',
]);
$pdo->commit();
} catch (Throwable $error) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
Jeśli transakcja zostanie zatwierdzona, zapisane będą oba rekordy; jeśli się nie powiedzie, żadnego z nich nie należy uznawać za zatwierdzony. Nie publikuj wiadomości do brokera w ramach transakcji w nadziei, że zapewni to atomowość operacji: zewnętrzne wywołanie nie uczestniczy w zatwierdzeniu SQL.
UUID z przykładu identyfikuje zdarzenie utworzone w tej transakcji, ale sam w sobie nie zapobiega duplikatom, gdy ponawiana jest cała operacja biznesowa. Jeśli takie ryzyko ma znaczenie — na przykład żądanie może zostać ponowione po przekroczeniu limitu czasu — użyj klucza idempotencji żądania albo ograniczenia unikalności dla operacji biznesowej. Gdy rozpoznasz ponowione żądanie, pobierz wynik i zdarzenie powiązane z pierwotną operacją, zamiast tworzyć kolejne zdarzenie z innym UUID.
Przetwarzanie oczekujących zdarzeń z obsługą współbieżności i odzyskiwania
Worker pobiera ograniczoną partię kwalifikujących się zdarzeń, przejmuje je i publikuje w miejscu docelowym. Mechanizm przejęcia powinien zapobiegać równoczesnemu przetwarzaniu tego samego zdarzenia przez kilka workerów albo przynajmniej umożliwiać bezpieczną obsługę takich duplikatów. Dostępne rozwiązania zależą od silnika: blokowanie wierszy, warunkowa zmiana statusu lub dzierżawy z terminem wygaśnięcia. Nie istnieje przenośne zapytanie, które poprawnie rozwiązuje wszystkie przypadki; sprawdź semantykę blokowania i izolacji używanej bazy danych.
Typowa strategia polega na przejęciu wierszy na ograniczony czas, opublikowaniu ich poza długotrwałą transakcją i oznaczeniu jako opublikowanych po otrzymaniu potwierdzenia z miejsca docelowego. Jeśli proces zakończy się po publikacji, ale przed aktualizacją statusu, dzierżawa wygaśnie i inny worker może opublikować zdarzenie ponownie. Jeśli status zostanie zmieniony na opublikowany przed publikacją, a proces zakończy się, zdarzenie może zostać utracone. Dlatego publikacja może mieć semantykę dostarczenia co najmniej raz, a nie dokładnie raz: może prowadzić do duplikatów, ale nie gwarantuje, że miejsce docelowe otrzyma zdarzenie. Faktyczne dostarczenie zależy od polityki ponawiania prób i od tego, czy miejsce docelowe znów stanie się dostępne przed wyczerpaniem prób, albo od podjęcia interwencji w celu odzyskania zdarzenia.
Korzystaj z małych lub konfigurowalnych partii i ograniczaj czas trwania każdego uruchomienia. Utrzymywanie blokad SQL podczas oczekiwania na zewnętrzną odpowiedź sieciową może pogorszyć działanie bazy danych. Rozmiar partii, czas trwania dzierżawy i częstotliwość odpytywania należy dostosowywać na podstawie pomiarów: duża partia nie zawsze poprawia wydajność i może zwiększyć opóźnienia lub utrudnić odzyskiwanie.
Idempotencja, ponawianie prób i zablokowane zdarzenia
Konsument musi tolerować wielokrotne otrzymanie tego samego zdarzenia. W przypadku efektu biznesowego zapisywanego w tej samej bazie danych można rejestrować identyfikator zdarzenia w tabeli przetworzonych zdarzeń z ograniczeniem unikalności i zastosować efekt w tej samej transakcji, która zapisuje ten identyfikator. Dzięki temu, jeśli transakcja zostanie zatwierdzona, identyfikator i efekt zostaną zapisane razem; jeśli się nie powiedzie, operację można ponowić bez ryzyka zatwierdzenia tylko jednego z nich.
Ten mechanizm nie zapewnia automatycznie idempotencji zewnętrznym efektom nietransakcyjnym. Jeśli konsument wywołuje API, wysyła wiadomość e-mail lub wykonuje inne działanie poza swoją bazą danych, awaria między wystąpieniem zewnętrznego efektu a lokalnym zapisem może spowodować powtórzenie. Użyj klucza idempotencji obsługiwanego przez miejsce docelowe, dedykowanego mechanizmu koordynacji lub odpowiedniego przepływu kompensacyjnego; jeśli nie ma takiej możliwości, jawnie uwzględnij ryzyko duplikacji i zarządzaj nim.
Ponawiaj próby z rosnącymi odstępami i rozsądnymi limitami, odróżniając błędy przejściowe — na przykład przekroczenie limitu czasu — od błędów trwałych, takich jak nieprawidłowy ładunek. Zapisuj ostatni błąd w sposób przydatny podczas diagnozowania, ale nie umieszczaj w logach sekretów ani zbędnych danych osobowych. Po wyczerpaniu prób pozostaw zdarzenie w widocznym stanie błędu; nie usuwaj go po cichu.
Odzyskiwanie powinno przebiegać jawnie: zidentyfikuj przyczynę, usuń ją i zdecyduj, czy ponowić to samo zdarzenie, czy potrzebna jest interwencja. Przed wznowieniem działania upewnij się, że konsument jest idempotentny. Przycisk „przetwórz ponownie” bez zabezpieczeń może powtórzyć zewnętrzne efekty lub zmienić oczekiwaną kolejność. Udokumentuj, kto może z niego korzystać i jakie informacje są rejestrowane.
Monitorowanie, retencja i ochrona danych
Monitoruj liczbę oczekujących zdarzeń, wiek najstarszego oczekującego zdarzenia, nieudane próby, tempo publikacji oraz czas między utworzeniem a dostarczeniem. Rosnąca kolejka może wskazywać na niedostępność miejsca docelowego, zatrzymanie konsumentów, powtarzające się błędy lub niewystarczającą przepustowość. Alarm oparty wyłącznie na łącznej liczbie zdarzeń może nie wykryć opóźnień; ich wiek pomaga ocenić, czy przetwarzanie postępuje.
Tabela będzie się powiększać wraz z każdym zdarzeniem. Określ politykę retencji dla zdarzeń opublikowanych i zakończonych błędem, a usuwanie wykonuj partiami, aby nie obciążać nadmiernie operacji konserwacyjnych. Przed czyszczeniem zachowaj dane potrzebne do audytu, wsparcia lub dochodzenia, zgodnie z wymaganiami biznesowymi. Ogranicz dostęp do ładunku, szyfruj lub minimalizuj dane wrażliwe, gdy jest to uzasadnione, i ustal politykę usuwania zgodną z retencją.
Kiedy używać outboxa, a kiedy unikać jego złożoności

Outbox jest przydatny, gdy zatwierdzenie operacji biznesowej powinno niezawodnie inicjować działanie asynchroniczne, między systemami mogą wystąpić awarie, a utrata powiadomienia miałaby konsekwencje. Sprawdza się szczególnie wtedy, gdy aplikacja korzysta już z transakcyjnej bazy danych i musi integrować się z kolejką lub usługami zewnętrznymi bez uzależniania dostępności tych usług od każdego żądania.
Może być zbędny w przypadku zadań, które można pominąć lub odtworzyć, gdy wystarczy okresowo odpytywać stan biznesowy albo gdy kolejka i trwały zapis udostępniają odpowiednią dla danego przepływu zintegrowaną transakcję. Oceniaj tę alternatywę, uwzględniając jej rzeczywiste ograniczenia: nie zakładaj, że dwie usługi są atomowe tylko dlatego, że należą do tego samego produktu.
Ten wzorzec wiąże się również z kosztami: dodatkową tabelą, workerem, ponawianiem prób, zarządzaniem statusami, retencją i obsługą operacyjną. Nie wprowadzaj złożonego systemu statusów, zanim poznasz wymagania. Zacznij od identyfikatorów, statusu, dat, liczby prób i bezpiecznego mechanizmu przejęcia; rozszerzaj projekt tylko w odpowiedzi na konkretne wymagania dotyczące kolejności, wolumenu, audytu lub odzyskiwania. Właściwym wyborem jest najprostsze rozwiązanie, które zachowuje wymaganą spójność oraz umożliwia wykrywanie i rozwiązywanie awarii.



