В CRM лежат две одинаковые карточки заказа. Покупатель уверяет, что нажал кнопку один раз, и, скорее всего, не лжёт. Он действительно сделал один заказ. Но n8n запускал workflow дважды, и каждый запуск честно выполнил всё, что ему велели. Вся задача сводится к одному различению: событие в магазине произошло один раз, а выполнений workflow по нему может быть несколько.

Эти две сущности живут в разных системах и имеют разные идентификаторы. У события есть номер, который присвоил отправитель. У выполнения есть номер в журнале n8n. Журнал запуска и идентификатор события отправителя — разные вещи, и если не связать их самим, то в момент повторной доставки узнать её будет нечем. Дальше разберём, где поставить защиту, чтобы она держалась не только при обычном повторе запроса, но и при повторе упавшего узла.

Почему одно событие приходит дважды

Webhook-узел n8n запускает workflow HTTP-запросом, и для него существуют отдельные test и production URL; production-регистрация связана с публикацией workflow (документация Webhook). Для узла принять запрос и успешно выполнить внешнее действие — разные вещи: первое никак не доказывает второе. Документация также не обещает, что бизнес-операции станут идемпотентными сами по себе. То есть каждый принятый запрос — это новый запуск, а умеет ли он отличить «тот же заказ» от «другого заказа», решает уже ваша схема.

Откуда берутся повторы? Типичные механизмы лежат на стороне отправителя, и мы опишем их как общую инженерную практику, а не как поведение конкретного сервиса:

  • отправитель не дождался ответа в своё время (n8n был занят, сеть моргнула) и отправил то же событие снова;
  • ответ был получен, но отправитель трактует его как неуспешный, например из-за кода статуса;
  • событие переотправляют вручную из панели магазина или платёжной системы;
  • на одной стороне настроены два подписанных адреса, и оба ведут в ваш workflow.

Здесь легко попасться на логику «ответим быстрее — и повторов не будет». Быстрый ответ снижает их число, но не отменяет: отправитель вправе повторить доставку по своим причинам. Режим ответа Webhook-узла выбирается отдельно, и это полезная настройка, но защитой от дублей она не служит. Защита должна лежать там, где повтор нельзя пропустить, — в хранилище с уникальностью.

Что считать «тем же событием»

Прежде чем писать SQL, надо решить, по какому признаку мы сравниваем два входящих запроса. Хеш всего тела — соблазнительный, но хрупкий вариант: в теле может быть время отправки или счётчик попыток, и тогда один и тот же заказ даст два разных хеша. Новый случайный идентификатор, сгенерированный внутри workflow, не годится вовсе: он будет разным у каждой попытки и ничего не сопоставит.

Устойчивый ключ берут из самого источника. Обычно подходят два поля:

  • идентификатор источника — короткая метка вроде shop или payments; она нужна, чтобы номера из разных систем не столкнулись;
  • идентификатор события — то, что отправитель присвоил именно этой доставке или этому бизнес-факту.

Тут есть тонкость, которую стоит решить заранее. Некоторые отправители присылают id события (одинаковый при повторной доставке), некоторые — только id заказа, а при смене статуса заказа приходят новые события с тем же заказом. Если ключом сделать id заказа, второе законное событие «заказ оплачен» будет принято за дубль «заказ создан». Если же ключом сделать id доставки, который меняется при ручной переотправке, мы получим дубль. Выбирайте ключ под смысл операции: для «создать карточку заказа» естественный ключ — источник плюс номер заказа; для «обработать смену статуса» — источник плюс id события или пара «заказ + статус». Ответ на этот вопрос лежит в документации отправителя, а не в n8n.

По поводу хранения: составную уникальность в PostgreSQL выражает ограничение UNIQUE на комбинацию столбцов, а PRIMARY KEY объединяет уникальность с запретом NULL (PostgreSQL: Constraints). Составной ключ из источника и идентификатора события — это наша инженерная рекомендация, а не готовая интеграция n8n; ниже она показана как учебная схема, которую нужно проверить на вашем окружении.

Право на обработку: одна вставка вместо «проверить, затем записать»

Первая мысль, как правило, такая: сначала SELECT — есть ли уже такое событие, а если нет, то обработать и записать. Проблема в зазоре между проверкой и записью. Два запроса, пришедшие почти одновременно, оба увидят «пусто», оба пойдут дальше, и защита, которая отлично работает в тесте с одним запросом, ломается ровно тогда, когда повтор приходит быстро.

Решение — не проверять, а занимать: попытаться вставить запись, и пусть уникальное ограничение выступит судьёй. INSERT в PostgreSQL умеет ON CONFLICT (столбцы) DO NOTHING и RETURNING, а RETURNING отдаёт только фактически вставленные строки (PostgreSQL: INSERT). Отсюда простое правило: вернулась строка — мы первые и получили право обрабатывать; не вернулось ничего — событие уже зарегистрировано.

Учебная схема (имена условные, на реальном n8n как целое не проверялась):

CREATE TABLE webhook_events (
  source      text        NOT NULL,
  event_id    text        NOT NULL,
  status      text        NOT NULL DEFAULT 'processing',
  created_at  timestamptz NOT NULL DEFAULT now(),
  updated_at  timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (source, event_id)
);

INSERT INTO webhook_events (source, event_id)
VALUES ('shop', 'order-1042')
ON CONFLICT (source, event_id) DO NOTHING
RETURNING event_id;

В n8n это узел Postgres с операцией выполнения запроса, в параметры которого подставляются значения из тела вебхука. Сразу за ним ставится ветвление (например, узел IF): есть ли у результата строка. Если есть — идём по ветке создания заказа. Если пусто — завершаем выполнение без внешних действий и отвечаем отправителю успехом: для него повтор доставлен, дальше повторять ему нечего. Обратите внимание: ответ «успех» на дубль — осознанный выбор, а не ошибка. Если вернуть ошибку, отправитель может продолжить присылать то же событие ещё долго.

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

Ловушка: база записала, а CRM — неизвестно

Теперь самое важное, ради чего статья и написана. Запись о событии в PostgreSQL и вызов CRM — две разные системы. Транзакция вашей базы не включает чужой HTTP-запрос, и нельзя откатить его, если что-то пошло не так. Это граница, которую не обойти хитрым SQL.

Посмотрите на временную линию одного неудачного прогона (время условное):

Сбой после успешного внешнего действия
МоментЧто происходитЧто знает база
t0Приходит событие order-1042, вставка проходит, строка получает статус processingсобытие занято
t1Узел HTTP отправляет в CRM запрос на создание заказаprocessing
t2CRM создаёт заказ и начинает отвечатьprocessing
t3Ответ теряется: таймаут или обрыв соединения, узел падает с ошибкойprocessing
t4Кто-то повторяет выполнение — или приходит повторная доставкаprocessing, исход неизвестен

На шаге t3 заказ в CRM уже есть, но workflow об этом не знает: он видит только ошибку. Если сейчас просто «попробовать ещё раз», получится тот самый второй заказ. Если же пометить событие как failed и разрешить повтор, ситуация та же. Единственный честный статус здесь — «исход неизвестен», и с ним надо что-то делать иначе, чем с обычной ошибкой.

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

  1. Ключ идемпотентности на стороне CRM. Если API принимает клиентский идентификатор запроса или внешний номер заказа и сам отвергает повтор, передавайте туда ваш бизнес-ключ, а не случайное значение. Оговорка: случайный идентификатор попытки, сгенерированный заново при каждом прогоне, защиты не даёт — он различает попытки, а не события.
  2. Поиск перед созданием. Если CRM позволяет найти заказ по внешнему номеру, перед повторной попыткой спросите у неё, не создан ли он уже. Это уменьшает риск, но не уничтожает его полностью, если два прогона идут параллельно.
  3. Ручной разбор. Если ни того, ни другого нет, статус «исход неизвестен» должен стать задачей для человека: один взгляд в CRM быстрее и дешевле, чем чистка дублей и переписка с покупателем.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Состояния вместо галочки «обработано»

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

  • processing — право получено, внешнее действие в работе или его исход неясен;
  • done — внешнее действие подтверждено ответом CRM;
  • failed — действие заведомо не выполнено (например, CRM явно вернула отказ до создания заказа), и повтор безопасен.

Различие между failed и «неизвестно» принципиально. Ошибка валидации с кодом 4xx, при которой CRM ясно сказала «заказ не создан», — это failed. Таймаут после отправки запроса — это не failed, а processing, который не стоит автоматически считать безопасным для повтора. Вот правило, которое хорошо защищает от самообмана: переводить в failed можно только то, что вы точно знаете.

После успешного ответа CRM отдельным узлом Postgres фиксируем результат:

UPDATE webhook_events
SET status = 'done', updated_at = now()
WHERE source = 'shop' AND event_id = 'order-1042'
  AND status = 'processing'
RETURNING event_id;

Условие status = 'processing' в WHERE — не декорация. Оно делает переход условным, а RETURNING сообщает, сколько строк реально обновилось (PostgreSQL: UPDATE).

Когда повтор разрешён: захват, а не чтение

Допустим, вы всё же хотите автоматически повторять события в состоянии failed. Наивная реализация такова: прочитать строку, увидеть failed, пойти выполнять. Если повторов два, оба прочитают failed и оба пойдут. Это та же гонка «проверил — записал», только на втором круге.

Исправление то же, что и при первой вставке: не читать, а захватывать условным обновлением.

UPDATE webhook_events
SET status = 'processing', updated_at = now()
WHERE source = 'shop' AND event_id = 'order-1042'
  AND status = 'failed'
RETURNING event_id;

Вернулась строка — вы стали исполнителем. Пусто — кто-то другой уже забрал её, или статус другой, и действовать не нужно. Конкурентный случай при уровне изоляции Read Committed по умолчанию выглядит так: второй UPDATE ждёт завершения первой транзакции, а затем повторно проверяет условие WHERE на обновлённой версии строки, видит там уже не failed и не трогает её (PostgreSQL: изоляция транзакций). Это объясняет, почему условный захват надёжен. Но запомним границу: механика защищает выбор исполнителя внутри базы и не превращает вызов внешнего API в однократный.

Состояние processing с давним updated_at — отдельная история. Это либо прогон, который ещё идёт, либо упавший на полпути. Автоматически пересматривать его по таймеру я бы не стал именно из-за неизвестного исхода: лучше отправить такие строки в отдельный список для проверки человеком или сверки с CRM.

Retry в n8n: защита в начале может не сработать

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

Из этого вытекают два следствия.

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

Второе неприятное: неудачный узел выполняется заново. Если это узел, создающий заказ в CRM, он отправит запрос ещё раз — а мы как раз на шаге t3 из таблицы выше не знаем, был ли предыдущий запрос успешным. Защита на входе, которая отсекает дубли событий, тут ничего не отсекает: она уже отработала и честно пропустила этот прогон. Это инженерный вывод, а не заявление документации, но он напрямую следует из описанного поведения: внешнее действие нуждается в собственной защите и проверке неопределённого исхода.

Есть ещё нюанс с элементами. Узел обрабатывает входные элементы, и если их несколько (например, позиции заказа или пакет заказов), то при сбое посередине часть уже ушла во внешний сервис, а при Retry узел пройдёт по всем входным элементам заново. Поэтому ключ идемпотентности стоит строить на уровне элемента, если один вебхук порождает несколько внешних операций: источник, id события и порядковый или бизнес-номер позиции. Тогда повтор узнаёт, какие элементы уже сделаны, а какие нет.

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

Уведомление об ошибке и связь с журналом

Чтобы неудачный прогон не пролежал незамеченным, назначьте для workflow отдельный error workflow, который начинается с Error Trigger (n8n: обработка ошибок). В сообщение полезно включить источник и id события, а не только номер выполнения: по нему вы сразу найдёте строку в таблице и CRM. Помните, что при ошибке в самом триггере контекст выполнения может быть менее полным, поэтому на «всё есть в error workflow» полагаться нельзя.

Там же пригодится поле execution.retryOf: оно появляется только у повторного запуска неудачного выполнения. Его можно записывать в журнал, чтобы отличать ручной Retry от новой доставки: у повторной доставки это поле пустое, но событие уже есть в таблице, а у Retry — заполнено. Это два разных вида «повтора», и различать их в логах полезно, потому что реагировать на них нужно по-разному.

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

Где держать таблицу и сколько на это нужно ресурсов

Таблица событий — маленькая: две строки текста, статус и пара меток времени на заказ. Нагрузки она почти не создаёт; обязательное требование другое — она должна переживать перезапуск и обновление n8n и лежать не в памяти воркфлоу. Если вы развёртываете n8n сами, имеет смысл поставить PostgreSQL рядом на том же сервере или отдельной базой на другом: у VPS есть root-доступ, а значит, вы сами решаете, как организовать хранилище. На VPS MAATRIX (NVMe, AMD EPYC, root-доступ) такая связка n8n и PostgreSQL развёртывается без ограничений на установку своего ПО; в публичном каталоге предложены и готовые установки n8n, а точное наличие образа и ОС уточняется в форме заказа.

Размер тарифа выбирайте по общей нагрузке workflow, а не по таблице дублей: она в этом смысле почти ничего не весит. Для пробного стенда хватит начального уровня Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe), а при росте числа одновременных выполнений и тяжёлых узлов имеет смысл смотреть на Uab (2 vCPU, 4 ГБ) и выше. Это ресурсы машины, а не обещание конкретной пропускной способности: реальную ёмкость покажет замер на ваших workflow. Резервные копии базы с таблицей событий организуйте отдельно — наличие автоматических бэкапов на всех тарифах мы не утверждаем.

Контрольный прогон: как убедиться, что защита работает

Защиту от дублей легко «проверить» одним запросом и ошибочно успокоиться. Нужна короткая последовательность, в которой каждая проверка ловит свой класс проблем. Используйте test-адрес и тестовую CRM, а не боевую.

  1. Тот же запрос дважды подряд. Отправьте одно тело с одним id события два раза. Ожидание: в CRM один заказ, в таблице одна строка, второй прогон завершился без внешних действий, отправитель получил успех.
  2. Два запроса одновременно. Запустите два одинаковых запроса параллельно. Ожидание то же. Именно этот шаг ловит схему «SELECT, потом INSERT».
  3. Другой id — другой заказ. Отправьте событие с новым идентификатором. Должна появиться вторая строка и второй заказ: защита не должна глотать законные события.
  4. Тот же id из другого источника. Проверьте, что составной ключ не склеивает разные системы.
  5. Сбой после внешнего действия. Искусственно оборвите ответ CRM (например, заглушкой, которая создаёт заказ и потом не отвечает). Ожидание: строка осталась в processing, автоматического второго заказа нет, а в уведомлении видны источник и id события.
  6. Retry неудачного узла. Выполните ручной Retry и посмотрите, что произошло с внешним действием: сработал ли поиск по внешнему номеру или ключ идемпотентности CRM.

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

Что должно получиться в итоге

Вернёмся к двум карточкам в CRM. После описанных изменений картина другая. Повторная доставка того же события упирается в уже занятый ключ, вставка не возвращает строк, workflow молча отвечает отправителю «принято» и ничего не создаёт. Параллельный дубль теряется на ограничении уникальности, а не на надежде, что запросы не совпадут по времени. А в редкой ситуации, когда CRM могла успеть создать заказ, но мы не услышали ответа, система не изображает уверенность: строка остаётся в processing, человек получает уведомление с номером заказа и идёт посмотреть, что там на самом деле. Это не так эффектно, как «всегда ровно один раз», зато честно: неизвестный исход остаётся неизвестным и не превращается в новую попытку.