Очереди мертвых писем: обработка «отравленных» сообщений в распределенных системах

Предотвращение блокировки очередей из-за отравляющих сообщений

Содержимое страницы

Очередь мёртвых писем (Dead-Letter Queue, DLQ) — это страховочная сеть, которая перехватывает сообщения, не поддающиеся обработке потребителями, чтобы одно неисправное сообщение не блокировало очередь и не приводило к тихой потере всех последующих сообщений.

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

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

dead letter queue routing failed messages away from the main queue

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

Что такое очередь мёртвых писем

Очередь мёртвых писем — это отдельная, обычная очередь, в которую брокер или потребитель пересылает сообщение после нескольких неудачных попыток его обработки. Это не специальный конструкт: очередь мёртвых писем в RabbitMQ — это обычная очередь, связанная с обычным обменом, а DLQ в SQS — это обычная стандартная или FIFO-очередь. То, что очередь называется «DLQ», обусловлено исключительно тем, что что-то другое направляет в неё сообщения с ошибками.

flowchart LR P[Producer] --> Q[Main Queue] Q --> C[Consumer] C -- ack: success --> Done[Message deleted] C -- fail / nack / timeout --> Q Q -- retry budget exhausted --> DLQ[Dead Letter Queue] DLQ --> I[Inspect / alert] I -- fix root cause --> R[Replay to main queue] I -- unrecoverable --> D[Archive / discard]

Каждый брокер реализует перенаправление по-своему:

  • Amazon SQS использует политику повторного перемещения (redrive policy) с параметром maxReceiveCount. Как только сообщение получено это количество раз без удаления, SQS перемещает его в конфигурационный deadLetterTargetArn. AWS явно рекомендует устанавливать для DLQ период хранения сообщений дольше, чем для исходной очереди, поскольку исходная метка времени постановки в очередь (а не время перемещения) по-прежнему управляет истечением срока годности.
  • RabbitMQ пересылает сообщение в очередь мёртвых писем, когда оно отклоняется с флагом requeue=false, истекает его TTL (время жизни сообщения), очередь достигает лимита длины или очередь с кворумом превышает значение delivery-limit. Это настраивается с помощью аргументов очереди x-dead-letter-exchange (и опционально x-dead-letter-routing-key), и RabbitMQ добавляет заголовки x-death, фиксирующие причину, исходную очередь и количество таких случаев.
  • Apache Kafka не имеет встроенной в брокер очереди мёртвых писем. Kafka отслеживает только смещения; у неё нет понятия «неудачное» сообщение. Паттерн темы мёртвых писем — это то, что вы строите в потребителе, в топологии Kafka Streams или в коннекторе Kafka Connect — обычно в паре с уровнем тем повторных попыток перед финальной DLT, как это делают @RetryableTopic и DeadLetterPublishingRecoverer в Spring Kafka.
  • Azure Service Bus автоматически пересылает сообщение в очередь мёртвых писем, когда счётчик доставки сообщения превышает MaxDeliveryCount (по умолчанию 10), а также по ряду системных причин, таких как TTLExpiredException, HeaderSizeExceeded и MaxTransferHopCountExceeded, каждая из которых записывается в свойстве DeadLetterReason сообщения.

Для более широкого обзора того, как брокеры и потоковые платформы взаимодействуют на операционном уровне, а не только как паттерн надёжности, см. Быстрый старт Apache Kafka и RabbitMQ на AWS EKS против SQS, которые охватывают инфраструктурную сторону запуска этих брокеров.

Отравляющие сообщения

Отравляющее сообщение (poison message) — это сообщение, которое никогда не будет обработано успешно, сколько бы раз потребитель ни пытался его повторить: некорректный JSON-блок данных, поле схемы, переименованное производителем, нарушение бизнес-правила или ошибка в коде, которая каждый раз выбрасывает исключение на определённом входе. Это отличается от временного сбоя, когда само сообщение корректно, но временно не работает среда: тайм-аут внешней системы, кратковременный разрыв соединения с базой данных, ответ о превышении лимита частоты запросов.

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

Несколько сигналов для обнаружения помогают разделить эти два случая:

  • Тип исключения. Ошибки десериализации, ошибки валидации и сбои типа ClassCastException почти всегда являются постоянными. DefaultErrorHandler в Spring Kafka явно обрабатывает определённые исключения как фатальные и пропускает повторные попытки для них, не расходуя бюджет повторных попыток.
  • Повторяющееся количество без вариаций. Массив заголовков x-death в RabbitMQ позволяет точно увидеть, сколько раз сообщение было переслано в очередь мёртвых писем и почему; сообщение с растущим счётчиком и идентичным x-first-death-reason на каждом цикле является отравляющим, а не просто неудачным.
  • Последовательный сбой на всех репликах. Если каждый экземпляр потребителя не может обработать одно и то же сообщение, успешно обрабатывая всё остальное вокруг него, проблема заключается в самом сообщении, а не в инфраструктуре.

Чтобы различать повторяемые и непостоянные сбои на уровне кода — ту же классификацию, на которой основывается политика DLQ, — см. Архитектура обработки ошибок в Go: Границы и паттерны.

Повторная попытка против отбрасывания

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

Текущие рекомендации по основным брокерам сходятся на схожих значениях:

Брокер Механизм Типичный порог
Amazon SQS maxReceiveCount в политике перемещения 3–5 для смешанных рабочих нагрузок (временные/постоянные)
RabbitMQ (очереди с кворумом) аргумент политики delivery-limit 3–5, настраивается для каждой очереди
Azure Service Bus MaxDeliveryCount По умолчанию 10, часто снижается для очередей, чувствительных к задержкам
Kafka (через темы повторных попыток) Заголовок счётчика повторных попыток + уровень тем повторных попыток 3–4 перехода по темам повторных попыток перед финальной DLT

Практический средний вариант, на который часто опираются команды: начните с консервативного значения (2–3 попытки), наблюдайте за фактическим распределением сбоев в продакшене и увеличивайте порог только для очередей, где можно показать, что большинство сбоев разрешается в течение нескольких попыток. Совместите количество повторных попыток с экспоненциальной задержкой и случайным добавлением джиттера между попытками, чтобы сбой внешней системы не превратился в шторм повторных попыток — те же принципы, которые описаны в дизайне экспоненциальной задержки и разъединителя (circuit breaker). Разъединитель на границе интеграции дополняет этот подход: он прекращает отправку запросов к неисправной зависимости, вместо того чтобы позволять каждому сообщению в очереди индивидуально обнаруживать сбой и отправляться в очередь мёртвых писем по одному.

Как только сообщение попадает в очередь мёртвых писем, «отбрасывание» всё равно должно быть осознанным действием, а не пренебрежением. Установите период хранения для самой очереди мёртвых писем — достаточно длинный для проведения расследований (AWS рекомендует устанавливать для DLQ период хранения дольше, чем для исходной очереди; для очередей мёртвых писем RabbitMQ типичным минимумом является одна неделя) — и настройте оповещения по глубине и возрасту очереди мёртвых писем, чтобы сбои классифицировались и обрабатывались, а не истекали тихо. Сообщение, которое вышло за пределы срока хранения в очереди мёртвых писем без рассмотрения, — это сообщение, которое вы решили потерять, не приняв осознанного решения об этом.

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

Стратегии повторной обработки

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

  1. Сначала устраните корневую причину. Развёртывание исправления потребителя перед повторной обработкой — это разница между чистым восстановлением и повторным «отравлением» очереди тем же сбоем во второй раз.
  2. Перемещайте намеренно, а не автоматически. SQS поддерживает функцию перемещения обратно к источнику, которая по запросу перемещает сообщения обратно в их исходную очередь (или другое назначение); RabbitMQ и Kafka требуют самостоятельной реализации эквивалентного потребителя или инструмента. В любом случае рассматривайте повторную обработку как действие, запускаемое оператором, с записью того, что было обработано повторно и когда.
  3. Сохраняйте порядок там, где это важно. Для Kafka тема мёртвых писем должна иметь не менее партиций, чем исходная тема, и должна сохранять исходный ключ сообщения, чтобы повторенные сообщения попадали обратно в правильную партицию и сохраняли порядок по ключам.
  4. Ограничивайте количество попыток повторной обработки. Сообщение, которое снова не обрабатывается после цикла «исправить и повторить», не является временным — направьте его в постоянное хранилище (таблицу базы данных, ведро объектного хранилища) вместо бесконечного зацикливания через очередь мёртвых писем. Собственные документация RabbitMQ предупреждает, что сообщение, пересланное в очередь мёртвых писем, может быть перенаправлено между очередями только ограниченное количество раз (16), после чего дальнейшее перемещение на основе TTL отключается.
  5. Никогда не позволяйте очереди мёртвых писем пересылать сообщения в саму себя. Если ваша очередь мёртвых писем имеет собственный x-dead-letter-exchange (RabbitMQ) или собственную политику перемещения (SQS), указывающую обратно на ту же цепочку, сбой при повторной обработке может создать бесконечный цикл. Оставьте конфигурацию перемещения очереди мёртвых писем пустой или направьте её в строго конечное хранилище.
  6. Оповещайте об объёме, а не только о наличии. Одно сообщение в очереди мёртвых писем — это точка данных; внезапный скачок — это инцидент. Подключите глубину очереди мёртвых писем и возраст сообщений к тому же конвейеру оповещений, который вы используете для всего остального — см. Современный дизайн систем оповещения для команд observability для практик маршрутизации и снижения шума, которые напрямую применяются к оповещениям DLQ.

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

Где DLQ вписываются в общую картину

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

Полезные ссылки

Подписаться

Получайте новые материалы про системы, инфраструктуру и AI engineering.