Откуда берутся дубли: анатомия проблемы

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

Я выделяю три сценария, каждый с разной природой.

Webhook retry от платёжной системы. Stripe, ЮKassa и любой другой шлюз ждут HTTP 200 в ответ на webhook. Не получили за таймаут (у Stripe это 30 секунд) - отправляют снова. По данным документации Stripe, повторные попытки идут с нарастающим интервалом на протяжении нескольких дней, но конкретное расписание стоит проверять в официальной документации: провайдеры периодически его меняют. ЮKassa ведёт себя похоже. Если ваш n8n притормозил на старте workflow, ответил с задержкой или вообще упал после приёма события, но до записи результата, платёжная система посчитает доставку неудачной и пришлёт тот же payment.succeeded ещё раз.

Повторный запуск workflow после сбоя. n8n в queue mode работает через Redis как брокер задач. Воркер берёт задачу, начинает выполнять, и если падает на середине, Redis через некоторое время возвращает задачу в очередь. Другой воркер её подбирает. Это правильное поведение с точки зрения отказоустойчивости. Но если первый воркер успел выполнить, например, списание через API, а потом упал уже после этого вызова, второй воркер об этом не знает и спишет снова. При сбое брокера ситуация хуже: задача может оказаться у двух воркеров одновременно.

Ручной перезапуск через UI. Самый предсказуемый сценарий, но от этого не менее опасный. Оператор видит в логах execution со статусом "error", жмёт "Retry", и workflow стартует заново. Никакой магии здесь нет: n8n просто запускает его с теми же входными данными. Если проблема была в сети на шаге "отправить письмо", а не на шаге "списать деньги", повторный запуск молча проведёт второе списание.

В n8n Community в 2024 году был показательный тред: пользователь настроил webhook от Stripe на создание подписки в своей базе и списание через Stripe API. Stripe прислал checkout.session.completed дважды (первый раз n8n ответил 200 с задержкой 28 секунд, Stripe не засчитал). Workflow не проверял, существует ли уже запись о платеже. Результат: два списания с карты клиента, два часа разбора полётов, ручной возврат через dashboard. Классика.

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

Диаграмма повторной отправки вебхука и дублирующегося события в очереди обработки

Один и тот же вебхук может прийти дважды: сеть упала, сервер не ответил 200, и платёжная система отправила запрос повторно.

Что такое идемпотентность и при чём тут n8n

Идемпотентная операция: запусти её один раз или сто, состояние системы будет одинаковым. Классический пример: DELETE /users/42. Первый вызов удаляет пользователя, второй просто получает 404. Итог тот же: пользователя нет. С GET то же самое: сколько раз ни читай ресурс, ты ничего не меняешь.

POST по спецификации HTTP идемпотентным не считается. Каждый вызов создаёт новую сущность или инициирует новое действие. И вот здесь начинаются проблемы: webhook-триггер n8n принимает именно POST.

Stripe, GitHub, Shopify, любой внешний сервис стреляет в твой webhook POST-запросом. Если что-то пошло не так на стороне отправителя, он повторит запрос. Может через 30 секунд, может через 5 минут, может три раза подряд. Это нормальное поведение: delivery at least once заложен в большинство webhook-систем на уровне архитектуры.

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

Значит, идемпотентность надо строить руками. Основа конструкции: ключ идемпотентности. Это уникальный идентификатор конкретного события, который ты используешь как барьер перед выполнением любых действий. Stripe кладёт его прямо в тело: поле id у каждого event-объекта выглядит как evt_1Nxxx. Shopify передаёт X-Shopify-Webhook-Id в заголовке. В самописных системах это может быть order_id, idempotency_key или любое поле, которое гарантированно одинаково для повторных доставок одного и того же события.

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

Весы с надписью идемпотентность: одна обработка против дублей

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

Паттерн 1: дедупликация через Redis SET NX

SET NX делает ровно одно: записывает ключ, только если его ещё нет. Если ключ уже существует, операция возвращает null и ничего не меняет. Атомарность встроена в Redis на уровне протокола, никаких гонок и race condition при параллельных запросах.

Практика такая. Приходит webhook от Stripe с id: evt_1ABC.... Ты берёшь этот ID, пишешь в Redis ключ stripe:evt_1ABC...:processed через SET NX с TTL 86400 секунд (сутки). Если ответ {"result":"OK"}, запрос новый, обрабатываешь. Если {"result":null}, этот ивент уже прошёл, останавливаешь цепочку.

Проблема с нативным Redis-нодом n8n

Встроенный Redis-нод в n8n по состоянию на июнь 2026 не поддерживает атомарный SET NX напрямую. Feature request висит в Community с 2023 года, воз и ныне там. Уточняйте актуальный список поддерживаемых команд в документации: возможности нода обновляются. Обходных путей два. Первый: Upstash Redis через HTTP Request нод. Upstash отдаёт REST API поверх обычного HTTPS, авторизация через Bearer-токен, тело запроса не нужно, всё в URL.

// HTTP Request к Upstash Redis REST API
// POST https://<host>.upstash.io/set/<key>/processed?nx=true&ex=86400
// Headers: Authorization: Bearer <token>
//
// Ответ: {"result":"OK"}   — новый запрос, продолжаем
// Ответ: {"result":null}   — дубль, прекращаем обработку

Второй путь: Execute Command нод с redis-cli SET stripe:{{ $json.id }}:processed 1 EX 86400 NX. Работает, если n8n развёрнут на своём сервере с доступом к redis-cli. В облачных managed-окружениях обычно недоступно.

TTL обязателен

Ключ без TTL останется в Redis навсегда. Если у тебя тысяча платёжных ивентов в день, через год это несколько миллионов ключей, которые ничего не делают кроме как занимают память. Строка SET key value EX 86400 NX ставит TTL атомарно вместе с записью. Никогда не делай SET NX и EXPIRE двумя отдельными командами: между ними процесс может упасть, и ключ останется без TTL.

Ответ инициатору

Когда SET NX вернул null, IF-нод должен вернуть HTTP 200, а не 409 или 4xx. Stripe при получении любого кода кроме 2xx ставит webhook в очередь повторной отправки. Получишь 409, получишь ретрай. А ретрай снова вернёт null, и ты снова ответишь 409. Классический цикл, который ломает очередь и засоряет логи. 200 говорит Stripe: "получил, всё хорошо", и тот успокаивается.

Схема работы Redis SET NX с TTL для блокировки дублирующихся вебхуков

Redis SET NX атомарно записывает ключ только если он отсутствует, что делает его удобным инструментом для дедупликации на лету.

Паттерн 2: дедупликация через PostgreSQL

Если n8n развёрнут в production по-человечески, Postgres уже есть в инфраструктуре. n8n сам хранит там свои данные об исполнениях, credentials, workflows. Подключение настроено. Так что добавить одну таблицу для дедупликации буквально бесплатно с точки зрения операционных расходов.

Создаём таблицу:

CREATE TABLE processed_events (
    event_id   VARCHAR PRIMARY KEY,
    received_at TIMESTAMP NOT NULL DEFAULT NOW(),
    workflow   VARCHAR NOT NULL
);

PRIMARY KEY на event_id делает всю работу. Никакой логики поверх не нужно.

Запрос в Postgres-ноде n8n:

-- Если результат пустой массив -> IF-нод -> Stop
INSERT INTO processed_events (event_id, received_at, workflow)
VALUES ('{{ $json.id }}', NOW(), 'stripe-payment')
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;

Логика простая: RETURNING event_id вернёт строку, если запись была вставлена. Если event_id уже существует, ON CONFLICT DO NOTHING тихо проглотит конфликт, и результат будет пустым массивом. После этого IF-нод проверяет длину результата: {{ $json.length === 0 }} ведёт в Stop and Branch.

Транзакция здесь не нужна. PRIMARY KEY constraint в Postgres атомарен сам по себе. Два параллельных INSERT с одинаковым event_id не дадут обоим пройти: один получит строку в RETURNING, второй получит пустой результат. Гонка исключена на уровне движка.

Про скорость честно: Postgres медленнее Redis на этой операции. Один INSERT с проверкой занимает порядка 1-5 мс против 50-200 мкс у Redis. Конкретная разница зависит от железа, нагрузки и конфигурации базы. Но для типичной webhook-нагрузки, скажем, Stripe или GitHub, где потолок обычно в районе десятков событий в секунду, эта разница не ощущается вообще. Redis имеет смысл, когда счёт идёт на тысячи событий в секунду или когда latency критична для SLA.

Есть и побочный плюс: таблица processed_events становится аудит-логом. Можно в любой момент посмотреть, какой workflow обработал какой event_id и когда. С Redis такого из коробки нет.

SQL-запрос INSERT INTO с конструкцией ON CONFLICT DO NOTHING в PostgreSQL

Конструкция ON CONFLICT DO NOTHING позволяет PostgreSQL молча игнорировать повторную вставку строки с уже существующим уникальным ключом.

Паттерн 3: идемпотентный ключ на стороне платёжной системы

Stripe и ЮKassa оба поддерживают идемпотентность на уровне API, и это закрывает один из самых неприятных сценариев: n8n отправил запрос на создание PaymentIntent, получил таймаут или 502, и теперь вы не знаете, списались ли деньги или нет.

Механика в обоих случаях одинакова. Stripe принимает заголовок Idempotency-Key при POST /v1/payment_intents. Если в течение 24 часов прийти с тем же ключом, Stripe вернёт ответ первого запроса без создания новой транзакции. ЮKassa работает через поле idempotence_key в теле запроса к POST /payments, срок жизни ключа там тоже 24 часа.

Ключ нужно генерировать до первого запроса и тут же сохранять в контексте заказа, а не вычислять на лету при каждом вызове. Формула uuid4() от order_id плюс фиксированный суффикс вроде -pay работает хорошо: она детерминирована для конкретного заказа и не создаёт коллизий между разными операциями по одному и тому же заказу (возврат, частичный захват и т.д. получат свои суффиксы).

В n8n это решается Code-нодом перед HTTP Request:

// Code-нод: генерация idempotency key
const orderId = $json.order_id;
const key = `order-${orderId}-pay`;

// Далее передаём idempotencyKey в HTTP Request -> Headers
return [{ json: { idempotencyKey: key } }];

В самом HTTP Request node добавляем заголовок Idempotency-Key со значением {{ $json.idempotencyKey }}. Для ЮKassa то же значение идёт в тело запроса.

Этот паттерн страхует от двойного создания транзакции при сетевых сбоях между n8n и платёжным API. Он не заменяет дедупликацию входящих webhook-ов со стороны вашего сервиса. Stripe может дважды прислать payment_intent.succeeded даже для одного PaymentIntent, и с этим надо разбираться отдельно через хранение обработанных event_id. Два механизма решают разные проблемы и должны работать вместе.

HTTP-заголовок Idempotency-Key в запросе к Stripe API

Stripe принимает заголовок Idempotency-Key и гарантирует, что повторный запрос с тем же ключом не создаст второй платёж.

Как правильно ответить на webhook, чтобы не спровоцировать retry

Stripe ждёт HTTP 200 в течение 30 секунд. Не получил, считает доставку неудачной и ставит событие в очередь на повтор. Звучит разумно, но на практике это создаёт проблему: n8n по умолчанию держит соединение открытым до конца всего workflow. Если у тебя там запросы к внешним API, запись в базу и отправка письма, эти 30 секунд улетают быстро.

Решение прямолинейное: поставить нод "Respond to Webhook" сразу после входа, до любой тяжёлой логики. Тело ответа минимальное, статус 200, и соединение закрыто. Stripe доволен. Остаток workflow выполняется асинхронно, и тебе уже всё равно, сколько он займёт.

Вот конкретная последовательность нодов:

Webhook → Respond to Webhook (200 OK) → проверка подписи → идемпотентность → бизнес-логика

Второй момент с дублями. Когда идемпотентный ключ уже есть в Redis и ты понимаешь, что это повтор, не возвращай 409 или 422. Stripe трактует любой 4xx как сбой на своей стороне или на твоей, и ретраит снова. Правильный ответ на дубль: тот же 200, просто с другим телом, например {"status": "already_processed"}. Событие будет помечено как доставленное, ретраев не будет.

Дубли при этом стоит фиксировать. Я пишу их в отдельную таблицу в Postgres: event_id, received_at, source_ip, тип события. Это не для отладки в моменте. Через неделю смотришь на эту таблицу и видишь, например, что конкретный тип события (customer.subscription.updated) прилетает в два раза чаще, чем должен, и всегда с интервалом ровно 30 секунд. Это точный признак того, что где-то в цепочке ответ уходит с задержкой, и Stripe успевает запустить retry до закрытия соединения.

Redis SET NX здесь работает как первый фильтр: быстро, без обращения к базе. Postgres-лог работает как аудит: медленнее, но с историей. Оба нужны по разным причинам.

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

Временная шкала: сервер отвечает 200 до начала фоновой обработки события

Ответить 200 сразу и обработать вебхук асинхронно, это способ избежать повторных попыток из-за таймаута на стороне отправителя.

Дедупликация заказов из WooCommerce и других CMS

WooCommerce умеет отправить одно и то же событие order.created дважды. Это происходит при обрыве соединения, когда плагин не получил ответ 200 и повторил запрос, или когда кто-то в панели администратора нажал "Отправить повторно" на вкладке Webhooks. Итог: в CRM два одинаковых заказа, задвоенная сумма в отчёте, злой менеджер.

Простейший ключ дедупликации: поле id из тела заказа. WooCommerce гарантирует его уникальность внутри одного магазина. Сохраняете id в Redis или в отдельной таблице, перед записью проверяете. Если запись есть, возвращаете 200 (не 409, иначе WooCommerce посчитает это ошибкой и повторит снова) и ничего не делаете.

Если вы интегрируете несколько магазинов в одну систему, id = 1047 могут прислать три разных магазина. Переходите на составной ключ: shop_domain + order_id. Например, "myshop.com:1047". Хэшировать смысла нет, строка и так короткая, хранится нормально.

Но иногда Redis или отдельной БД под дедупликацию просто нет. Тогда работает более медленный, зато самодостаточный вариант: перед созданием заказа в CRM делаете GET /orders?external_id=1047 и смотрите на ответ. Если заказ уже есть, пропускаете. Это добавляет один HTTP-запрос на каждое событие и немного увеличивает latency, но убирает инфраструктурную зависимость. Для магазинов с нагрузкой до нескольких сотен заказов в сутки разницы вы не почувствуете.

Отдельный момент с Shopify, Tilda и самописными CMS: у них нет такого же гарантированного целочисленного id. Shopify присылает id как gid://shopify/Order/1234567890, Tilda генерирует свои строковые идентификаторы, а самописный сайт может прислать вообще что угодно. Заранее договоритесь, какое поле считается внешним идентификатором, и закрепите это в документации интеграции, а не в голове разработчика.

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

Составной ключ дедупликации из event_id, order_id и типа события в WooCommerce

Составной ключ из идентификатора события и идентификатора заказа защищает от коллизий, когда одна система генерирует похожие события для разных объектов.

Тестирование идемпотентности: как убедиться, что защита работает

Написать проверку на дубли и не протестировать её, это примерно как застегнуть ремень безопасности и не проверить, защёлкнулся ли замок. Вот как я это делаю.

ЮKassa Sandbox. В личном кабинете sandbox есть кнопка повторной отправки webhook для конкретного события. Берёшь реальный payment.succeeded, жмёшь "Отправить повторно" дважды с интервалом в секунду. Если во второй раз транзакция не создалась, а в логах появился duplicate_skipped, защита работает. Никакого прода, никакого риска.

Stripe CLI даёт больше контроля. Команда:

stripe trigger payment_intent.succeeded --override id=evt_test_123

Запускаешь её три раза подряд с одним и тем же id. Первый запуск обрабатывается, второй и третий должны вернуть 200 без сайд-эффектов. Именно 200, а не 409. Stripe ожидает, что ты уже видел это событие и подтверждаешь получение.

В n8n схема чуть другая. Берёшь Exec ID первого успешного запуска из интерфейса, потом запускаешь workflow вручную через "Test workflow" с тем же payload, подставив этот ID как event_id. Смотришь в историю выполнений: второй прогон должен пройти тот же путь до узла проверки идемпотентности и там остановиться, не дойдя до записи в БД.

Хороший признак корректной работы: второй запрос с уже виденным event_id завершается заметно быстрее первого. Это означает, что система дошла только до Redis или до SELECT в Postgres и вернулась, не выполняя бизнес-логику. Если время выполнения второго запроса сопоставимо с первым, стоит разобраться: возможно, дубль всё-таки обрабатывается полностью.

Отдельно добавляю в мониторинг счётчик отбитых дублей. В Prometheus это выглядит как инкремент webhook_duplicates_total с лейблом источника (yookassa, stripe, internal). В норме это единицы в день, сетевые ретраи. Если счётчик вдруг вырастает до сотен в час, это уже сигнал: либо у платёжного провайдера проблемы с их стороной (retry loop), либо что-то сломалось в обработке и система сама себя переотправляет. Без этого счётчика такую ситуацию можно не заметить неделями.

Терминал с командой Stripe CLI для тестирования повторной отправки вебхука

Stripe CLI позволяет вручную повторить любое событие командой stripe events resend, что удобно для отладки логики дедупликации.

Типичные ошибки при реализации и как их избежать

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

Timestamp как ключ идемпотентности. Выглядит логично: берёшь Date.now() или new Date().toISOString(), кладёшь в Redis. Проблема в том, что два события от той же платёжной системы, пришедших с интервалом меньше миллисекунды, получат одинаковый timestamp и пройдут как один. Или наоборот: одно событие придёт дважды с разными таймстампами, и защита вообще не сработает. Всегда берём event_id из тела запроса. Stripe кладёт его в evt_xxxxxxxx, PayPal в id, большинство провайдеров делают аналогично.

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

Проверка дубля в середине флоу. Вижу это постоянно: разработчик ставит Code-нод с проверкой Redis где-то после HTTP Request к внешнему API или после тяжёлого SQL-запроса. Логика понятна, ему казалось, что сначала нужно "подготовить данные". Но если событие пришло дважды, оба раза выполнится дорогой запрос, и только потом воркфлоу поймёт, что это дубль. Проверка идёт первым шагом сразу после Webhook-нода, до любой логики.

**

Чеклист пяти типичных ошибок при реализации идемпотентности вебхуков
Самые частые ошибки: использование нестабильного ключа, отсутствие TTL у Redis-записи, обработка до ответа 200 и игнорирование гонки потоков.