Обмен с 1С через очередь: как доставить заказ ровно один раз

20 мин22.08.2026

Заказ, оформленный на сайте, живёт двойной жизнью. Пока он не попал в 1С, менеджер его не видит, склад не резервирует товар, а покупатель ждёт звонка, которого не будет. Штатный обмен «1С — 1С-Битрикс» устроен так, что при идеальной сети он работает незаметно. Проблемы начинаются, когда сеть неидеальна: рвётся соединение, отваливается веб-сервер по таймауту, 1С перезапускает сеанс. В этот момент заказ либо не доезжает, либо приезжает дважды.

Мы (DEV-BX) работаем с 1С-Битрикс с 2008 года и с 2021 года пишем собственные модули. Эта статья — разбор механики, а не пересказ документации. Официальный протокол обмена есть по ссылке, и повторять его целиком смысла нет. Ценно другое: что именно ломается на практике и как построить обмен, при котором заказ доставляется ровно один раз.

Как ломается штатный обмен

Как устроен штатный обмен и почему это важно понимать

Инициатором обмена всегда выступает 1С — сайт сам в 1С ничего не отправляет, он только отвечает на запросы. Обмен заказами (type=sale) идёт по последовательности из четырёх шагов, описанной в открытом протоколе обмена с сайтом и в официальном «Алгоритме выгрузки данных на сайт» на dev.1c-bitrix.ru, где адрес запроса формируется как <адрес_скрипта>?type=<тип>&mode=checkauth:

  1. mode=checkauth — авторизация. Сайт возвращает три строки: слово success, имя cookie и значение cookie. Все следующие запросы несут это cookie.
  2. mode=init — 1С запрашивает параметры: поддержку zip и file_limit (максимальный размер файла в байтах за один запрос).
  3. mode=query — сайт отдаёт XML с заказами в формате CommerceML 2.
  4. mode=success — 1С сообщает, что заказы успешно получены и записаны; именно на этом шаге сайт фиксирует «время окончания последней выгрузки».

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

Где именно теряется заказ

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

В штатном экспорте bitrix:sale.export.1c у отдельного заказа нет персонального флага «выгружен в 1С». Вместо этого весь отбор «что ещё не отдано» держится на одной общей опции-таймстампе на весь магазин — last_export_time_committed_/bitrix/admin/1c_excha (хранится в таблице b_option, модуль sale). Заказ попадает в выгрузку, если выполняются условия: EXTERNAL_ORDER = "N" (заказ создан на сайте, а не пришёл из 1С), UPDATED_1C = "N" и DATE_UPDATE заказа новее сохранённого таймстампа.

Логика фильтра в методе CSaleExport::ExportOrders2Xml сводится к сравнению даты изменения заказа с этим таймстампом:

php
<?php
// Упрощённая логика отбора заказов на выгрузку (штатный механизм sale)
$lastExport = COption::GetOptionString(
    "sale",
    "last_export_time_committed_/bitrix/admin/1c_excha",
    ""
);

$arFilter = [];
if (strlen($lastExport) > 0) {
    // берём только заказы, изменённые ПОСЛЕ последней успешной выгрузки
    $arFilter[">=DATE_UPDATE"] = ConvertTimeStamp($lastExport, "FULL");
}

// плюс отбрасываем заказы, пришедшие из 1С, и уже подтверждённые
$arFilter["EXTERNAL_ORDER"] = "N";
$arFilter["UPDATED_1C"] = "N";

CSaleExport::ExportOrders2Xml($arFilter, /* ... */);

Самое важное: таймстамп обновляется не на шаге mode=query, когда сайт уже отдал XML, а только на шаге mode=success. Причём в него записывается время запроса списка заказов, а не текущий момент, — чтобы заказы, добавленные между query и success, не потерялись, а попали в следующий сеанс.

Теперь смотрим на точку неопределённости. Между «сайт отдал XML заказов» (query) и «1С подтвердила приём» (success) существует окно, в котором истина не определена:

  • 1С получила файл, разобрала его, создала документы заказов — но ответ success не дошёл до сайта из-за обрыва;
  • либо 1С не успела дочитать файл — упала, потеряла соединение, перезапустилась;
  • либо сайт отдал файл, но сам процесс на стороне 1С завис.

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

Это и есть классическая точка неопределённости: одна сторона считает заказ переданным, другая — нет, и по одному лишь состоянию соединения различить, что произошло, невозможно. Ту же природу имеют обрывы на длинной выгрузке каталога: пошаговый импорт (mode=import) отвечает progress и просит повторить запрос, и если веб-сервер обрывает соединение по таймауту в середине шага, 1С не понимает, доехал ли шаг.

Почему обмен обрывается на практике

Причина неопределённости — почти всегда время. Пошаговый импорт в Битриксе устроен так, что тяжёлые операции (добавление и обновление товаров через CIBlockCMLImport::ImportElements) выполняются порциями по несколько секунд, и после каждой порции сайт отвечает progress, ожидая повторного запроса. Если один шаг не укладывается во время выполнения PHP или в таймаут веб-сервера, соединение обрывается.

Наглядный пример из практики — лог обмена с таймаутом в час:

21.09.2018 0:05:27--Процесс выполнения обмена: Метаданные импортированы успешно.
21.09.2018 1:05:27--Не удалось получить данные с сервера. Проверьте
правильность адреса сервера, порт, имя пользователя и пароль, а также
настройки подключения к Интернет.

Здесь запрос провисел ровно час и упал по таймауту nginx (504). На больших каталогах и заказах с длинной номенклатурой это типично: обработчик упирается в max_execution_time PHP или в лимит памяти. Значения таймаутов и file_limit берутся из конфигурации конкретного сервера, поэтому в статье они обозначены как настройки, а не как фиксированные числа: max_execution_time PHP, file_limit из ответа mode=init, таймаут веб-сервера.

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

Почему повторная отправка «в лоб» — плохо

Первое, что приходит в голову после обрыва: «отправить ещё раз». В контексте обмена с 1С это опасно сразу с двух сторон.

Со стороны заказов. Если 1С уже записала документы, но success не дошёл, повторная выгрузка отдаст те же заказы снова. Хорошо написанный модуль обмена на стороне 1С найдёт ранее загруженный документ по идентификатору и обновит его. Плохо написанный (или при рассинхроне идентификаторов) — создаст дубликаты. На официальном форуме 1С-Битрикс есть показательный случай: после проблем с сопоставлением кодов «с заказов полезло очень много дублей товара», и базу 1С пришлось восстанавливать из копии.

Со стороны номенклатуры. При повторной выгрузке каталога дубли появляются, когда сопоставление идёт по названию, а не по внешнему коду: при малейшем изменении названия в 1С создаётся новая карточка вместо обновления старой. В Битриксе внешний код 1С пишется в поле XML_ID, и всё сопоставление идёт по нему. Если после сбоя создаётся дублирующий инфоблок, выгрузка начинает идти в него, и товары двоятся.

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

Из чего собирается надёжная доставка

Защита от повторной обработки: идемпотентность

Идемпотентность — это свойство операции давать один и тот же результат при любом числе повторов. Повторно применённый запрос не меняет состояние системы больше одного раза. Для обмена это означает: сколько бы раз ни пришёл один и тот же заказ, документ в 1С создаётся ровно один.

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

Какие поля годятся на роль ключа:

  • GUID/UUID сообщения — сгенерированный при создании сообщения уникальный идентификатор. Лучший вариант: он ничего не значит для бизнеса и потому не меняется.
  • Внешний идентификатор сущности — в терминах Битрикса поле XML_ID заказа или его глобальный идентификатор <Ид> в CommerceML. Стабильный ключ, привязанный к конкретному заказу.
  • Хеш содержимого — SHA-256 от канонического тела сообщения. Годится, когда своего идентификатора нет, но чувствителен к любому, даже незначащему, изменению полей.

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

  • Перенумерация. Счётчик заказов сбрасывают в начале года или периода — и номер 1 появляется снова.
  • Несколько сайтов на одной 1С. Два магазина независимо выдают заказ №100 — в 1С это два разных заказа с одинаковым номером.
  • Префиксы и форматы. Номер на сайте (ACCOUNT_NUMBER) и внутренний ID заказа — разные поля; при выгрузке в CommerceML в <Номер> попадает ACCOUNT_NUMBER, а в <Ид> — внутренний ID. Смешение этих полей ломает сопоставление.
  • Ручные заказы и повторное использование. Менеджер заводит заказ вручную, номер переиспользуется, формат меняется по бизнес-правилам.

Идентификатор <Ид> из документа CommerceML устойчивее: именно по нему (плюс дате) 1С по протоколу записывает и находит заказ. Поэтому ключ строим на неизменном идентификаторе, а не на человекочитаемом номере.

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

sql
-- Таблица обработанных сообщений (inbox) на принимающей стороне
CREATE TABLE inbox_processed (
    message_key   VARCHAR(64)  NOT NULL,
    processed_at  DATETIME     NOT NULL,
    result_ref    VARCHAR(255)     NULL,   -- ссылка на созданный документ
    PRIMARY KEY (message_key)              -- уникальность = гарантия
) ENGINE=InnoDB;

Атомарная вставка ключа отсекает дубликат ещё до бизнес-логики:

php
<?php
use Bitrix\Main\Application;

function processOrderMessage(string $messageKey, array $orderData): bool
{
    $connection = Application::getConnection();
    $connection->startTransaction();
    try {
        // Пытаемся застолбить ключ. Если он уже есть — вставка не пройдёт.
        $sql = "INSERT INTO inbox_processed (message_key, processed_at)
                VALUES ('" . $connection->getSqlHelper()->forSql($messageKey) . "', NOW())";
        $connection->queryExecute($sql);
    } catch (\Bitrix\Main\DB\SqlQueryException $e) {
        // Дубликат ключа — сообщение уже обработано, тихо выходим.
        $connection->rollbackTransaction();
        return false;
    }

    try {
        createOrUpdateOrderInErp($orderData);   // бизнес-логика
        $connection->commitTransaction();
        return true;
    } catch (\Throwable $e) {
        $connection->rollbackTransaction();      // ключ откатится вместе с логикой
        throw $e;
    }
}

Вариант без исключений — INSERT ... ON DUPLICATE KEY UPDATE или INSERT IGNORE с последующей проверкой числа затронутых строк. Смысл тот же: решение о том, новое это сообщение или повтор, принимает база через уникальный индекс, а не код через SELECT перед INSERT (последнее оставляет гонку между проверкой и вставкой).

На стороне 1С аналог такой таблицы — поиск ранее загруженного документа по <Ид> перед созданием нового. Именно этот поиск и защищает от дублей при повторной выгрузке; когда он ломается (несовпадение кодов, смена идентификаторов), появляются задвоения.

Повторные попытки: как повторять правильно

Идемпотентность делает повтор безопасным. Но повторять всё равно нужно с умом.

Почему нельзя повторять сразу. Если принимающая сторона легла под нагрузкой, немедленный повтор добавляет ей нагрузки — и роняет окончательно. Когда так делают все клиенты разом, возникает «шторм повторов» (thundering herd): сервис на секунду поднимается, его тут же добивает волна синхронных ретраев, и цикл повторяется.

Нарастающая пауза (exponential backoff). Интервал между попытками растёт экспоненциально: 1 с, 2 с, 4 с, 8 с… Формула: задержка = base * 2^номер_попытки. Это даёт принимающей стороне время восстановиться.

Разброс (jitter). Одного роста паузы мало: если тысяча клиентов упала одновременно и все ждут ровно 1, 2, 4 секунды, они всё равно бьют залпами. Поэтому к задержке добавляют случайность. Стратегия «full jitter» — задержка равна случайному числу от нуля до расчётного значения:

php
<?php
/**
 * Задержка перед следующей попыткой: экспонента + full jitter.
 * $attempt — номер попытки (0, 1, 2, ...).
 */
function retryDelaySeconds(int $attempt, float $base = 1.0, float $cap = 60.0): float
{
    $exp = min($cap, $base * (2 ** $attempt)); // экспонента с потолком
    return mt_rand(0, (int)($exp * 1000)) / 1000; // случайно от 0 до exp
}

Потолок (cap) обязателен: без него задержка растёт бесконечно и не улучшает шансы. Разумное число попыток тоже конечно — за пределами разумного повторять бессмысленно, и число попыток, и потолок паузы зависят от того, насколько быстро восстанавливается конкретная связка.

Что повторять, а что нет. Повторять имеет смысл только временные ошибки: сетевой таймаут, 500, 502, 503, 504. Ошибки в самих данных (некорректный XML, невалидный формат) повтором не лечатся — их надо разбирать руками. Отправлять их на повтор — только жечь ресурсы.

Что делать с сообщением, которое не прошло совсем. Если исчерпаны все попытки, сообщение нельзя ни терять, ни держать в основной очереди вечно (оно будет блокировать остальные). Его перекладывают в отдельную очередь непрошедших сообщений — dead letter. Там оно ждёт ручного разбора.

Гарантии доставки и почему «ровно один раз» собирается из двух частей

В теории очередей есть три уровня гарантии доставки:

Гарантия Что означает Риск
Не более одного раза (at-most-once) Сообщение либо доставляется, либо теряется Потеря заказов
Не менее одного раза (at-least-once) Сообщение доставляется, но возможны повторы Дубли заказов
Ровно один раз (exactly-once) Каждое сообщение обрабатывается однократно

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

  • RabbitMQ штатно поддерживает «не более одного раза» и «не менее одного раза».
  • Kafka обеспечивает «ровно один раз» только в ограниченном сценарии: по документации Confluent — при чтении из одного топика и записи в другой, за счёт транзакционного продюсера, добавленного в версии 0.11.0.0. На внешние вызовы к базам и сторонним интерфейсам этот механизм автоматически не распространяется.

Правильная формула такая:

Ровно один раз обработки = доставка «не менее одного раза» + защита от повторной обработки (идемпотентность).

То есть выбираем «не менее одного раза» (лучше дубль, чем потеря) и гасим дубли на приёме идемпотентным потребителем. Это и есть тот механизм, ради которого написана статья: не «надёжный обмен» вообще, а конкретная сборка из двух проверяемых свойств.

Чем доставлять заказ

Очереди сообщений применительно к обмену с 1С

Штатный обмен синхронный: 1С дёргает 1c_exchange.php и ждёт ответа в том же соединении. Именно синхронность и делает обрыв губительным — терять связь в середине операции нельзя. Очередь разрывает эту жёсткую связь: сайт кладёт заказ в очередь, а доставкой в 1С занимается отдельный процесс, который умеет повторять и не мешает оформлению новых заказов.

Разберём, какие инструменты подходят.

RabbitMQ. Классический брокер под эту задачу. Даёт два ключевых механизма надёжности:

  • Publisher confirms — подтверждение от брокера, что сообщение принято и сохранено. Для устойчивых сообщений в устойчивой очереди подтверждение приходит после записи на диск. Без него отправитель не знает, дошло ли сообщение.
  • Consumer acknowledgements — потребитель подтверждает обработку вручную, после того как заказ реально доставлен в 1С. Если потребитель упал до подтверждения, сообщение вернётся в очередь и будет доставлено снова.

Оба механизма дают именно «не менее одного раза». Документация RabbitMQ прямо предупреждает об этом:

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

RabbitMQ, «Reliability Guide»

Описание подтверждений публикации добавляет второе: обрыв соединения с брокером при неподтверждённых сообщениях не означает, что они потеряны, — поэтому повторная публикация может привести к дублям.

Отсюда прямо следует наша формула: доставку берём «не менее одного раза», а дубли гасим идемпотентностью.

Для непрошедших сообщений в RabbitMQ есть dead letter exchange. По документации RabbitMQ, сообщение попадает туда, когда потребитель отклонил его через basic.reject или basic.nack с requeue=false, когда истёк TTL сообщения, когда число сообщений в очереди превысило максимальную длину, и когда для очереди типа quorum сообщение возвращено больше раз, чем разрешает delivery-limit (в заголовке — x-delivery-count).

Пример объявления очереди типа quorum с лимитом попыток и dead letter:

bash
rabbitmqadmin declare queue name=orders_to_1c queue_type=quorum durable=true \
  arguments='{"x-dead-letter-exchange":"dlx.orders","x-delivery-limit":5}'

rabbitmqadmin declare exchange name=dlx.orders type=direct durable=true
rabbitmqadmin declare queue name=orders_to_1c.dead queue_type=quorum durable=true
rabbitmqadmin declare binding source=dlx.orders destination=orders_to_1c.dead

Здесь заказ повторяется не более пяти раз, после чего уходит в orders_to_1c.dead на ручной разбор. Очереди типа quorum реплицируются и переживают падение узла — для заказов это желательно.

Kafka. Поддерживает идемпотентного продюсера и транзакции, что даёт «ровно один раз» в упомянутом выше ограниченном сценарии внутри Kafka. Но для типовой задачи «сайт → 1С» это тяжёлая инфраструктура; её оправдывает высокий поток и потребность в перечитывании истории, а не обмен заказами среднего магазина.

Redis Streams. Лёгкий вариант: XADD для записи, consumer group и XREADGROUP для чтения, XACK для подтверждения. Непрошедшие сообщения остаются в Pending Entries List (PEL); по превышению times_delivered их вручную перекладывают в отдельный поток для непрошедших сообщений. Важная тонкость: неподтверждённые сообщения копятся в PEL и едят память, а XDEL после XACK фрагментирует структуру — за этим нужно следить.

Очередь уровня приложения. Если тянуть RabbitMQ ради одного обмена не хочется, очередь можно реализовать таблицей в базе. Это и есть паттерн «исходящий ящик».

Исходящий и входящий ящик (outbox / inbox)

Здесь важно понять, почему нельзя просто «в момент оформления заказа отправить его в очередь». Это два разных хранилища — база сайта и брокер, — и запись в них не атомарна. Заказ сохранился в базе, а публикация в очередь упала (или наоборот) — и системы разошлись. Это называется проблемой двойной записи (dual write).

Соблазнительно решить это распределённой транзакцией (двухфазная фиксация, 2PC) между сайтом и 1С. Так делать не стоит: 2PC требует, чтобы обе стороны поддерживали координатора транзакций и держали блокировки на время всего протокола. 1С в связке через 1c_exchange.php этого не умеет и по своей природе синхронна, а блокировки на распределённую транзакцию по HTTP — прямой путь к зависаниям. Практика распределённых систем давно отказалась от 2PC в пользу локальных транзакций плюс идемпотентности.

Исходящий ящик (transactional outbox). Заказ и запись «нужно отправить в 1С» пишутся в одной локальной транзакции базы сайта. Отдельный процесс-релей читает ящик и отправляет сообщения в очередь/1С, помечая отправленные:

sql
CREATE TABLE outbox_orders (
    id            BIGINT AUTO_INCREMENT PRIMARY KEY,
    order_id      INT          NOT NULL,
    message_key   VARCHAR(64)  NOT NULL,   -- ключ идемпотентности (GUID)
    payload       LONGTEXT     NOT NULL,   -- XML CommerceML заказа
    status        ENUM('NEW','SENT','FAILED') NOT NULL DEFAULT 'NEW',
    attempts      INT          NOT NULL DEFAULT 0,
    next_retry_at DATETIME         NULL,
    created_at    DATETIME     NOT NULL,
    UNIQUE KEY uk_message_key (message_key)
) ENGINE=InnoDB;

Запись заказа и его сообщения в одной транзакции гарантирует, что событие не потеряется и не появится «фантомом» без заказа:

php
<?php
use Bitrix\Main\Application;

// Внутри обработчика сохранения заказа (например, OnSaleOrderSaved)
$connection = Application::getConnection();
$connection->startTransaction();
try {
    // ... сохранение/расчёт заказа средствами модуля sale ...

    $messageKey = \Bitrix\Main\Security\Random::getString(32); // GUID сообщения
    $sql = sprintf(
        "INSERT INTO outbox_orders (order_id, message_key, payload, status, created_at)
         VALUES (%d, '%s', '%s', 'NEW', NOW())",
        $orderId,
        $connection->getSqlHelper()->forSql($messageKey),
        $connection->getSqlHelper()->forSql($orderXml)
    );
    $connection->queryExecute($sql);

    $connection->commitTransaction();
} catch (\Throwable $e) {
    $connection->rollbackTransaction();
    throw $e;
}

Здесь важно помнить особенность Битрикса: событие OnSaleOrderSaved срабатывает несколько раз при одном оформлении, потому что заказ пересохраняется при смене статусов. Уникальный индекс на message_key и привязка ключа к конкретной версии заказа спасают от лишних записей в ящик.

Релей отправляет и фиксирует результат отдельной транзакцией; при сбое сообщение остаётся в статусе NEW/FAILED и будет повторено по расписанию next_retry_at:

php
<?php
$rows = getOutboxBatch(['status' => ['NEW', 'FAILED'], 'due' => true], 50);

foreach ($rows as $row) {
    try {
        publishToQueue($row['message_key'], $row['payload']); // + publisher confirm
        markOutbox($row['id'], 'SENT');
    } catch (\Throwable $e) {
        $attempt = $row['attempts'] + 1;
        $delay   = retryDelaySeconds($attempt);
        markOutboxRetry($row['id'], $attempt, $delay); // status=FAILED, next_retry_at
        logExchange($row['message_key'], $attempt, 'FAILED', $e->getMessage());
    }
}

Входящий ящик (inbox). Зеркальная защита на приёме — та самая таблица inbox_processed с уникальным ключом из раздела про идемпотентность. Outbox гарантирует, что сообщение уедет; inbox гарантирует, что оно обработается один раз. Вместе они дают сквозную гарантию «ровно один раз обработки» без распределённых транзакций.

Схема обмена и таблица состояний

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

Сайт (outbox)         Очередь/брокер            Потребитель → 1С (inbox)
     │                      │                             │
 [1] сохранить заказ +      │                             │
     запись в outbox        │                             │
     (одна транзакция)      │                             │
     │                      │                             │
 [2] релей публикует ──────>│                             │
     │<─ publisher confirm ─┤   (сообщение на диске)      │
 [3] пометить SENT          │                             │
     │                      ├── доставка ────────────────>│
     │                      │                         [4] проверить ключ
     │                      │                             в inbox
     │                      │                         [5] создать заказ в 1С
     │                      │                             + записать ключ
     │                      │                             (одна транзакция)
     │                      │<────── ack ─────────────[6] подтвердить обработку
     │                      │  (удалить из очереди)       │

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

Шаг Что произошло при обрыве Состояние сторон Как система выходит
1. Запись заказа + outbox Транзакция не зафиксирована Заказа нет ни в базе, ни в ящике Ничего не потеряно: заказ либо целиком есть, либо целиком нет (атомарность)
2. Публикация в очередь Сообщение отправлено, но confirm не дошёл Сайт не знает, принято ли Сообщение осталось NEW → релей повторит; дубликат в очереди отсечёт ключ на приёме
3. Пометка SENT Confirm получен, но статус не записан Брокер принял, сайт считает NEW Повторная публикация → брокер/потребитель получит дубль → отсечёт по ключу идемпотентности
4–5. Обработка в 1С Заказ создан, но ack не отправлен 1С записала, брокер считает необработанным Сообщение вернётся в очередь → потребитель увидит ключ в inbox → пропустит без повторного создания
6. Подтверждение (ack) ack не дошёл до брокера Обработано, но брокер не знает Повторная доставка → ключ уже в inbox → идемпотентный пропуск, затем повторный ack
Все попытки исчерпаны Сообщение не проходит (битые данные) Сайт отправил, 1С не принимает Уход в dead letter → алерт → ручной разбор

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

Когда всё-таки сломалось

Что смотреть, когда всё-таки сломалось

Даже идеальная схема требует наблюдаемости — иначе о проблеме узнаёшь от покупателя, а не от системы.

Журнал обмена. Логировать по каждому сообщению: идентификатор (ключ идемпотентности), номер попытки, статус, ответ принимающей стороны. Без номера попытки и ключа разобрать инцидент невозможно — видно, что «что-то падало», но не видно, что именно и сколько раз.

php
<?php
function logExchange(string $key, int $attempt, string $status, string $response): void
{
    $line = sprintf(
        "%s\t%s\tattempt=%d\tstatus=%s\tresponse=%s\n",
        date('c'), $key, $attempt, $status, mb_substr($response, 0, 500)
    );
    file_put_contents(
        $_SERVER['DOCUMENT_ROOT'] . '/local/logs/exchange_1c.log',
        $line, FILE_APPEND | LOCK_EX
    );
}

Метрики очереди. Две метрики важнее прочих:

  • Глубина очереди — сколько сообщений ждёт. Растёт — потребитель не успевает, начинается затор.
  • Возраст самого старого сообщения — сколько ждёт самое давнее необработанное. Эта метрика честнее глубины: небольшая, но «застрявшая» очередь опаснее большой, но текущей.

Отдельно следят за dead letter: полный DLQ не так страшен, как DLQ, который неделями никто не разбирает. Практичные метрики повторов: доля сообщений с ошибкой (с разбивкой по типам), p95 числа попыток на сообщение, время между первой попыткой и успехом, число сообщений, крутящихся в повторах дольше выбранного порога.

Как выглядит разбор инцидента на практике. Сценарий: менеджер сообщает, что заказ не появился в 1С.

  1. Найти в журнале. Ищем заказ по ключу идемпотентности, а не по номеру: номер ненадёжен по причинам из раздела про ключи. Видим статус последней попытки.
  2. Сообщение ещё в работе. Статус FAILED, попытки не исчерпаны — смотрим next_retry_at: ждём срока или запускаем повтор вручную.
  3. Сообщение в dead letter. Открываем его тело и ответ 1С. Типичная причина застревания — ошибка в данных: невалидный XML, несопоставленная номенклатура, отсутствующий контрагент.
  4. Починить и вернуть в очередь. Правим данные и перекладываем сообщение обратно в основную очередь. Дубля не будет, даже если часть работы уже выполнена, — за это отвечает идемпотентность.

Отладку самого XML заказа удобно смотреть напрямую через mode=query: после авторизации на 1c_exchange.php?type=sale&mode=checkauth открывается 1c_exchange.php?type=sale&mode=query&sessid=ИД_сессии — сайт отдаёт тот же XML, что уходит в 1С. Если заказ в XML есть, а в 1С его нет — проблема на стороне разбора в 1С; если нет и в XML — проблема на сайте.

Что это меняет по сравнению со штатным обменом

Штатный обмен не «плохой» — он синхронный и не рассчитан на ненадёжную сеть. Он отлично работает, пока сеанс не рвётся. Очередь плюс идемпотентность не заменяют протокол CommerceML, а оборачивают его: сайт кладёт заказ в исходящий ящик, отдельный процесс доставляет его с повторами, а на приёме стоит защита от дублей. В результате обрыв в любой точке перестаёт быть катастрофой — он превращается в отложенный повтор, который система обработает сама.

Если разбор узнаваем, но собирать исходящий ящик, идемпотентный приём и разбор dead letter под свой поток заказов руками некогда — это ровно та работа, которую мы делаем на проектах по интеграции сайта с 1С (страница /integraciya-sayta-s-1s/). Спроектируем обмен под вашу связку 1С и Битрикс так, чтобы заказ доходил один раз.

← Все статьи