Перейти к содержанию
buzinov.tech, главная страница

4 мин чтения

Повторный запрос не должен создавать второй заказ

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

Содержание
  1. Откуда берутся повторные запросы
  2. Ключ идемпотентности: один на бизнес-операцию
  3. Сначала запись в журнал, потом запрос в 1С
  4. Если 1С или CRM не принимают ключ идемпотентности
  5. Как проверить, что дублей не будет
  6. Чек-лист перед запуском
  7. Частые вопросы

Сайт отправляет новый заказ в 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 ничего не знают о ключах идемпотентности. Тогда интеграция сначала ищет, а потом создаёт:

  1. Внешний идентификатор хранится в целевой системе, например в реквизите «номер заказа маркетплейса».
  2. Перед созданием интеграция ищет по этому реквизиту и держит блокировку строки журнала (SELECT ... FOR UPDATE).
  3. Запись создаётся, только если ничего не нашлось, и сразу после этого операция отмечается выполненной.

Без блокировки не обойтись. Иначе два обработчика ищут одновременно, оба ничего не находят и оба создают заказ.

Как проверить, что дублей не будет

Тест удачного сценария здесь ничего не доказывает: в удачном сценарии дублей не бывает и так.

Полезные тесты ломают обмен нарочно. Обрывают соединение сразу после вызова 1С, перезапускают обработчик посреди операции, отправляют один и тот же вебхук дважды одновременно. После каждого такого теста заказ должен быть ровно один.

Чек-лист перед запуском

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

Как этот приём работает в обмене заказами между маркетплейсом и учётной системой, я разобрал в статье «Как связать Kaspi.kz с 1С». Если такой обмен нужен вам, пакеты и цены на странице «Интеграция 1С, CRM и маркетплейсов».

Частые вопросы

Что такое ключ идемпотентности?

Идентификатор бизнес-операции, например kaspi:order:512344101:create. Одно и то же событие всегда даёт один и тот же ключ, поэтому повторный запрос с ним не создаёт вторую запись.

Почему нельзя взять случайный UUID для каждого запроса?

У повторного запроса будет новый UUID, и система примет его за новую операцию. Ключ собирается из бизнес-данных: номера заказа, счёта или склада.

Что делать, если 1С не принимает ключ идемпотентности?

Хранить внешний номер в реквизите документа 1С и перед созданием искать по нему, удерживая блокировку строки журнала. Запись создаётся, только если ничего не нашлось.

Другие статьи

Данные до сих пор переносят вручную?

Опишите задачу в паре абзацев. В течение суток отвечу, как её решить, где главный риск и сколько это будет стоить.