<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
КОНЦЕПЦІЯ DDD · КРОК 10 З 10

OutboxTransactional outbox

Повідомлення про зміну спершу записують у таблицю outbox тієї самої бази й у тій самій транзакції, що й саму зміну. Окремий процес читає таблицю й доставляє, тож зміна без повідомлення стає неможливою.

7 хв читання Middle → Senior
01 · ЩО ЦЕ

Два записи без спільної транзакції

Сценарій зберігає відгук у PostgreSQL і кладе повідомлення в RabbitMQ, щоб роботодавець отримав лист. Це дві різні системи, і спільної транзакції в них немає. Таку ситуацію називають . Процес упав між двома записами: відгук є, повідомлення немає. Повідомлення пішло до коміту, а транзакція відкотилась: лист про відгук, якого в базі немає.

СЛОВНИКDual write. Запис у дві системи, наприклад базу й брокер, без спільної транзакції. Рано чи пізно одна з них отримає запис, а інша ні.

зводить два записи до одного. Повідомлення стає рядком у таблиці outbox тієї самої бази й комітиться разом зі зміною: або є обидва, або жодного. окремим процесом вибирає невідправлені рядки, публікує їх і позначає відправленими.

СЛОВНИКTransactional outbox. Таблиця в базі сервісу, куди повідомлення пишуть у тій самій транзакції, що й зміну. Звідти їх доставляє окремий процес.
СЛОВНИКRelay. Процес, який читає невідправлені рядки outbox, публікує їх і позначає відправленими. Ще його називають message relay чи publisher.

Relay може опублікувати повідомлення й упасти до того, як позначив рядок. Після перезапуску він відправить його ще раз, тож гарантія тут . Тому споживач має бути : повторне повідомлення з тим самим ID нічого не змінює.

СЛОВНИКAt-least-once. Гарантія доставки, за якої повідомлення прийде щонайменше один раз, але може прийти й двічі.
СЛОВНИКІдемпотентний обробник. Обробник, для якого повторне те саме повідомлення не має нового ефекту. Зазвичай він памʼятає ID оброблених повідомлень.

У Laravel DB::afterCommit(), інтерфейс ShouldDispatchAfterCommit для подій і ShouldQueueAfterCommit для задач лише відкладають відправку до коміту: від втрати між комітом і відправкою вони не рятують. Атомарність дає драйвер черги database на тому самому зʼєднанні з вимкненим after_commit: задача, поставлена всередині DB::transaction(), пишеться в таблицю jobs у тій самій транзакції. У Symfony Messenger так поводиться транспорт doctrine:// на тому самому DBAL-зʼєднанні, що й Doctrine ORM. Для зовнішнього брокера все одно потрібен relay.

02 · МЕЖА

Від транзакції до обробника

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
ОДНА ТРАНЗАКЦІЯPostgreSQLсценарійApplyToJobhandle()applicationsidstatus812newoutboxidtypesent_at41ApplicationSubmittedNULLокремий процесOutboxRelaypublish()RabbitMQобробникNotifyEmployerідемпотентний
ОДНА ТРАНЗАКЦІЯPostgreSQLсценарійApplyToJobhandle()applicationsidstatus812newoutboxidtypesent_at41ApplicationSubmittedNULLокремий процесOutboxRelaypublish()RabbitMQобробникNotifyEmployerідемпотентний
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Домен Інфраструктура Клієнт
  • Одна транзакція (PostgreSQL) містить: applications, outbox.
  • ApplyToJob використовує applications.
  • ApplyToJob використовує outbox.
  • OutboxRelay використовує outbox.
  • OutboxRelay використовує RabbitMQ.
  • RabbitMQ надсилає подію, її отримує NotifyEmployer.
  1. 1Рядок outbox пишуть у тій самій транзакції й на тому самому зʼєднанні, що й зміну.
  2. 2Relay бере пачку з блокуванням: у PostgreSQL це FOR UPDATE SKIP LOCKED, у Laravel lock('for update skip locked') у Query Builder.
  3. 3Кожне повідомлення має власний ID, і обробник за ним відкидає повтори.
  4. 4Відправлені рядки чистять за розкладом, інакше таблиця outbox росте без кінця.
03 · ЯК У PHP UKRAINE

Де outbox знадобився б

Outbox у PHP Ukraine немає, як і брокера повідомлень. Черга налаштована на драйвер database, але жоден клас не ставить у неї задач: листи йдуть синхронно, у тому самому запиті. Проблема двох записів уже видна в SubmitLead: заявка на розробку зберігається, а потім окремо йде лист команді. Якщо пошта не відповіла, заявка лишається в базі, а лист губиться без сліду, бо помилку ковтають, щоб форма не показала збій. Тут це свідомий компроміс: усі заявки видно в адмінці /panel. Найближче до outbox у проєкті ContentOps: задачі для локального воркера лежать у таблиці content_tasks, і сервер видає їх воркеру пачкою з lockForUpdate, як relay. Але задачі створює не та транзакція, що змінює вакансію, а окремий запуск content:enqueue за розкладом, який шукає вакансії без нашого опису.

php SubmitLead.php фрагмент, скорочено
1final readonly class SubmitLead
2{
3 public function __construct(
4 private LeadRepository $leads,
5 private LeadNotifier $notifier,
6 private Clock $clock,
7 ) {}
8
9 public function handle(array $input): Lead
10 {
11 $lead = Lead::submit(
12 kind: LeadKind::from((string) ($input['kind'] ?? '')),
13 brief: (string) ($input['brief'] ?? ''),
14 email: (string) ($input['email'] ?? ''),
15 now: $this->clock->now(),
16 );
17
18 $this->leads->save($lead);← запис 1
19
20 try {
21 $this->notifier->notify($lead);← запис 2
22 } catch (Throwable) {← втрату не видно
23 // The lead is stored; a broken mailer must not turn a submitted brief into an error page.
24 }
25
26 return $lead;
27 }
28}
04 · ТИПОВІ ПОМИЛКИ

Як outbox зазвичай ламають

✕ 1

Outbox поза транзакцією

Сценарій зберігає відгук, а рядок outbox пише вже після DB::transaction(). Між двома записами знову є вікно, у якому повідомлення губиться.

Натомість: обидва записи в одному замиканні: DB::transaction(function () use ($application) { $this->applications->save($application); DB::table('outbox')->insert(['type' => 'ApplicationSubmitted', 'payload' => json_encode(['application_id' => $application->id]), 'created_at' => now()]); });

✕ 2

afterCommit замість outbox

Подію позначили ShouldDispatchAfterCommit і вважають доставку гарантованою. Коміт пройшов, процес упав до відправки, і подія зникла.

Натомість: afterCommit захищає лише від повідомлень про відкочені зміни. Гарантію доставки дає запис у ту саму транзакцію: outbox або черга database на тому самому зʼєднанні.

✕ 3

Обробник без захисту від повторів

Relay відправив повідомлення двічі після перезапуску, і роботодавець отримав два однакові листи.

Натомість: зберігайте ID оброблених повідомлень з унікальним індексом і пропускайте повтор. Або робіть саму дію ідемпотентною, як upsert замість insert.

05 · ДЕРЕВО РІШЕНЬ

Чи потрібен вам outbox

1
Чи є після зміни в базі повідомлення, яке має дійти до когось іншого?
Лист, подія для іншого сервісу, задача в черзі.
НІ →Outbox не потрібен: немає другого запису, немає й проблеми.
2
Чи відчутна втрата або хибне повідомлення?
Роботодавець не дізнався про відгук або отримав лист про відгук, якого немає.
НІ →Відправте після коміту й залогуйте помилку. Так PHP Ukraine шле роботодавцю лист про новий відгук.
3
Чи живе отримувач поза базою застосунку?
RabbitMQ, Kafka, інший сервіс, зовнішній API.
НІ →Поставте задачу в чергу database на тому самому зʼєднанні всередині транзакції: таблиця jobs і стане вашим outbox.
ТАК НА ВСІ →Вам потрібен outbox. Таблиця в тій самій транзакції, relay з SKIP LOCKED і ідемпотентні обробники.
06 · СЛОВНИК
Dual write
Запис у дві системи, наприклад базу й брокер, без спільної транзакції. Рано чи пізно одна з них отримає запис, а інша ні.
Transactional outbox
Таблиця в базі сервісу, куди повідомлення пишуть у тій самій транзакції, що й зміну. Звідти їх доставляє окремий процес.
Relay
Процес, який читає невідправлені рядки outbox, публікує їх і позначає відправленими. Ще його називають message relay чи publisher.
At-least-once
Гарантія доставки, за якої повідомлення прийде щонайменше один раз, але може прийти й двічі.
Ідемпотентний обробник
Обробник, для якого повторне те саме повідомлення не має нового ефекту. Зазвичай він памʼятає ID оброблених повідомлень.
07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з архітектури →

Між комітом і відправкою процес може впасти, а брокер може бути недоступний. Тоді зміна в базі є, а події немає, і ніхто про це не дізнається. Outbox робить подію частиною тієї самої транзакції.

At-least-once: relay може відправити повідомлення й упасти, не позначивши його, тож після перезапуску відправить ще раз. Тому обробники мають бути ідемпотентними, а повідомлення мати стабільний ID.

Використати драйвер черги database на тому самому зʼєднанні, що й дані, з after_commit = false, і ставити задачу всередині DB::transaction(). Рядок у таблиці jobs комітиться або відкочується разом зі зміною, а воркер виконує задачу після коміту. Для зовнішнього брокера цього мало: задача в черзі має сама публікувати повідомлення й бути ідемпотентною.