АрхитектураАрхитектура ПОИнженер по распределённым системам

Как повторная доставка одного сообщения влияет на систему, если обработчик не идемпотентен?

Как повторная доставка одного сообщения влияет на систему, если обработчик не идемпотентен?

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

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

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

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

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

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

Так появляется компромисс at-least-once: сообщение не должно теряться, но обработчик обязан выдерживать дубликаты. Строгая гарантия единственного выполнения во всей распределённой системе сложна, особенно когда обработчик взаимодействует с несколькими внешними системами.

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

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

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

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

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

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

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

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

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

Модель at-most-once уменьшает риск дубликатов, если подтверждать сообщение до выполнения работы, но повышает риск потери. Модель exactly-once может быть достижима внутри ограниченной транзакционной границы, однако не гарантирует единственное выполнение побочных эффектов за её пределами. Поэтому на практике обычно принимают повторную доставку и проектируют операции идемпотентными.

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

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

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

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

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

1. Достаточно ли проверить наличие идентификатора сообщения перед выполнением операции?

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

2. Что произойдёт, если обработчик сохранит отметку «обработано» до внешнего вызова?

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

3. Почему уникальный идентификатор сообщения не делает любую операцию идемпотентной?

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