Два записи без спільної транзакції
Сценарій зберігає відгук у PostgreSQL і кладе повідомлення в RabbitMQ, щоб роботодавець отримав лист. Це дві різні системи, і спільної транзакції в них немає. Таку ситуацію називають . Процес упав між двома записами: відгук є, повідомлення немає. Повідомлення пішло до коміту, а транзакція відкотилась: лист про відгук, якого в базі немає.
зводить два записи до одного. Повідомлення стає рядком у таблиці outbox тієї самої бази й комітиться разом зі зміною: або є обидва, або жодного. окремим процесом вибирає невідправлені рядки, публікує їх і позначає відправленими.
Relay може опублікувати повідомлення й упасти до того, як позначив рядок. Після перезапуску він відправить його ще раз, тож гарантія тут . Тому споживач має бути : повторне повідомлення з тим самим ID нічого не змінює.
У Laravel DB::afterCommit(), інтерфейс ShouldDispatchAfterCommit для подій і ShouldQueueAfterCommit для задач лише відкладають відправку до коміту: від втрати між комітом і відправкою вони не рятують. Атомарність дає драйвер черги database на тому самому зʼєднанні з вимкненим after_commit: задача, поставлена всередині DB::transaction(), пишеться в таблицю jobs у тій самій транзакції. У Symfony Messenger так поводиться транспорт doctrine:// на тому самому DBAL-зʼєднанні, що й Doctrine ORM. Для зовнішнього брокера все одно потрібен relay.
Від транзакції до обробника
- Одна транзакція (PostgreSQL) містить: applications, outbox.
- ApplyToJob використовує applications.
- ApplyToJob використовує outbox.
- OutboxRelay використовує outbox.
- OutboxRelay використовує RabbitMQ.
- RabbitMQ надсилає подію, її отримує NotifyEmployer.
- 1Рядок outbox пишуть у тій самій транзакції й на тому самому зʼєднанні, що й зміну.
- 2Relay бере пачку з блокуванням: у PostgreSQL це FOR UPDATE SKIP LOCKED, у Laravel lock('for update skip locked') у Query Builder.
- 3Кожне повідомлення має власний ID, і обробник за ним відкидає повтори.
- 4Відправлені рядки чистять за розкладом, інакше таблиця outbox росте без кінця.
Де outbox знадобився б
Outbox у PHP Ukraine немає, як і брокера повідомлень. Черга налаштована на драйвер database, але жоден клас не ставить у неї задач: листи йдуть синхронно, у тому самому запиті. Проблема двох записів уже видна в SubmitLead: заявка на розробку зберігається, а потім окремо йде лист команді. Якщо пошта не відповіла, заявка лишається в базі, а лист губиться без сліду, бо помилку ковтають, щоб форма не показала збій. Тут це свідомий компроміс: усі заявки видно в адмінці /panel. Найближче до outbox у проєкті ContentOps: задачі для локального воркера лежать у таблиці content_tasks, і сервер видає їх воркеру пачкою з lockForUpdate, як relay. Але задачі створює не та транзакція, що змінює вакансію, а окремий запуск content:enqueue за розкладом, який шукає вакансії без нашого опису.
Як outbox зазвичай ламають
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()]); });
afterCommit замість outbox
Подію позначили ShouldDispatchAfterCommit і вважають доставку гарантованою. Коміт пройшов, процес упав до відправки, і подія зникла.
Натомість: afterCommit захищає лише від повідомлень про відкочені зміни. Гарантію доставки дає запис у ту саму транзакцію: outbox або черга database на тому самому зʼєднанні.
Обробник без захисту від повторів
Relay відправив повідомлення двічі після перезапуску, і роботодавець отримав два однакові листи.
Натомість: зберігайте ID оброблених повідомлень з унікальним індексом і пропускайте повтор. Або робіть саму дію ідемпотентною, як upsert замість insert.
Чи потрібен вам outbox
- Dual write
- Запис у дві системи, наприклад базу й брокер, без спільної транзакції. Рано чи пізно одна з них отримає запис, а інша ні.
- Transactional outbox
- Таблиця в базі сервісу, куди повідомлення пишуть у тій самій транзакції, що й зміну. Звідти їх доставляє окремий процес.
- Relay
- Процес, який читає невідправлені рядки outbox, публікує їх і позначає відправленими. Ще його називають message relay чи publisher.
- At-least-once
- Гарантія доставки, за якої повідомлення прийде щонайменше один раз, але може прийти й двічі.
- Ідемпотентний обробник
- Обробник, для якого повторне те саме повідомлення не має нового ефекту. Зазвичай він памʼятає ID оброблених повідомлень.
Питання зі співбесід
Між комітом і відправкою процес може впасти, а брокер може бути недоступний. Тоді зміна в базі є, а події немає, і ніхто про це не дізнається. Outbox робить подію частиною тієї самої транзакції.
At-least-once: relay може відправити повідомлення й упасти, не позначивши його, тож після перезапуску відправить ще раз. Тому обробники мають бути ідемпотентними, а повідомлення мати стабільний ID.
Використати драйвер черги database на тому самому зʼєднанні, що й дані, з after_commit = false, і ставити задачу всередині DB::transaction(). Рядок у таблиці jobs комітиться або відкочується разом зі зміною, а воркер виконує задачу після коміту. Для зовнішнього брокера цього мало: задача в черзі має сама публікувати повідомлення й бути ідемпотентною.