Outbox pattern - okładka

Outbox pattern

Opublikowano Kategorie Architektura, BackendCzas czytania 9min

Outbox to wzorzec, który rozwiązuje pewne problemy pojawiające się w systemach z komunikacją asynchroniczną. W tym artykule dowiesz się, jakie problemy rozwiązuje Outbox i jak go zaimplementować. Pokażę Ci również, na co zwrócić uwagę podczas implementacji, by rozwiązując jeden problem, nie stworzyć kolejnych.

Jakie problemy rozwiązuje Outbox?

Każdy dodatkowy komponent to potencjalne źródło problemów z poprawnością działania systemu. Awarii może ulec zarówno rozwijana przez nas aplikacja, jak i dowolny element infrastruktury, np. baza danych czy message broker. Jeśli transakcja w bazie danych zostanie już zatwierdzona, ale publikacja powiązanego zdarzenia nie powiedzie się, np. z powodu awarii brokera, reszta systemu się o tym nie dowie.


await orderRepository.save(order);

await eventEmitter.emit(event); // fail! 💥

Taki scenariusz może zadziać się również w drugą stronę: zdarzenie zostanie wyemitowane, ale zapis rekordu w bazie danych się nie powiedzie.


await eventEmitter.emit(event);

await orderRepository.save(order);// fail! 💥

W obu przypadkach dojdzie do niespójności danych.

Weźmy na tapet przykład sklepu internetowego. Sklep składa się z dwóch serwisów – serwisu do zarządzania zamówieniami (OrdersService) i serwisu do zarządzania logistyką (WarehouseService). Użytkownik składa zamówienie, a OrdersService emituje OrderCreatedEvent. W chwili publikacji, message broker przestaje działać. Na zdarzenie nasłuchuje WarehouseService. W tym przypadku zamówienia jednak nie dostaje. Klient widzi zamówienie w systemie i czeka na dostawę, ale magazyn nie wie, że powinien je wysłać.

Skończy się to dochodzeniem, co tak właściwie się wydarzyło, i być może straconym klientem. Sytuacja będzie jeszcze gorsza, gdy w międzyczasie inny klient wykupi ostatnią sztukę produktu, a jego zdarzenie wyemituje się poprawnie. Ponieważ wcześniejsza awaria spowodowała, że produkt nadal był na stanie, został on sprzedany dwukrotnie. W efekcie pierwszy klient nie otrzyma zamówienia.

Do podobnego problemu może prowadzić również awaria w trakcie przetwarzania zamówienia. W praktyce to nawet nie musi być awaria. Wystarczy, że serwis zrestartuje się po zapisaniu zamówienia w bazie danych, ale przed wyemitowaniem zdarzenia. Restart może być konieczny na przykład podczas wdrażania nowej wersji serwisu. Możemy też korzystać z infrastruktury, w której instancje są dynamicznie przydzielane i odbierane. Przykładem może być infrastruktura zbudowana na Amazon EC2 Spot Instances, gdzie AWS może odebrać instancje, gdy potrzebuje ich do innych zastosowań.


await orderRepository.save(order);

// service restart 💥

await eventEmiter.emit(event); 

Rozwiązaniem tych problemów może być wzorzec Outbox. Zamiast wysyłać wiadomości bezpośrednio, zapisujemy je w skrzynce nadawczej. Czekają tam na wysłanie, a po wysyłce zostają oznaczone jako opublikowane. Skrzynką najczęściej jest dedykowana tabela w bazie danych. Dzięki temu możemy objąć jedną transakcją zapis obiektu i powiązanego z nim zdarzenia. W przypadku awarii transakcja nie powiedzie się, a transakcja zostanie zrollbackowana.


await db.transaction(async (tx) => {
  await ordersRepository.save(order, tx);
  await outbox.add(event, tx);
});

Po zatwierdzeniu transakcji zdarzenie zostanie asynchronicznie wysłane do message brokera. Odpowiada za to osobny proces, który cyklicznie pobiera wiadomości z Outboxa i publikuje je. Następnie czeka na potwierdzenie odbioru (ACK) od brokera i oznacza wiadomości jako wysłane lub je usuwa. Wariant z oznaczaniem wiadomości ma jednak kilka istotnych zalet:

  • Umożliwia ponowienie wysyłki w razie potrzeby. Możemy ręcznie usunąć informację o przetworzeniu i ponownie wysłać zdarzenia z Outboxa.
  • Persystencja zdarzeń daje więcej możliwości w zakresie audytowania czy szukania błędów w procesach.

Całość procesu możesz prześledzić na poniższym diagramie.

Outbox - diagram sekwencji

 

Na co należy uważać?

Problemy mogą wystąpić również podczas obsługi Outboxa. Przykładem jest sytuacja, w której wiadomość została już opublikowana, ale serwis nie zdążył oznaczyć jej jako wysłaną. Jeśli w tym momencie nastąpi awaria czy restart serwisu, Outbox wyśle ją po raz drugi. Systemy wykorzystujące Outboxa spełniają at least once delivery, co oznacza, że wiadomość może zostać dostarczona więcej niż raz.

Korzystając z Outboxa, należy zadbać o idempotentne przetwarzanie wiadomości przez odbiorców. Operacja jest idempotentna, jeśli wielokrotne jej wykonanie daje taki sam rezultat jak pojedyncze wykonanie. Jednym ze sposobów na deduplikację jest wykorzystanie identyfikatora zdarzenia. Konsument może zapisywać identyfikatory przetworzonych wiadomości w tabeli z ograniczeniem UNIQUE na identyfikatorze i odrzucać już te przetworzone.

Przykładowa implementacja

Skupiłem się na pokazaniu happy path, pomijając scenariusze brzegowe, które znacznie skomplikowałyby kod. Na początek musimy utworzyć tabelę, w której przechowywane będą wiadomości do wysłania.


CREATE TABLE outbox (
    id UUID PRIMARY KEY,
    event_type TEXT NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    emitted_at TIMESTAMPTZ NULL
);

CREATE INDEX idx_outbox_pending
    ON outbox (created_at)
    WHERE emitted_at IS NULL;

Przykładowa implementacja Outboxa w TypeScript, obsługująca podstawowe scenariusze, może wyglądać tak:


import type { PoolClient } from "pg";

class Outbox {
  constructor(private readonly db: PoolClient) {}

  async add(event: Event, tx: PoolClient): Promise<void> {
    await tx.query(
      `INSERT INTO outbox (id, event_type, payload)
       VALUES ($1, $2, $3)`,
      [crypto.randomUUID(), event.name, JSON.stringify(event.payload)],
    );
  }

  async getPending(limit = 100): Promise<OutboxMessage[]> {
    const result = await this.db.query(
      `SELECT id, event_type, payload
       FROM outbox
       WHERE emitted_at IS NULL
       ORDER BY created_at
       LIMIT $1`,
      [limit],
    );

    return result.rows;
  }

  async markAsEmitted(id: string): Promise<void> {
    await this.db.query(
      `UPDATE outbox
       SET emitted_at = NOW()
       WHERE id = $1`,
      [id],
    );
  }
}

W praktyce trzeba uwzględnić również scenariusze brzegowe, które omawiam w dalszej części artykułu. Kolejnym krokiem jest wykorzystanie Outboxa w docelowym miejscu. W przedstawionym wcześniej scenariuszu emitowany jest OrderCreatedEvent oraz wywoływany jest zapis zamówienia do bazy. Oba zapisy wykonamy w ramach jednej transakcji.


await database.transaction(async (tx) => {
  await ordersRepository.save(order, tx);

  await outbox.add(
    new OrderCreatedEvent({
      orderId: order.id,
      customerId: order.customerId,
      total: order.total,
    }),
    tx,
  );
});

Ostatnim krokiem jest wysłanie wiadomości do message brokera. W tym celu należy zdefiniować proces, który cyklicznie pobiera oczekujące zdarzenia i publikuje je.


async function processOutbox(
  outbox: Outbox,
  emitter: EventEmitter,
): Promise<void> {
  const events = await outbox.getPending();
  for (const event of events) {
    try {
      await emitter.emit(event);
      await outbox.markAsEmitted(event.id);
    } catch (error) {
      console.error(`Failed to emit ${event.id}`, error);
    }
  }
}

Scenariusze brzegowe

W przykładzie skupiłem się na implementacji happy path. W praktyce trzeba jednak uwzględnić również scenariusze brzegowe.

Jednym z problemów jest możliwa zmiana kontraktu zdarzenia oczekującego na publikację w Outboxie. Zdarzenie poprawne w chwili zapisu może stać się nieobsługiwane, zanim zostanie opublikowane. Jeśli między zapisem a wysyłką zmienimy schemat wiadomości, np. zostanie dodane wymagane pole, możemy nie być już w stanie jej poprawnie przetworzyć. W takim przypadku konieczne może być wersjonowanie wiadomości czy zapewnienie kompatybilności wstecznej.

Wiadomość może również zawierać niepoprawny payload, np. przekraczać maksymalny rozmiar dozwolony przez brokera. Należy zadbać o to, by taka wiadomość nie blokowała przetwarzania pozostałych wiadomości.

Należy pamiętać, że kilka procesów może jednocześnie pobierać wiadomości z Outboxa. Trzeba więc zadbać o to, by każdy proces przetwarzał tylko te wiadomości, których nie obsługuje inny proces. Możemy w tym celu je zalockować np. przez SELECT FOR UPDATE SKIP LOCKED.

Przypominam też, że Outbox wiąże się z at-least-once delivery. Dlatego konsumenci powinni przetwarzać wiadomości w sposób idempotentny.

Ostatnia kwestia nie dotyczy bezpośrednio Outboxa, lecz wynika ze specyfiki systemów asynchronicznych. Nie mamy żadnej gwarancji co do kolejności wysyłki czy przetworzenia. Załóżmy, że mamy do wysłania 200 wiadomości i uruchamiamy równolegle dwa procesy, z których każdy pobiera po 100. Wiadomości mogą zostać wysłane w kolejności 1, 2, 101, 3, 102… albo 101, 102, 1, 2… lub dowolnej innej. Przetwarzanie wiadomości przez konsumentów również nie gwarantuje kolejności. Najbezpieczniej przyjąć, że wiadomości mogą być wysyłane i odbierane w dowolnej kolejności.

Podsumowanie

W tym artykule chciałem podkreślić dwie kluczowe kwestie. Po pierwsze, problemy i awarie, które adresuje Outbox, nie są egzotycznymi scenariuszami. Awaria brokera jest możliwa, szczególnie gdy sami odpowiadamy za jego dostępność. Podobnie jest z awariami czy restartami serwisu, które są czymś zupełnie normalnym. Szansa na wystąpienie opisanego wcześniej scenariusza, w którym zamówienie nie zostaje wysłane, jest niewielka, ale nie zerowa. Pół biedy, jeśli to będzie ostatnia paczka śrubek. Gorzej, jeśli będzie to jakiś ostatni egzemplarz limitowanego produktu.

Jak w przypadku każdego innego wzorca, nie warto stosować go zawsze i wszędzie. Jeśli utrata choćby jednej wiadomości jest dla nas problemem, warto rozważyć zastosowanie Outboxa. Dobrym przykładem zastosowania, w którym Outbox może nie mieć sensu, jest aplikacja wysyłająca co minutę dane telemetryczne ze stacji pogodowej. Jeśli utracimy informację, że w piątek o 13:28 temperatura wynosiła 10 stopni i padał lekki deszcz, prawdopodobnie nie będzie to miało większego znaczenia.

Masz przemyślenia na temat Outboxa albo przykłady sytuacji, w których się sprawdził lub nie? Zachęcam do zostawienia komentarza.

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, dobrej kawy i miłośnik jazdy na rowerze.

Inne wpisy, które mogą Cię zainteresować

Kolejna książka o Gicie — naucz się korzystać z Gita jak profesjonalista

Okładka e-booka - Kolejna książka o Gicie

"Kolejna książka o Gicie" to kompleksowy e-book, który pozwoli Ci poznać Gita od A do Z, a także liczne narzędzia dedykowane pracy z Gitem!

Dlaczego warto?

  • 👉 Git od podstaw do poziomu PRO. E-book przeprowadzi Cię krok po kroku, niezależnie od poziomu doświadczenia.
  • 👉 Zdobędziesz praktyczne umiejętności, które natychmiast wykorzystasz w prawdziwych projektach.
  • 👉 Wiedza połączona z praktyką! Oprócz masy teorii w e-booku znajdziesz też zadania praktyczne.

Okładka e-booka - Kolejna książka o Gicie

Nie przegap kolejnych artykułów

Zapisz się na mój mailing, jeśli chcesz być na bieżąco z nowymi artykułami. Wystarczy, że skorzystasz z formularza poniżej 👇
Po zapisie otrzymasz także fragment „Kolejnej książki o Gicie”.

guest

0 komentarzy
Najwięcej głosów
Najnowsze Najstarsze