Практическая ситуация: сервис записывает заказ в базу данных и публикует событие для другого сервиса. При сбое между этими действиями заказ сохраняется, но событие теряется. Какой механизм устранит этот разрыв?
начать транзакцию базы данных
заказ = сохранить_заказ(данные)
зафиксировать транзакцию
опубликовать("ЗаказСоздан", заказ.id)
Используйте паттерн Transactional Outbox: сохраняйте заказ и запись о событии в одной транзакции базы данных, а отдельный процесс публикует записи outbox и помечает их отправленными. Тогда событие не теряется при сбое между записью заказа и публикацией.
Проблема возникла из-за двойной записи: одна операция должна изменить локальную базу данных и внешнюю систему обмена сообщениями. Обычная транзакция базы данных не охватывает брокер сообщений или HTTP-вызов, поэтому между двумя действиями остается окно отказа.
Transactional Outbox появился как практический способ связать надежность локальной транзакции с последующей доставкой сообщения без требования распределенной транзакции между всеми системами.
В исходном коде заказ фиксируется до публикации события. Если процесс завершится после зафиксировать транзакцию, но до опубликовать, другие сервисы не узнают о созданном заказе.
Обратный порядок тоже опасен: событие может быть опубликовано, а транзакция базы данных затем откатится. Тогда потребитель обработает сообщение о заказе, которого фактически нет.
Интеграционный тест должен проверять не только успешную публикацию, но и поведение при сбое между этапами. Иначе тест подтвердит лишь счастливый путь и пропустит рассинхронизацию систем.
В одной транзакции сохраняют бизнес-данные и событие в таблицу outbox. После коммита отдельный воркер читает необработанные записи, публикует сообщения и фиксирует статус отправки.
Если процесс завершится до коммита, не появится ни заказ, ни outbox-запись. Если он завершится после коммита, запись останется и будет отправлена при следующем запуске воркера.
Публикация обычно должна быть как минимум однократной. Сбой после фактической отправки, но до отметки отправлено приведет к повторной публикации, поэтому потребитель должен обрабатывать события идемпотентно или применять дедупликацию по уникальному идентификатору события.
Нужно контролировать размер outbox, повторные попытки, задержку доставки, число ошибок и очистку успешно отправленных записей. Паттерн не гарантирует мгновенную доставку: между фиксацией заказа и публикацией возможна задержка.
Альтернативы имеют ограничения. Распределенная транзакция может обеспечить более сильную атомарность, но усложняет инфраструктуру и требует поддержки всеми участниками. Прямая публикация после коммита проще, однако оставляет окно потери события. Публикация до коммита дает обратную несогласованность.
В сервисе заказов событие ЗаказСоздан запускало резервирование товара. При высокой нагрузке процесс иногда завершался после записи заказа, поэтому заказ отображался пользователю, но товар не резервировался.
Рассматривались три варианта. Повторять HTTP-вызов из исходной транзакции было просто, но не устраняло проблему недоступности получателя и не защищало от сбоя после коммита. Использовать распределенную транзакцию было надежнее теоретически, но потребовало бы согласованной поддержки базы данных и брокера.
Выбрали Transactional Outbox с уникальным идентификатором события, повторными попытками и дедупликацией у потребителя. Интеграционная проверка принудительно завершала отправитель после коммита и убеждалась, что воркер позже публикует сохраненное событие. В результате временные сбои стали приводить к задержке обработки, а не к потере события.
Нет. Обычно он обеспечивает сохранение события и доставку с семантикой at-least-once, то есть возможны повторы. Повтор возникает, если сообщение принято брокером, но отправитель не успел записать признак успешной публикации.
Поэтому идентификатор события должен быть стабильным, а потребитель — хранить обработанные идентификаторы либо выполнять операцию так, чтобы повтор не менял итог.
Она должна находиться в той же транзакционной границе, что и изменяемые данные, согласованность которых нужно гарантировать. Если заказ записывается в одной базе, а outbox — в другой, исходная проблема двойной записи сохраняется.
Для нескольких локальных хранилищ потребуется отдельная стратегия согласования; простое добавление второй outbox-таблицы не делает операцию атомарной.
Тест должен явно задать критерий: после успешного коммита заказ существует, outbox содержит событие, а после восстановления издателя событие становится доступным потребителю в установленный предел времени. Немедленная проверка сразу после коммита некорректна, если система асинхронная.
Также следует проверить повторный запуск издателя и убедиться, что потребитель не создает второй резерв или другую побочную операцию. Такой тест проверяет не только наличие сообщения, но и корректность поведения при сбое и повторной доставке.