Ответ
Классическое решение — паттерн Transaction Outbox. В рамках одной транзакции с БД вы сохраняете не только бизнес-данные, но и событие в специальную таблицу-очередь (outbox). Затем отдельный фоновый процесс (например, воркер) забирает события из этой таблицы и гарантированно отправляет их в брокер.
Пример реализации на PHP (упрощённо):
// Внутри сервиса создания заказа
$entityManager->beginTransaction();
try {
// 1. Сохраняем бизнес-сущность
$order = new Order($data);
$entityManager->persist($order);
$entityManager->flush(); // Получаем ID
// 2. Сохраняем событие в outbox в той же транзакции
$outboxMessage = new OutboxMessage(
topic: 'order.created',
payload: json_encode(['orderId' => $order->getId()]),
status: OutboxMessage::STATUS_PENDING
);
$entityManager->persist($outboxMessage);
$entityManager->commit(); // Оба сохранения атомарны
} catch (Exception $e) {
$entityManager->rollback();
throw $e;
}
// 3. Отдельный воркер отправляет события из outbox
// Он периодически выбирает сообщения со статусом PENDING,
// отправляет их в RabbitMQ и помечает как отправленные.
Альтернативы и дополнения:
- CDC (Change Data Capture): Использование инструментов вроде Debezium для отслеживания изменений в логах БД и автоматической публикации событий. Это выносит логику отправки полностью за пределы приложения.
- Двухфазный коммит (2PC): Сложный и менее производительный подход, пытающийся сделать commit в БД и брокер атомарным. На практике используется редко.
Почему Outbox — хороший выбор:
- Гарантирует атомарность: Событие будет сохранено, только если основная транзакция успешна.
- Устраняет двойную отправку (в идеале): Воркер может использовать идемпотентность или блокировки записей в outbox.
- Повышает отказоустойчивость: Если RabbitMQ недоступен в момент транзакции, событие остаётся в outbox и будет отправлено позже.