Проблема подвійного запису. Замовлення треба зберегти в базу і повідомити інші системи (черга, брокер повідомлень, вебхук). Це дві різні системи, і атомарно оновити обидві неможливо:
DB::transaction(function () use ($order) {
$order->save();
});
$broker->publish(new OrderPlaced($order)); // впало тут - подію втрачено назавжди
Якщо поміняти місцями - можна опублікувати подію про замовлення, транзакція якого потім відкотиться.
Transactional outbox: подія записується в ту саму базу й ту саму транзакцію, що й зміна даних, - у спеціальну таблицю:
CREATE TABLE outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
aggregate_type text NOT NULL,
aggregate_id bigint NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
DB::transaction(function () use ($order) {
$order->save();
Outbox::create(['event_type' => 'OrderPlaced', 'payload' => [...]]);
});
Тепер або є і замовлення, і подія, або немає нічого.
Окремий процес-ретранслятор читає неопубліковані записи й надсилає їх у брокер:
SELECT * FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED;
Після успішної відправки - позначає published_at (або видаляє запис). Альтернатива опитуванню - читати зміни таблиці з WAL через Change Data Capture (Debezium).
Що з цього випливає:
- Доставка «щонайменше раз»: ретранслятор міг надіслати подію й упасти, не позначивши її. Отримувачі мають бути ідемпотентними (ID події + перевірка, чи вже оброблено).
- Порядок подій зберігається в межах одного агрегата, якщо ретранслятор обробляє їх за
id. - Таблицю outbox треба чистити - вона росте з кожною подією.
У Laravel частково цю задачу вирішують afterCommit для завдань і подій (відправити в чергу лише після успішного коміту), але це не захищає від падіння процесу між комітом і відправкою - для справжньої гарантії потрібен саме outbox.