4 мин чтения
Повторный запрос не должен создавать второй заказ
Ключ идемпотентности, журнал операций и ограничение уникальности в базе: как интеграция переживает обрыв связи и не создаёт дубли.
Содержание
Сайт отправляет новый заказ в 1С. 1С его создаёт, но соединение обрывается раньше, чем приходит ответ. Сайт получает таймаут и отправляет запрос ещё раз.
Теперь в 1С два заказа, один и тот же товар зарезервирован дважды, а через неделю бухгалтерия пытается понять, что случилось.
Ничего необычного в этой истории нет: в интеграциях так бывает постоянно. Защищаются от неё тремя вещами: у каждой бизнес-операции есть свой ключ, операция записывается в журнал в собственной базе до запроса в 1С, а второй раз ту же запись не пускает ограничение уникальности в PostgreSQL.
Откуда берутся повторные запросы
HTTP-клиенты повторяют запрос после таймаута. Обработчик очереди получает сообщение заново после перезапуска.
Вебхуки маркетплейсов и CRM доставляются по принципу «как минимум один раз», и иногда одно событие приходит дважды. Оператор жмёт кнопку ещё раз, если экран не отреагировал.
Избавиться от повторов нельзя, да и не нужно: без них короткий сбой сети превращается в потерянный заказ. Нужно, чтобы повторный запрос ничего не ломал.
Ключ идемпотентности: один на бизнес-операцию
Ключ обозначает операцию, а не попытку её выполнить. Случайный UUID на каждый HTTP-запрос не спасает: у повторного запроса будет новый UUID, и система примет его за новую операцию.
Поэтому ключ собирается из бизнес-данных:
kaspi:order:512344101:createдля создания заказа с маркетплейса;payment:INV-2026-0418:postдля проведения оплаты;stock:WH1:SKU-8812:2026-09-16T10:00для снимка остатков.
Одно и то же событие всегда даёт один и тот же ключ, сколько бы раз оно ни пришло.
Сначала запись в журнал, потом запрос в 1С
Журнал операций лежит в базе самой интеграции, и запись в нём появляется раньше, чем 1С что-то получит:
CREATE TABLE operations (
key text PRIMARY KEY,
status text NOT NULL CHECK (status IN ('pending', 'done', 'failed')),
result jsonb,
attempts integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
Главную работу здесь делает первичный ключ. Если два обработчика одновременно возьмутся за одну операцию, вставить строку сможет только один. Дубль отсекает база, а не условие if в коде, которое может проиграть гонку.
С журналом обработчик читается почти как бизнес-правило:
func (s *Sync) CreateOrder(ctx context.Context, o Order) (ERPRef, error) {
key := "kaspi:order:" + o.MarketplaceID + ":create"
op, err := s.journal.Begin(ctx, key) // вставить строку или заблокировать существующую
if err != nil {
return ERPRef{}, err
}
if op.Status == StatusDone {
return op.Result, nil // уже создан: отвечаем так же, как в первый раз
}
ref, err := s.erp.FindOrCreateOrder(ctx, o, key)
if err != nil {
return ERPRef{}, s.journal.Fail(ctx, key, err)
}
return ref, s.journal.Done(ctx, key, ref)
}
Повторный запрос по завершённой операции получает сохранённый результат. Вызывающая сторона не отличит первую попытку от пятой, ради этого всё и затевается.
Журнал я закладываю в любой обмен заказами или платежами, даже в самый маленький. Сама таблица занимает семь строк, а дубль платежа потом приходится разбирать вручную вместе с бухгалтерией.
Если 1С или CRM не принимают ключ идемпотентности
Большинство ERP и CRM ничего не знают о ключах идемпотентности. Тогда интеграция сначала ищет, а потом создаёт:
- Внешний идентификатор хранится в целевой системе, например в реквизите «номер заказа маркетплейса».
- Перед созданием интеграция ищет по этому реквизиту и держит блокировку строки журнала (
SELECT ... FOR UPDATE). - Запись создаётся, только если ничего не нашлось, и сразу после этого операция отмечается выполненной.
Без блокировки не обойтись. Иначе два обработчика ищут одновременно, оба ничего не находят и оба создают заказ.
Как проверить, что дублей не будет
Тест удачного сценария здесь ничего не доказывает: в удачном сценарии дублей не бывает и так.
Полезные тесты ломают обмен нарочно. Обрывают соединение сразу после вызова 1С, перезапускают обработчик посреди операции, отправляют один и тот же вебхук дважды одновременно. После каждого такого теста заказ должен быть ровно один.
Чек-лист перед запуском
- Ключ строится из бизнес-данных, а не из номера попытки.
- Операция записывается в журнал до обращения к внешней системе.
- Дубли отсекает ограничение в базе, а не проверка в коде.
- Если внешняя система не принимает ключ, сначала поиск под блокировкой, потом создание.
- Повторный запрос получает тот же ответ, что и первый.
Как этот приём работает в обмене заказами между маркетплейсом и учётной системой, я разобрал в статье «Как связать Kaspi.kz с 1С». Если такой обмен нужен вам, пакеты и цены на странице «Интеграция 1С, CRM и маркетплейсов».
Частые вопросы
Что такое ключ идемпотентности?
Идентификатор бизнес-операции, например kaspi:order:512344101:create. Одно и то же событие всегда даёт один и тот же ключ, поэтому повторный запрос с ним не создаёт вторую запись.
Почему нельзя взять случайный UUID для каждого запроса?
У повторного запроса будет новый UUID, и система примет его за новую операцию. Ключ собирается из бизнес-данных: номера заказа, счёта или склада.
Что делать, если 1С не принимает ключ идемпотентности?
Хранить внешний номер в реквизите документа 1С и перед созданием искать по нему, удерживая блокировку строки журнала. Запись создаётся, только если ничего не нашлось.