Сервис сохраняет заказ в базе, но публикация события может завершиться отдельно. Как устранить риск рассинхронизации между этими операциями?
Используйте паттерн транзакционного исходящего сообщения: сохранение заказа и записи о событии выполняется в одной транзакции той же базы данных. Отдельный процесс после коммита доставляет записи из этой таблицы в брокер сообщений с повторными попытками.
Так устраняется потеря события при сбое между записью заказа и публикацией. Доставка обычно имеет семантику at-least-once, поэтому потребители должны обрабатывать события идемпотентно.
Проблема возникла из-за операции двойной записи: сервис должен изменить собственное хранилище и одновременно отправить сообщение во внешнюю систему. Обычная транзакция базы данных не охватывает брокер сообщений, поэтому атомарно зафиксировать обе операции нельзя без распределённой транзакции.
Паттерн исходящей таблицы решает практическую часть этой проблемы без координации двух независимых систем. Надёжность опирается на уже имеющиеся транзакционные гарантии базы данных, а доставку выполняет отдельный механизм.
Если сначала записать заказ, а затем отправить событие, сбой между операциями оставит заказ сохранённым, но событие будет потеряно. Если сначала отправить событие, а затем записать заказ, потребитель может увидеть ссылку на несуществующий заказ.
Повторная публикация тоже создаёт риск: после отправки события сервис может упасть до фиксации отметки о доставке. При восстановлении он отправит событие ещё раз, поэтому обработчик должен выдерживать дубликаты.
В одной транзакции сервис сохраняет бизнес-изменение и запись в outbox-таблице. В записи обычно хранятся уникальный идентификатор события, тип события, идентификатор агрегата, полезная нагрузка, время создания и состояние доставки.
После успешного коммита отдельный доставщик читает необработанные записи и публикует их в брокер. После подтверждения брокером он помечает запись как доставленную. Если процесс завершился после публикации, но до обновления статуса, событие будет опубликовано повторно — это ожидаемое поведение at-least-once.
Потребитель должен сохранять обработанные идентификаторы событий или применять бизнес-операцию идемпотентно. Для событий одного заказа часто требуется сохранять порядок: например, использовать ключ заказа при публикации или проверять последовательность версий агрегата.
Доставщик должен ограничивать размер выборки, использовать блокировку или механизм конкурентного захвата записей, выполнять повторные попытки с задержкой и изолировать постоянно неуспешные сообщения. Нужны метрики возраста необработанных записей, числа повторов и времени доставки.
Подход увеличивает задержку между фиксацией заказа и публикацией события, расходует место в базе и требует очистки или архивирования старых записей. Он также не делает атомарной цепочку между несколькими базами: если событие должно изменить данные в другой системе, та система всё равно должна корректно обрабатывать повторы и частичные сбои.
Вместо опроса таблицы можно использовать CDC — захват изменений из журнала базы данных. Это снижает задержку и нагрузку от периодического опроса, но добавляет инфраструктурную зависимость и требует корректной фильтрации и маршрутизации изменений.
Сервис заказов после фиксации заказа отправлял событие в брокер отдельным вызовом. При кратковременной недоступности брокера заказы успешно создавались, но сервис не имел надёжного списка событий для повторной отправки; восстановление требовало ручного поиска.
Рассматривались три варианта. Распределённая транзакция могла бы дать более сильную координацию, но потребовала бы поддержки протокола всеми участниками и усложнила эксплуатацию. Повторная отправка из журнала приложения была дешевле, но не гарантировала связь с фактически зафиксированной транзакцией. Транзакционная исходящая таблица добавила запись в ту же базу и отдельный доставщик, зато использовала надёжный коммит бизнес-данных.
Выбрали исходящую таблицу: после коммита каждое событие стало наблюдаемым и доступным для повторной доставки. Потребители добавили дедупликацию по идентификатору события, а команда настроила контроль возраста очереди и отдельную обработку сообщений, которые не удаётся доставить длительное время.
1. Гарантирует ли транзакционная исходящая таблица ровно одну доставку события?
Нет. Она гарантирует, что событие не будет потеряно после успешного коммита бизнес-изменения, но не исключает повторную публикацию. Сбой между подтверждением брокера и фиксацией статуса доставки делает повтор неизбежным, поэтому требование «ровно один раз» обычно заменяют комбинацией at-least-once и идемпотентной обработки.
2. Где должна находиться исходящая таблица, если данные заказа распределены между несколькими хранилищами?
Она должна участвовать в той же транзакции, что и изменение, которое порождает событие. Если один бизнес-операционный факт записывается в несколько независимых хранилищ, одна outbox-таблица не обеспечит атомарность между ними; нужно изменить границу владения данными, выбрать один источник истины или принять сагу с компенсациями и явно описанными промежуточными состояниями.
3. Как предотвратить нарушение порядка событий одного заказа при параллельной доставке?
Нужно определить требуемый порядок и поддержать его на всех этапах. Обычно события одного агрегата направляют с одинаковым ключом партиционирования, а потребитель проверяет версию или номер последовательности и откладывает событие, если предыдущее ещё не обработано. Это снижает параллелизм и может создать горячую партицию, поэтому порядок следует обеспечивать только там, где он действительно необходим.