Представьте заказ с номером 48213. Он лежит в очереди, потребитель берёт его, спотыкается на пустом поле адреса, роняет обработчик и не подтверждает сообщение. Брокер, который ничего не знает про адреса, честно возвращает заказ в очередь. Потребитель берёт его снова и снова спотыкается. Заказ 48214 терпеливо ждёт за спиной у товарища и ни в чём не виноват, но клиент уже пишет в поддержку, что оформление «зависло».

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

Почему сообщение возвращается и где проходит граница обработки

Начать стоит с механики подтверждений, потому что вся остальная конструкция стоит на ней. Когда потребитель работает в режиме ручного подтверждения, брокер считает сообщение «взятым в работу», но не «обработанным». Окончательным оно становится только после ack. Если потребитель умер, соединение оборвалось или канал закрылся без подтверждения, сообщение вернётся в очередь и будет доставлено повторно, возможно, другому потребителю. Это не недостаток, а обещание: лучше доставить дважды, чем потерять заказ.

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

Второй инструмент потребителя — отрицательное подтверждение, nack или reject. Им потребитель говорит: «я это не обработал». Ключевой параметр здесь — возвращать ли сообщение в очередь. Если возвращать, мы получаем ту самую карусель из начала статьи. Если не возвращать, сообщение либо исчезает, либо, при настроенной маршрутизации недоставленных, уходит в другое место. Именно этот выбор и есть граница между «временной неприятностью» и «требует человека»: код потребителя должен принять решение, а не оставить его брокеру.

Как отличить временное от постоянного

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

Типичные причины сбоя при обработке заказа и разумная реакция
ПричинаПройдёт сама?Реакция потребителя
Таймаут или 5xx от внешней системыСкорее даВернуть в обработку с ограничением числа попыток
Своя база временно недоступнаСкорее даНе подтверждать, повторить с паузой
Пустой или недопустимый адрес, неизвестный артикулНетОтклонить без возврата, в очередь ошибок
Сообщение не разбирается как JSON или не соответствует схемеНетОтклонить без возврата, в очередь ошибок
Внешняя система вернула 4xx с понятной причиной отказаНетОтклонить без возврата, причину сохранить
Исчерпан лимит попыток по временной ошибкеУже нет смысла ждатьПередать человеку с пометкой, что ошибка была временной

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

Отдельная очередь для заказов, которые остановили

Теперь о самом механизме разворота сообщения. В RabbitMQ для этого есть dead letter exchange, или DLX. Очередь можно настроить так, чтобы сообщения, которые она «хоронит», перенаправлялись в указанный обменник и оттуда по обычным правилам маршрутизации попадали в другую очередь. Обычно к таким случаям относят отклонение без возврата в очередь, истечение времени жизни и переполнение лимита; точный список и названия аргументов стоит сверить с документацией вашей версии на странице Dead Letter Exchanges.

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

Важно сразу сказать, чего эта схема не делает. Очередь ошибок не чинит заказ. Она ничего не знает ни про адрес, ни про артикул, ни про схему сообщения. Она только гарантирует, что нерешённая проблема лежит в отдельном месте и не мешает остальным. Тот, кто воспринимает DLQ как автоматический ремонт, получает кладбище: сообщения исправно туда уходят и никогда не возвращаются.

Три оговорки из документации, которые нельзя пропустить

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

Во-вторых, по умолчанию внутреннее повторное опубликование через DLX происходит без подтверждений издателя (publisher confirms). Если целевая очередь недоступна в момент такого перекладывания, сообщение может быть потеряно. Для писем в рассылке это терпимо, для заказов, за которыми стоят деньги, нет.

В-третьих, для quorum queues документация описывает отдельный режим dead lettering «как минимум один раз» (at-least-once). Он создан для случая, когда потеря при перекладывании неприемлема. Но это именно особый режим, зависящий от типа очереди и версии брокера. Включать его стоит осознанно, прочитав условия в документации, а не в надежде, что «оно как-то само надёжнее». И даже он обещает «минимум один раз», то есть допускает повторы. Значит, обработчик заказов всё равно должен выдерживать дубли.

Связь с исходным событием: что положить в сообщение заранее

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

  • бизнес-ключ заказа, например номер заказа в вашей учётной системе, а не внутренний номер сообщения;
  • идентификатор самого сообщения и время его создания;
  • источник: какой сервис или интеграция его опубликовала;
  • версия схемы сообщения, чтобы через месяц понимать, по каким правилам его читать;
  • идентификатор корреляции, связывающий заказ с исходным событием на витрине, в CRM или в вебхуке.

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

Если вы делаете перекладывание сами, а не через DLX, вы получаете контроль над содержимым, но берёте на себя ровно ту проблему, о которой говорит документация: публикацию нужно подтверждать, иначе при сбое между отклонением и публикацией заказ исчезнет. Выбор между «брокер перекладывает» и «потребитель публикует в очередь исключений сам» — это выбор между простотой и богатством контекста. Допустимый компромисс: брокер перекладывает сообщение, а описание причины пишется в свою базу по бизнес-ключу. Тогда оператору показывается их соединение.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Что оператор должен увидеть до кнопки «повторить»

Очередь исключений — это, по сути, рабочее место человека. И рабочее место устроено плохо, если оператору приходится открывать консоль управления брокером, смотреть сырой JSON и угадывать. Есть смысл собрать для него карточку, где всё нужное уже лежит рядом.

Карточка заказа в очереди исключений
ПолеЗачем оно оператору
Бизнес-ключ заказаНайти заказ в учётной системе и понять, не создан ли он уже
Причина остановкиПонять, что именно нужно исправить, и кому это передать
Число попыток и время первой неудачиОтличить разовый сбой от долгой проблемы
Исходное содержимоеУвидеть, что именно пришло, без догадок
Статус во внешней системеПонять, есть ли там уже созданный заказ или его точно нет
История решенийНе повторить то, что уже пробовал коллега

Строка про статус во внешней системе — самая важная и самая дорогая. Именно из-за неё повтор бывает опасен. Заказ мог «упасть» в вашем коде уже после того, как внешняя система приняла его. Для оператора такая картина выглядит как обычный сбой, хотя на другой стороне заказ уже существует. Если кнопка «повторить» просто положит сообщение обратно, получится дубль, а клиент получит две посылки или два списания.

Поэтому разумное правило: перед разрешением повтора система должна сама проверить, что известно о заказе снаружи, и показать результат. Если внешний заказ найден, оператору нужно предложить не «повторить», а «сопоставить и закрыть». Если не найден, повтор допустим. Если проверить нельзя, потому что внешняя система недоступна, кнопка должна быть выключена с понятным объяснением, а не нажиматься вслепую.

Исправление и повтор без второго заказа

Допустим, оператор исправил индекс в данных и хочет вернуть заказ в обработку. Что должно произойти? Технически просто: сообщение публикуется обратно в рабочую очередь. Но безопасность этого шага определяется не очередью, а тем, что делает обработчик при получении заказа с уже знакомым бизнес-ключом.

Самая надёжная основа — идемпотентное бизнес-действие: повторное выполнение с тем же бизнес-ключом даёт тот же результат, а не второй заказ. Идемпотентность не появляется сама. Её нужно построить, и вот основные детали.

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

Здесь есть тонкое место, которое стоит назвать прямо. Транзакция вашей базы данных не включает в себя вызов внешнего API: если вы записали «отправлено» и упали до ответа, или получили ответ и упали до записи, база и внешний мир расходятся. Атомарности между ними нет, если у внешней системы нет отдельного механизма, который это обеспечивает. Вся конструкция с ключом идемпотентности существует именно для того, чтобы такое расхождение можно было безопасно разрешить повтором.

Как выглядит ручной возврат

Практичный порядок действий оператора, когда причина понятна, такой.

  1. Открыть карточку заказа и прочитать причину остановки.
  2. Посмотреть, что система нашла во внешней системе по бизнес-ключу.
  3. Исправить данные там, где они хранятся первоисточником (в учётной системе), а не только в копии сообщения, иначе следующий заказ с тем же дефектом упадёт снова.
  4. Нажать «повторить»: система публикует сообщение в рабочую очередь с прежним бизнес-ключом и записывает, кто и когда это сделал.
  5. Убедиться, что заказ дошёл до подтверждённого состояния, и только после этого считать задачу закрытой.

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

Не забудьте и про предохранитель от повторных кругов. Если вручную возвращённый заказ снова не прошёл, он снова должен попасть в очередь исключений, но с пометкой «повторный отказ после ручного возврата». Иначе легко получить цикл, в котором человек и система бесконечно передают друг другу один и тот же заказ. А ещё помните про циклы маршрутизации из документации: возврат должен идти по явной, отдельной связке, а не по той, которая замыкает очередь исключений на рабочую.

Два числа, которые нельзя смешивать

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

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

Покажу на условном примере, цифры которого придуманы для иллюстрации. Пусть магазин получает 1 200 заказов в час, а 0,5 % из них не проходят из-за данных. Это 6 сообщений в час, 144 в сутки. Если сообщение весит около 8 КБ вместе с заголовками, то за сутки в очереди исключений набежит чуть больше мегабайта, а за месяц без разбора — порядка 35 МБ. Для диска это ничто. Серьёзная цена здесь другая: 144 нерешённых заказа в сутки означают, что кто-то должен их разбирать, и каждый такой заказ — недовольный клиент.

А теперь представим плохой день: ошибка в выкатке ломает все заказы на двадцать минут. При тех же 1 200 в час это примерно 400 сообщений в очереди исключений. Поток подскочил с 6 в час до 1 200 в час, запас вырос на 400. Автоматика отработала, как надо: рабочая очередь не встала. Но для оператора это 400 карточек. Хорошо, если у вас есть групповой возврат по причине, с той же проверкой внешней системы для каждого заказа. Если его нет, плохой день превращается в плохую неделю.

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

Что это значит для сервера

Расчёт выше показывает, что сама очередь исключений для типичного потока заказов почти ничего не весит. Ресурсы нужны другим частям: самому брокеру с его журналами и очередями, потребителям заказов и базе, где хранятся состояния и причины. Для небольшого проекта, где заказы идут десятками в час, часто хватает скромного VPS с NVMe-диском и root-доступом, чтобы поставить брокер, потребителей и мониторинг рядом и всё настроить под себя. Если потребителей станет больше, база вырастет или потребуется кластер из нескольких узлов, тарифная линейка MAATRIX от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) до Djed (16 vCPU, 32 ГБ, 320 ГБ) даёт место для роста. Но эти ресурсы — исходные параметры, а не обещание конкретной ёмкости: сколько заказов в секунду вы выдержите, покажет замер на вашей нагрузке. Подробнее о том, как поставить сам брокер, есть в материале RabbitMQ на VPS: брокер сообщений.

Что получилось в итоге

Вернёмся к заказу 48213. Теперь история у него другая. Потребитель споткнулся на пустом адресе, понял, что повтор ничего не изменит, и отклонил сообщение без возврата. Брокер переложил его в очередь «заказы: требуют решения». Заказ 48214 прошёл без задержки и уехал клиенту. Одновременно в карточке оператора появилась запись: бизнес-ключ, причина «не заполнен адрес доставки», одна попытка, время, исходное содержимое и отметка, что во внешней системе такого заказа нет.

Заказ больше не ходит по кругу и не отнимает силы у потребителя. Но он и не пропал: он лежит на виду, с причиной, которую можно прочитать без раскопок в логах, и ждёт человека, который исправит данные и вернёт его с тем же бизнес-ключом. Очередь исключений не починила заказ, и она на это не претендовала. Она сделала другое, не менее ценное: остановила повторение и сохранила видимой причину остановки.