Очередь обмена со сторонней системой - задания, повторы, сверка
Отправляем заказы в стороннюю систему так, чтобы её недоступность не ломала оформление, а потерянные отправки находились в тот же день.
Механика
Прямой вызов чужого сервиса в момент оформления заказа - самая частая ошибка таких интеграций. Покупатель ждёт ответа чужого сервера, а при его недоступности теряет заказ вместе с корзиной. Между покупателем и чужим API всегда ставят что-то своё.
Это «своё» - очередь. Событие сохранения заказа кладёт в свою таблицу задание: что отправить, куда и с каким ключом. Оформление на этом заканчивается, а отправкой занимается задание по расписанию, для которого недоступность сервиса - штатная ситуация.
Идемпотентность решает вторую половину задачи. Сервис мог принять запрос и не успеть ответить, поэтому повтор обязан не создавать второй заказ. Для этого в запрос кладут ключ, собранный из номера заказа, и по нему сервис узнаёт уже принятое.
Третья часть - разбор неудач. Сеть отвалилась, сервис вернул ошибку, формат ответа изменился: всё это нормальные события, и у каждого должна быть понятная судьба. Повторили несколько раз с растущей паузой, не вышло - задание уходит в разбор человеку, а не исчезает.
Наконец, у очереди есть цена. Это своя таблица, своё задание по расписанию и своё место, куда смотреть при разборе, - то есть ещё один кусок системы, который нужно поддерживать. Оправдана она там, где потеря отправки стоит дороже этой поддержки: заказы, платежи, документы. Для необязательных уведомлений хватает и простого вызова с коротким таймаутом.
Шаги
- Завести таблицу очереди: что отправляем, сколько раз пробовали, когда следующая попытка.
- Класть задание в очередь на событии сохранения заказа, а не отправлять сразу.
- Разбирать очередь заданием по расписанию, порциями и с ограничением времени.
- Собирать запрос с идемпотентным ключом и разбирать ответ по кодам, а не по тексту.
- Повторять неудачи с растущей паузой и уводить безнадёжные в разбор.
- Сверять раз в сутки: сколько заказов создано и сколько из них ушло.
Код
Описываем таблицу очереди:
public static function getMap(){ return [ new IntegerField('ID', ['primary' => true, 'autocomplete' => true]), new IntegerField('ORDER_ID'), new StringField('STATUS'), // new, sent, failed, manual new IntegerField('TRIES'), new DatetimeField('NEXT_TRY'), new TextField('LAST_ERROR'), ];}Очередь - обычная своя таблица. В ней хранится не сам запрос, а ссылка на заказ и состояние отправки: остальное собирается в момент попытки из актуальных данных.
Кладём задание на событии заказа:
EventManager::getInstance()->addEventHandler('sale', 'OnSaleOrderSaved', function (\Bitrix\Main\Event $event) { $order = $event->getParameter('ENTITY'); QueueTable::add(['ORDER_ID' => $order->getId(), 'STATUS' => 'new', 'TRIES' => 0, 'NEXT_TRY' => new DateTime()]); });// обработчик обязан быть коротким: он выполняется внутри оформления заказаОбработчик события только регистрирует намерение. Всё тяжёлое - сбор данных, обращение к сервису, разбор ответа - происходит позже и в другом процессе.
Разбираем очередь по расписанию:
$rows = QueueTable::getList([ 'filter' => ['@STATUS' => ['new', 'failed'], '<=NEXT_TRY' => new DateTime()], 'order' => ['NEXT_TRY' => 'ASC'], 'limit' => 50,])->fetchAll();foreach ($rows as $row) { if (time() - $start > 40) { break; } // укладываемся в отведённое время self::sendOne($row);}Задание берёт порцию, а не всю очередь. Ограничение по времени важнее размера порции: агент, не успевший закончить, встретит следующий свой запуск и начнёт работу заново.
Отправляем с идемпотентным ключом:
$http = new \Bitrix\Main\Web\HttpClient(['socketTimeout' => 5, 'streamTimeout' => 10]);$http->setHeader('Idempotency-Key', 'order-' . $row['ORDER_ID']);$response = $http->post($url, Json::encode($payload));$code = $http->getStatus();// 2xx - принято, 4xx - наша ошибка и повторять бесполезно, 5xx - повторяем позжеКлюч идемпотентности защищает от двойной отправки. Он собирается из номера заказа, а не из времени: только так повтор через час остаётся для сервиса тем же самым запросом.
Повторяем с растущей паузой:
$tries = $row['TRIES'] + 1;$delay = min(3600, 60 * (2 ** $tries)); // минута, две, четыре и так далееQueueTable::update($row['ID'], $tries > 6 ? ['STATUS' => 'manual', 'LAST_ERROR' => $error] : ['STATUS' => 'failed', 'TRIES' => $tries, 'LAST_ERROR' => $error, 'NEXT_TRY' => DateTime::createFromTimestamp(time() + $delay)]);Растущая пауза бережёт и чужой сервис, и свой. Бесконечные повторы каждую минуту превращают короткий сбой на стороне партнёра в отказ, который держится всё время, пока очередь бьётся в закрытую дверь.
Сверяем итоги за сутки:
$created = OrderTable::getList(['select' => ['CNT' => new ExpressionField('CNT', 'COUNT(%s)', 'ID')], 'filter' => ['>=DATE_INSERT' => $dayStart]])->fetch();$sent = QueueTable::getList(['select' => ['CNT' => new ExpressionField('CNT', 'COUNT(%s)', 'ID')], 'filter' => ['=STATUS' => 'sent', '>=DATE_INSERT' => $dayStart]])->fetch();printf("создано %s, отправлено %s\n", $created['CNT'], $sent['CNT']);Сверка отвечает на вопрос, который сама очередь задать не может. Задание, потерянное из-за ошибки в коде, в очереди не появится вовсе, и найдёт его только сравнение с числом заказов.
Уводим безнадёжное в разбор:
$manual = QueueTable::getList(['filter' => ['=STATUS' => 'manual'], 'select' => ['ID', 'ORDER_ID', 'LAST_ERROR', 'TRIES']])->fetchAll();if ($manual) { \Bitrix\Main\Mail\Event::send(['EVENT_NAME' => 'QUEUE_MANUAL', 'LID' => SITE_ID, 'C_FIELDS' => ['COUNT' => count($manual)]]);}// задания в разборе должны заканчиваться решением человека, а не копитьсяЗадания в разборе обязаны кому-то мешать. Список, о котором никто не знает, растёт месяцами и обнаруживается ровно тогда, когда партнёр спрашивает про пропавшие заказы за полгода.
Ограничения
Очередь не делает обмен мгновенным. Между оформлением заказа и его появлением в чужой системе проходит время работы задания, и это стоит проговорить с теми, кто будет с ней работать: ожидание «заказ там сразу» приводит к жалобам на несуществующую поломку.
Порядок отправки очередь тоже не гарантирует сама по себе. Если чужой системе важно, что отмена приходит после создания, порядок обеспечивают явно: сортировкой по времени и отказом отправлять отмену раньше, чем ушло создание.
Очередь живёт на том же сервере, что и сайт, и делит с ним нагрузку. Разбор пятидесяти заданий каждую минуту - это пятьдесят обращений к чужому сервису и столько же записей в базу, и в часы распродажи это заметно. Размер порции подбирают замером, а не на глаз.
Идемпотентность работает только тогда, когда её поддерживает чужая сторона. Если сервис ключи не принимает, защиту от дублей строят на своей стороне: помечают отправленное до ответа и разбирают спорные случаи сверкой.
Состояния задания стоит держать в коротком списке и не выдумывать новых. Четырёх - новое, отправлено, неудача, разбор - хватает почти всегда, а десяток состояний превращает разбор очереди в отдельное исследование.
Тело запроса полезно собирать в момент отправки, а не в момент постановки в очередь. Заказ успевает измениться между этими моментами, и уходить должно актуальное состояние, а не снимок часовой давности.
Типичные проблемы
Оформление заказа висит по десять секунд.
Чужой сервис вызывается прямо в обработчике сохранения заказа. Обработчик обязан только ставить задание в очередь.
В чужой системе дубли заказов.
Повтор после неотвеченного запроса создаёт вторую запись. В запрос кладут ключ, собранный из номера заказа.
Очередь растёт, а никто не замечает.
Нет ни сверки, ни оповещения о зависших заданиях. Ежедневная сверка ловит это в тот же день.
Короткий сбой партнёра превратился в часовой.
Повторы идут каждую минуту без растущей паузы. Пауза между попытками должна расти.
Часть заказов не попала в очередь вовсе.
Событие сохранения не поднимается при этом способе создания заказа. Такие места закрывают сверкой по числу заказов.
Частые вопросы
Зачем очередь, если сервис обычно доступен?
Ради тех случаев, когда он недоступен. Без очереди эти заказы теряются молча.
Где хранить очередь: в базе или в файле?
В своей таблице базы: её видно запросом и переживает перезапуск. Файлы для этого неудобны.
Сколько раз повторять отправку?
Шести-семи попыток с растущей паузой обычно достаточно. Дальше задание уходит человеку в разбор.
Что делать с заданиями в разборе?
Смотреть текст ошибки и решать: чинить данные, чинить код или отправлять руками. Копиться они не должны.
Нужна ли очередь для чтения из чужого сервиса?
Для чтения обычно хватает задания по расписанию. Очередь нужна там, где потеря отправки недопустима.
Смежное
-
REST и вебхуки - оглавление подтемы
-
Очередь сообщений: фоновая обработка задач в ядре - штатная очередь вместо своей таблицы
-
Вызов внешнего сервиса из кода: таймауты, повторы, журнал - механика одного вызова
-
Заказы сайта в Битрикс24: вебхук, сопоставление, дубли - типовой потребитель такой очереди
-
Своё API для приложения: контроллер, токен, версии - обратное направление
-
Свой агент и задание по расписанию: создание, шаг, защита - чем разбирать очередь
-
REST и интеграции - устройство темы целиком
-
Свой журнал решения: файл, ротация, что писать - куда писать ход отправки
-
Заявки с сайта: хранение, дубли, передача в CRM - ещё один поток через очередь
-
Товары на торговой площадке: фид, остатки, заказы обратно - типовой потребитель очереди
-
Мониторинг интеграций: что проверять, пороги, оповещение - как заметить, что очередь встала