Сервис записал заказ в БД, но упал перед публикацией события. Как обеспечить доставку события без рассогласования?
Используйте паттерн Transactional Outbox: сохраняйте бизнес-изменение и событие в одной локальной транзакции базы данных. Отдельный доставщик затем публикует событие в брокер, повторяя попытки до успеха.
Такая схема устраняет потерю события между фиксацией заказа и его публикацией. Обычно она обеспечивает семантику как минимум один раз, поэтому потребитель должен обрабатывать дубликаты идемпотентно.
Проблема возникла из-за так называемой двойной записи: сервис должен изменить собственную БД и отправить сообщение во внешний брокер. Эти операции обычно не входят в одну локальную транзакцию.
Если сначала отправить сообщение, а затем упасть до записи в БД, потребитель увидит событие о несуществующем заказе. Если сначала зафиксировать БД, а затем упасть перед публикацией, заказ будет создан, но другие сервисы никогда не узнают об этом.
Распределённая транзакция между БД и брокером могла бы связать операции, но повышает сложность, стоимость координации и зависимость от поддержки общего протокола. Outbox ограничивает атомарность одной БД, где она реализуется надёжнее.
Пусть транзакция создаёт заказ, а после её фиксации процесс должен опубликовать событие OrderCreated. Между этими действиями возможны падение процесса, потеря соединения с брокером, тайм-аут или временная недоступность брокера.
Нельзя считать неизвестный результат публикации признаком неуспеха: брокер мог принять сообщение, но подтверждение могло потеряться. Простое повторение публикации поэтому способно создать дубликаты.
Неверное решение приводит к потерянным событиям, зависшим бизнес-процессам, неполному поисковому индексу или неверным уведомлениям. Особенно опасно молча удалять запись о событии после первой неудачной попытки.
В одной транзакции сохраняются бизнес-данные заказа и запись в таблице outbox. Запись outbox содержит идентификатор события, тип, идентификатор агрегата, полезную нагрузку, время создания и состояние доставки либо число попыток.
После фиксации транзакции фоновый доставщик читает необработанные записи и отправляет их брокеру. При подтверждённой публикации он помечает запись как доставленную. При ошибке запись остаётся доступной для повторной попытки; для временных ошибок применяют задержку и ограничение частоты повторов.
Если доставщик упал после принятия сообщения брокером, но до отметки в outbox, он отправит сообщение повторно. Поэтому outbox сам по себе не даёт строгую семантику ровно один раз. Потребитель должен использовать стабильный идентификатор события и дедупликацию, например сохранять обработанные идентификаторы в своей БД в той же транзакции, что и бизнес-эффект.
Для порядка событий одного агрегата нужны дополнительные меры: последовательный номер версии, маршрутизация в одну партицию или проверка ожидаемой версии потребителем. Глобальный порядок обычно не гарантируется и без необходимости ухудшает масштабируемость.
Доставщик может читать outbox через периодический опрос или механизм Change Data Capture. Опрос проще внедрить, но создаёт задержку и нагрузку на БД; CDC уменьшает задержку и нагрузку на запросы, но добавляет инфраструктурную сложность.
Outbox не решает атомарность между несколькими независимыми БД. Если один бизнес-процесс меняет данные в разных сервисах, нужны согласованные локальные транзакции, сага или другой явно выбранный протокол координации.
Сервис заказов записывал заказ в БД, а затем напрямую публиковал событие в брокер. При кратковременной недоступности брокера заказ успешно создавался, но платёжный сервис не получал уведомление. Повторная отправка всего запроса была неприемлема: она могла создать второй заказ.
Рассматривались три варианта. Прямое повторение публикации было простым, но не устраняло окно между двумя операциями и создавало риск дубликатов. Распределённая транзакция давала более сильную координацию, но требовала поддержки общего протокола и усложняла эксплуатацию. Transactional Outbox добавлял таблицу и доставщик, зато сохранял атомарность в уже используемой БД.
Выбрали outbox: заказ и событие стали фиксироваться одной транзакцией, доставщик повторял публикацию, а потребитель дедуплицировал события по их идентификаторам. После этого временная недоступность брокера приводила к задержке обработки, но не к потере события; остаток outbox контролировали метриками и отдельной политикой удаления доставленных записей.
Почему outbox не гарантирует ровно однократную доставку?
Между публикацией события и фиксацией статуса в outbox существует неопределённое окно. Если процесс упадёт после принятия сообщения брокером, но до обновления статуса, доставщик повторит отправку. Следовательно, корректная модель — как минимум один раз плюс идемпотентная обработка потребителем.
Как сделать обработку события идемпотентной?
Событие должно иметь стабильный уникальный идентификатор. Потребитель в одной транзакции проверяет и сохраняет этот идентификатор, затем применяет бизнес-изменение; уникальное ограничение на идентификатор не позволяет повторно выполнить эффект. Нельзя полагаться только на проверку в памяти: после перезапуска или при параллельной обработке она теряет надёжность.
Что произойдёт, если outbox переполнится?
Если брокер недоступен длительное время, записи будут накапливаться вместе с нагрузкой на БД. Нужно контролировать возраст самой старой записи, размер таблицы, скорость публикации и число повторных ошибок, а также заранее определить политику хранения и аварийного восстановления. Удалять записи только по возрасту опасно: ещё не доставленное событие может быть потеряно; обычно удаляют подтверждённые записи после достаточного периода для аудита или архивируют их.