АрхитектураМикросервисы и интеграцииИнженер по распределённым системам

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

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

Проходите собеседования с ИИ помощником Hintsage

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

Обработчик должен быть идемпотентным: повторная обработка одного логического события не должна повторять побочный эффект. Для этого событие получает стабильный идентификатор, а обработчик атомарно фиксирует факт обработки вместе с изменением своего состояния; для внешнего платёжного провайдера дополнительно используется тот же ключ идемпотентности.

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

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

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

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

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

Допустим, сервис заказов публикует событие об оплате, а платёжный сервис получает его и вызывает внешнюю платёжную систему. Обработчик может успешно списать деньги, но потерять ответ из-за сетевого сбоя; после этого брокер доставит событие повторно.

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

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

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

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

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

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

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

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

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

Рассматривались три варианта. Запретить повторы на уровне брокера было бы недостаточно: сбой мог произойти после доставки. Использовать только флаг обработанности до вызова провайдера означало бы рисковать потерей платежа. Выполнять повторный вызов без ключа операции было опасно из-за возможного повторного списания.

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

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

  1. Достаточно ли уникального ограничения в базе данных для идемпотентной обработки?

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

  1. Можно ли считать обработку идемпотентной, если у каждого повтора новый идентификатор сообщения?

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

  1. Как обработчик должен действовать, если первый запуск завершился неизвестным результатом?

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