ТестированиеТестирование API и интеграцийИнженер по качеству интеграций

Практическая ситуация: сервис записывает заказ в базу данных и публикует событие для другого сервиса. При с...

Практическая ситуация: сервис записывает заказ в базу данных и публикует событие для другого сервиса. При сбое между этими действиями заказ сохраняется, но событие теряется. Какой механизм устранит этот разрыв?

начать транзакцию базы данных
заказ = сохранить_заказ(данные)
зафиксировать транзакцию
опубликовать("ЗаказСоздан", заказ.id)
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Используйте паттерн Transactional Outbox: сохраняйте заказ и запись о событии в одной транзакции базы данных, а отдельный процесс публикует записи outbox и помечает их отправленными. Тогда событие не теряется при сбое между записью заказа и публикацией.

Исторический контекст

Проблема возникла из-за двойной записи: одна операция должна изменить локальную базу данных и внешнюю систему обмена сообщениями. Обычная транзакция базы данных не охватывает брокер сообщений или HTTP-вызов, поэтому между двумя действиями остается окно отказа.

Transactional Outbox появился как практический способ связать надежность локальной транзакции с последующей доставкой сообщения без требования распределенной транзакции между всеми системами.

Постановка проблемы

В исходном коде заказ фиксируется до публикации события. Если процесс завершится после зафиксировать транзакцию, но до опубликовать, другие сервисы не узнают о созданном заказе.

Обратный порядок тоже опасен: событие может быть опубликовано, а транзакция базы данных затем откатится. Тогда потребитель обработает сообщение о заказе, которого фактически нет.

Интеграционный тест должен проверять не только успешную публикацию, но и поведение при сбое между этапами. Иначе тест подтвердит лишь счастливый путь и пропустит рассинхронизацию систем.

Подробное решение

В одной транзакции сохраняют бизнес-данные и событие в таблицу outbox. После коммита отдельный воркер читает необработанные записи, публикует сообщения и фиксирует статус отправки.

начать транзакцию заказ = сохранить_заказ(данные) outbox = сохранить_событие("ЗаказСоздан", заказ.id) зафиксировать транзакцию для каждого события из outbox: опубликовать(событие) пометить_как_отправленное(событие)

Если процесс завершится до коммита, не появится ни заказ, ни outbox-запись. Если он завершится после коммита, запись останется и будет отправлена при следующем запуске воркера.

Публикация обычно должна быть как минимум однократной. Сбой после фактической отправки, но до отметки отправлено приведет к повторной публикации, поэтому потребитель должен обрабатывать события идемпотентно или применять дедупликацию по уникальному идентификатору события.

Нужно контролировать размер outbox, повторные попытки, задержку доставки, число ошибок и очистку успешно отправленных записей. Паттерн не гарантирует мгновенную доставку: между фиксацией заказа и публикацией возможна задержка.

Альтернативы имеют ограничения. Распределенная транзакция может обеспечить более сильную атомарность, но усложняет инфраструктуру и требует поддержки всеми участниками. Прямая публикация после коммита проще, однако оставляет окно потери события. Публикация до коммита дает обратную несогласованность.

Ситуация из практики

В сервисе заказов событие ЗаказСоздан запускало резервирование товара. При высокой нагрузке процесс иногда завершался после записи заказа, поэтому заказ отображался пользователю, но товар не резервировался.

Рассматривались три варианта. Повторять HTTP-вызов из исходной транзакции было просто, но не устраняло проблему недоступности получателя и не защищало от сбоя после коммита. Использовать распределенную транзакцию было надежнее теоретически, но потребовало бы согласованной поддержки базы данных и брокера.

Выбрали Transactional Outbox с уникальным идентификатором события, повторными попытками и дедупликацией у потребителя. Интеграционная проверка принудительно завершала отправитель после коммита и убеждалась, что воркер позже публикует сохраненное событие. В результате временные сбои стали приводить к задержке обработки, а не к потере события.

Что кандидаты часто упускают

  1. Гарантирует ли outbox ровно однократную доставку?

Нет. Обычно он обеспечивает сохранение события и доставку с семантикой at-least-once, то есть возможны повторы. Повтор возникает, если сообщение принято брокером, но отправитель не успел записать признак успешной публикации.

Поэтому идентификатор события должен быть стабильным, а потребитель — хранить обработанные идентификаторы либо выполнять операцию так, чтобы повтор не менял итог.

  1. Где должна находиться outbox-запись при использовании нескольких баз данных?

Она должна находиться в той же транзакционной границе, что и изменяемые данные, согласованность которых нужно гарантировать. Если заказ записывается в одной базе, а outbox — в другой, исходная проблема двойной записи сохраняется.

Для нескольких локальных хранилищ потребуется отдельная стратегия согласования; простое добавление второй outbox-таблицы не делает операцию атомарной.

  1. Как интеграционный тест отличит потерю события от допустимой задержки?

Тест должен явно задать критерий: после успешного коммита заказ существует, outbox содержит событие, а после восстановления издателя событие становится доступным потребителю в установленный предел времени. Немедленная проверка сразу после коммита некорректна, если система асинхронная.

Также следует проверить повторный запуск издателя и убедиться, что потребитель не создает второй резерв или другую побочную операцию. Такой тест проверяет не только наличие сообщения, но и корректность поведения при сбое и повторной доставке.