Вебхуки приходили по три раза, и товар отгрузили трижды
В пятницу вечером служба поддержки получила три однотипных обращения подряд: «мне привезли два одинаковых заказа, а на складе говорят, что отправили три». Разбор занял два дня и упёрся в простую вещь, которую разработчики откладывали полгода — обработчик вебхука не был идемпотентным. Ниже — как выглядел инцидент изнутри, какие версии отбросили и что в итоге поменяли в коде и инфраструктуре.
Содержание
Что случилось
Интернет-магазин принимал вебхук от платёжного шлюза: событие «оплата подтверждена» дергало внутренний эндпоинт /webhooks/payment, тот проверял подпись, помечал заказ оплаченным и синхронно вызывал API склада на отгрузку. Схема работала больше года без проблем — до дня распродажи, когда нагрузка на склад выросла и его API начал отвечать медленнее обычного.
За вечер накопилось 11 заказов, отправленных на склад более одного раза: 8 — дважды, 3 — трижды. Прямые издержки — обратная логистика, компенсации, испорченные отношения с частью клиентов. Косвенные — два дня разбора и работа склада по возврату лишних отправлений.
Первый сигнал пришёл не из мониторинга, а от поддержки: автоматических алертов на «заказ отгружен более одного раза» не существовало вовсе. Это отдельная проблема, к которой ещё вернёмся в разделе про мониторинг.
Что показали логи и метрики
Начали с логов эндпоинта /webhooks/payment. Отфильтровали по order_id одного из проблемных заказов и увидели три записи с одинаковым телом запроса:
2026-08-21T19:02:11Z POST /webhooks/payment event_id=evt_7f21ac order_id=48291 status=200 duration_ms=8412
2026-08-21T19:02:41Z POST /webhooks/payment event_id=evt_7f21ac order_id=48291 status=200 duration_ms=7960
2026-08-21T19:05:11Z POST /webhooks/payment event_id=evt_7f21ac order_id=48291 status=200 duration_ms=8103
Три ключевые детали сразу бросились в глаза:
event_idу всех трёх запросов одинаковый — это не три разных события, а три доставки одного и того же.- Интервалы между попытками — около 30 секунд и потом около 3 минут, что похоже на типичную схему ретраев с бэкоффом на стороне шлюза.
duration_msу каждого запроса больше 7 секунд — обработчик не просто медленный, он стабильно медленный именно в это время.
Дальше подняли графики. Метрика p95 времени ответа /webhooks/payment в обычные дни держалась в районе 300–500 мс, а в вечер инцидента ушла за 8 секунд и держалась там почти час — ровно на пике распродажи. Параллельно графики API склада показали такой же рост латентности отдельных ручек создания отгрузки.
Стало ясно, что задержка возникала не в самом обработчике вебхука, а в синхронном вызове склада внутри него — обработчик ждал ответа склада, прежде чем вернуть 200 OK шлюзу.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые не подтвердились
Прежде чем зафиксировать причину, проверили четыре версии — каждая казалась правдоподобной, но ни одна не объясняла картину целиком.
Балансировщик дублирует запросы. Первая мысль — HAProxy или nginx перед приложением ретраит запрос сам, если бэкенд не ответил вовремя. Проверили конфиг: proxy_next_upstream в nginx был выключен для POST, retry на уровне LB для этого location не настроен. В access-логе балансировщика на каждый входящий запрос — ровно одна запись на бэкенд, без внутренних повторов. Версию закрыли.
Гонка при параллельной обработке. Предположили, что несколько воркеров приложения одновременно взяли один и тот же вебхук из какой-то общей точки входа и оба его обработали. Но эндпоинт вебхука не читал ничего из очереди — это был прямой HTTP-хендлер, вызываемый шлюзом напрямую, без промежуточного брокера. Внутренней гонки быть не могло, потому что не было общего ресурса, за который могли бы конкурировать два воркера.
Двойной клик или повторная отправка формы на клиенте. Логично для веб-форм, но здесь вебхук — это server-to-server вызов от платёжного шлюза, браузер клиента в этом потоке вообще не участвует. Версию отбросили сразу же, как только посмотрели на источник запроса — статический IP шлюза, а не IP клиента.
Очередь сообщений повторно доставила событие. Проверили, не стоит ли между шлюзом и обработчиком какой-нибудь брокер (RabbitMQ, Kafka), который мог бы редоставить сообщение из-за не отправленного ack. Оказалось, что очереди в этом потоке не было вообще — именно отсутствие буфера между внешним вызовом и обработкой и стало корнем проблемы, но не в том смысле, что кто-то не подтвердил получение из очереди.
Реальная причина
Платёжный шлюз, как и большинство подобных сервисов, ретраит доставку вебхука, если не получает 2xx-ответ в течение заданного таймаута (в документации шлюза значился таймаут около 5 секунд) или получает явную ошибку. Схема ретраев была примерно такой: повтор почти сразу, потом через 30 секунд, потом ещё через несколько минут — именно эти интервалы мы и увидели в логах.
Обработчик делал так:
@app.post("/webhooks/payment")
def handle_payment_webhook(request):
event = verify_signature(request) # быстро
order = get_order(event.order_id) # быстро
order.mark_paid() # быстро
warehouse_client.create_shipment(order.id) # медленно — синхронный HTTP-вызов
return {"status": "ok"}, 200
В обычный день create_shipment укладывался в пару сотен миллисекунд, и всё дерево вызовов успевало отработать быстрее таймаута шлюза. В день распродажи склад начал отвечать за 7–9 секунд. Шлюз не дожидался ответа, считал доставку неуспешной и присылал вебхук повторно — с тем же event_id, но это неважно, потому что обработчик никак не проверял, обрабатывалось ли уже это событие. Каждая из трёх попыток доходила до create_shipment и создавала новую отгрузку на складе, потому что и склад со своей стороны тоже не делал дедупликацию по внешнему идентификатору.
Ирония в том, что все три попытки в итоге отработали «успешно» и вернули 200 — просто слишком поздно с точки зрения шлюза, который к тому моменту уже отправил следующий повтор. Гонка была не между воркерами приложения, а между медленным ответом и таймаутом ретрая на стороне внешнего сервиса.
Что изменили: быстрый ACK и асинхронная обработка
Первое и самое важное изменение — обработчик вебхука перестал делать что-либо медленное синхронно. Его задача теперь — проверить подпись, сохранить событие и немедленно ответить 200:
@app.post("/webhooks/payment")
def handle_payment_webhook(request):
event = verify_signature(request)
saved = save_event_if_new(event) # см. следующий раздел — идемпотентность
if saved:
enqueue_task("process_payment_event", event.id)
return {"status": "ok"}, 200
Реальная работа — обновление статуса заказа и вызов склада — переехала в фонового воркера, который читает задачи из очереди и не связан таймаутом внешнего шлюза:
def process_payment_event(event_id):
event = load_event(event_id)
order = get_order(event.order_id)
if order.status == "paid":
return # уже обработан, выходим
order.mark_paid()
warehouse_client.create_shipment(order.id)
Для очереди задач взяли то, что уже было в инфраструктуре (Redis + RQ), не стали городить отдельный брокер ради одного потока событий — если у вас в проекте такой инфраструктуры ещё нет, устройство очередей и выбор между вариантами разобраны в статье как устроена очередь сообщений. Отдельный нюанс: воркер сам может зависнуть и утащить за собой всю очередь — это отдельный класс инцидентов, который стоит держать в голове при проектировании ретраев воркера.
Смысл переноса в том, что теперь скорость ответа шлюзу не зависит от скорости склада. Даже если склад отвечает 15 секунд, обработчик вебхука вернёт 200 за десятки миллисекунд, и шлюз просто не увидит повода для повтора.
Что изменили: идемпотентность на уровне БД и API
Быстрый ACK снижает вероятность повторов, но не устраняет её полностью — шлюз может ретраить и по другим причинам: сетевой сбой, перезапуск инстанса приложения между чтением запроса и ответом, временная недоступность. Поэтому вторым слоем защиты стала идемпотентность на уровне хранилища.
Завели таблицу обработанных событий с уникальным ограничением по event_id:
CREATE TABLE processed_webhook_events (
event_id TEXT PRIMARY KEY,
order_id BIGINT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
И сохранение события делает вставку с обработкой конфликта, а не отдельную проверку «а есть ли уже такое» перед вставкой — так надёжнее при параллельных вызовах:
INSERT INTO processed_webhook_events (event_id, order_id)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
Если INSERT вернул строку — событие новое, ставим задачу в очередь. Если не вернул ничего — событие уже видели, тихо отвечаем 200 и ничего не делаем. Уникальное ограничение в БД гарантирует, что даже два параллельных запроса с одинаковым event_id (например, при гонке между старым и новым инстансом приложения во время деплоя) не пройдут оба — база пропустит только первую вставку.
Для проектов с высоким потоком событий, где лишний поход в Postgres на каждый вебхук нежелателен, тот же паттерн делают через Redis командой SET key value NX EX 86400 — атомарная установка ключа, если его ещё нет, с истечением через сутки. Если частые проблемы с самим Redis уже случались в проекте, стоит заглянуть в разбор типичных ошибок Redis на сервере — идемпотентность на нём имеет смысл строить только когда сам Redis настроен с понятной политикой персистентности, иначе ключи дедупликации переживут сам инцидент хуже, чем хотелось бы.
Отдельно добавили идемпотентность и на вызов склада: create_shipment теперь принимает idempotency_key = order.id, и склад со своей стороны тоже не создаёт вторую отгрузку на тот же ключ, даже если приложение всё-таки вызовет его дважды. Это защита на случай, если первый слой (дедупликация в БД) не сработает по какой-то ещё не предусмотренной причине — параноидальный, но дешёвый второй барьер.
Мониторинг и защита от повторения
До инцидента система вообще не знала, что заказ отгрузили дважды — узнали от клиентов. После разбора добавили три вещи.
Счётчик повторных событий — при каждом ON CONFLICT DO NOTHING инкрементируется метрика webhook_duplicate_events_total{source="payment_gateway"}. Сам факт повтора не проблема (шлюзы ретраят по своей природе), но резкий рост счётчика — сигнал, что где-то на пути снова появилась задержка, из-за которой шлюз считает доставку неуспешной.
Алерт на несоответствие количества отгрузок и количества оплаченных заказов — простой батч-джоб раз в 15 минут сверяет count(shipments) > count(distinct paid orders) и шлёт алерт при расхождении, вместо того чтобы полагаться только на жалобы клиентов.
Регресс-тест в стейджинге, который явно шлёт один и тот же вебхук три раза подряд с одинаковым event_id и проверяет, что в системе создалась ровно одна отгрузка. Это дёшево завести один раз и держать в CI — ловит регресс, если кто-то в будущем добавит новый обработчик событий и забудет про идемпотентность.
Отдельно пересобрали инфраструктуру под очередь и воркеров: раньше воркер крутился на том же инстансе, что и веб-приложение, и делил с ним ресурсы CPU в пиковые часы — то есть в момент нагрузки на склад воркер тоже замедлялся, усиливая эффект. Вынесли воркер и Redis для очереди на отдельный VPS, чтобы пиковая нагрузка на веб-слой не била по обработке фоновых задач и наоборот. Для такой роли не нужен мощный сервер — важна стабильная сеть до платёжного шлюза и склада и предсказуемая латентность, поэтому под воркер с Redis обычно достаточно небольшого выделенного инстанса, поднятого отдельно от основного продакшена.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему нельзя было просто увеличить таймаут ответа для шлюза?
У большинства платёжных шлюзов таймаут на стороне провайдера не настраивается клиентом — это фиксированное значение в их инфраструктуре. Даже если бы получилось его увеличить, проблема осталась бы: обработчик всё равно был бы уязвим к любой временной просадке склада, сети или базы данных. Быстрый ACK и идемпотентность решают проблему на уровне архитектуры, а не подгонкой одного параметра.
Достаточно ли только дедупликации по event_id, без переноса обработки в очередь?
Само по себе снижает риск, но не убирает его: если обработчик всё ещё отвечает медленно, шлюз продолжит ретраить, и каждая попытка будет тратить время на проверку в БД плюс сетевой вызов к складу, увеличивая нагрузку и шанс упереться в собственный таймаут приложения или прокси. Быстрый ACK и идемпотентность дополняют друг друга, а не заменяют.
А что если сам шлюз пришлёт два разных event_id на одну и ту же оплату по ошибке?
Такое возможно при сбоях на стороне провайдера, и от этого дедупликация по event_id не защитит. Дополнительный барьер — уникальное ограничение на order_id в таблице отгрузок или проверка статуса заказа перед вызовом склада (как в примере с if order.status == "paid": return), это ловит дубликаты даже с разными идентификаторами события.
Нужно ли аналогичным образом защищать все вебхуки в системе, а не только платёжный?
Да, если обработчик делает что-то небезопасное для повтора — списывает деньги, создаёт отгрузку, отправляет уведомление. Разумный подход — завести общую таблицу processed_webhook_events с полем source, а не отдельную для каждого интегрируемого сервиса, и подключать проверку идемпотентности как обязательный шаг в любом новом обработчике вебхука.
Как быстро можно было заметить проблему без ожидания жалоб от клиентов?
Метрика расхождения между количеством оплаченных заказов и количеством созданных отгрузок — самый дешёвый и быстрый сигнал, её можно посчитать простым запросом к БД без сложной инфраструктуры мониторинга. В этом инциденте такой проверки не было вовсе, и это было едва ли не большей ошибкой, чем сама неидемпотентность обработчика.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →