lenec ru

← все посты

Dead letter queue: куда складывать сообщения, которые не получилось обработать

13K

Сценарий, который я разбирал не раз: consumer получает событие OrderCreated, обработчик падает с NullPointerException, retry-механизм брокера повторяет доставку. Раз, два, тысячу раз. Consumer-под занят бесполезной работой, очередь не движется, остальные события стоят. На дашборде «consumer работает», на проде — стопор.

Dead letter queue (DLQ) — это про то, чтобы убрать «отравленное» сообщение из основного потока, дать остальным двигаться, и при этом не потерять его навсегда. Звучит просто, но в реальной реализации куча нюансов: когда отправлять в DLQ, что с ним делать дальше, как избежать DLQ-зомби, которые накапливаются в углу и про которых все забыли.

Что такое DLQ концептуально

DLQ — это «изолятор» для сообщений, которые не удалось обработать. После N неудачных попыток сообщение перемещается в отдельную очередь/топик, где его можно разобрать вручную или автоматически.

Ключевое слово — «изолятор». Цель не в том, чтобы события исчезли, а в том, чтобы:

  • Основная обработка не блокировалась.
  • Команда видела, что произошло.
  • Было место хранения для последующего разбора.

Без DLQ у вас два плохих варианта: либо «poison message» бесконечно крутится в очереди, либо вы его выбрасываете, и команда никогда не узнает, что что-то сломалось.

Когда отправлять в DLQ

Не каждая ошибка — повод для DLQ. Я различаю три типа сбоев в обработчике.

Транзиентные

Сетевой таймаут к внешнему API, временная недоступность БД, deadlock на транзакции. Ошибка, которая через 30 секунд скорее всего пройдёт.

Тут DLQ не нужен. Нужен retry с backoff: попробовать через секунду, через две, через четыре. После N попыток в течение разумного времени — отправлять в DLQ.

Перманентные

NullPointerException, нарушение constraint в БД, неправильный формат сообщения. Эти не пройдут с ретраем — они проходить не будут никогда без правки кода или данных.

Для перманентных лучше сразу отправлять в DLQ, без ретраев. Ретраи на перманентной ошибке — это просто потеря CPU и забивание логов.

Бизнес-ошибки

Событие пришло, но в моменте его обработать нельзя по бизнес-логике. Например, OrderPaid пришёл, но в БД ещё нет соответствующего OrderCreated (отстаёт другой consumer).

Это не сбой обработчика. Это нормальная гонка событий. Вместо DLQ — отложенный retry с большим интервалом, или специальная логика waiting state. DLQ для таких случаев — слишком грубо.

Как отличить транзиентное от перманентного

Это самый сложный вопрос в дизайне DLQ-стратегии. Простые правила:

try {
    handle(event)
} catch (e: Exception) {
    when (e) {
        is SocketTimeoutException, is ConnectException -> throw RetriableException(e)
        is JsonParseException, is IllegalArgumentException -> throw NonRetriableException(e)
        is DataIntegrityViolationException -> {
            // зависит от контекста: дубль (постоянно) или гонка (временно)
            if (isDuplicate(e)) throw NonRetriableException(e)
            else throw RetriableException(e)
        }
        else -> throw RetriableException(e) // дефолт — пробуем ещё
    }
}

Ошибки сериализации — почти всегда перманентные. Бизнес-проверки — ситуативные. Сетевые — почти всегда транзиентные.

Если сомневаетесь, относите к транзиентным. После N ретраев всё равно попадёт в DLQ. Лучше попробовать ещё раз, чем отправить ложно-перманентное в DLQ.

Реализация в Kafka

В Kafka DLQ — это просто отдельный топик. Можно построить руками или использовать встроенные механизмы Kafka Streams / Spring Kafka / Kafka Connect.

@KafkaListener(topics = ["orders"])
fun handleOrder(record: ConsumerRecord<String, String>) {
    try {
        val event = mapper.readValue(record.value(), OrderEvent::class.java)
        processEvent(event)
    } catch (e: Exception) {
        if (record.headers().lastHeader("retry-count")?.value()?.toString(Charsets.UTF_8)?.toInt() ?: 0 >= 5) {
            sendToDlq(record, e)
        } else {
            sendToRetry(record, e)
        }
    }
}

Spring Kafka умеет это из коробки: SeekToCurrentErrorHandler или DefaultErrorHandler с настройкой BackOff и DeadLetterPublishingRecoverer.

Топология обычно такая: основной топик → retry-топик с backoff → DLQ. Это удобно, потому что retry не блокирует основной поток: «сложные» сообщения уходят в свою очередь и обрабатываются с задержкой.

Реализация в RabbitMQ

В RabbitMQ есть встроенный механизм DLX (Dead Letter Exchange). Очередь декларируется с указанием, куда отправлять сообщения после nack или TTL:

channel.queueDeclare(
    "orders.main",
    true, false, false,
    mapOf(
        "x-dead-letter-exchange" to "orders.dlx",
        "x-dead-letter-routing-key" to "orders.dlq"
    )
)

Когда consumer делает basicNack(deliveryTag, false, false) (без requeue), сообщение автоматически уходит в DLX → orders.dlq.

Для задержки между ретраями используется TTL на промежуточной очереди:

orders.main -- nack --> orders.retry (TTL 30s) -- expire --> orders.main
                    -- after N retries --> orders.dlq

Эта схема ставит сообщение в очередь с TTL 30 секунд, оно «протухает», возвращается обратно в orders.main через DLX той retry-очереди. После N циклов — отправляется в DLQ.

Реализация в NATS JetStream

В JetStream нет встроенного DLQ-механизма как такового, но есть max_deliver на consumer'е и возможность настроить redirect на превышение.

js.addOrUpdateConsumer("ORDERS", ConsumerConfiguration.builder()
    .durable("orders-handler")
    .maxDeliver(5)
    .ackWait(Duration.ofSeconds(30))
    .build())

После 5 неудачных попыток сообщение помечается как «delivered too many times» и не доставляется. Но в этом виде оно теряется — нужно явно публиковать в DLQ-stream.

На практике это делается в обработчике:

val deliveryCount = msg.metaData().deliveredCount()
if (deliveryCount >= 5) {
    js.publish("DLQ.orders", buildDlqEnvelope(msg))
    msg.ack()
} else {
    try {
        process(msg)
        msg.ack()
    } catch (e: Exception) {
        msg.nakWithDelay(backoff(deliveryCount))
    }
}

Метаданные при попадании в DLQ

В DLQ нужно отправлять не просто сообщение, а сообщение с контекстом:

  • Оригинальный topic/queue.
  • Время первого получения и время попадания в DLQ.
  • Количество попыток обработки.
  • Тип ошибки и stack trace последнего падения.
  • Имя сервиса, который не справился.
{
  "original_topic": "orders",
  "original_payload": { "orderId": "123", "amount": 1000 },
  "first_received_at": "2026-03-15T10:30:00Z",
  "failed_at": "2026-03-15T10:35:42Z",
  "retry_count": 5,
  "failed_in_service": "orders-processor",
  "last_error": {
    "type": "NullPointerException",
    "message": "...",
    "stack": "..."
  }
}

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

Что делать с сообщениями в DLQ

Главный вопрос. Без процесса разбора DLQ становится свалкой.

Алертинг. Любое сообщение в DLQ — повод для алерта. На queue size > 0 или на конкретные типы ошибок. Не «дашборд, на который иногда смотрят», а активный пинг команды.

Категоризация. Регулярно (раз в день/неделю) проходить по DLQ и группировать по причине. Десять сообщений с одинаковой ошибкой — это один баг. Тысяча с разными — это стихийное бедствие.

Replay. После исправления бага должна быть возможность вернуть сообщения из DLQ обратно в основную очередь. Простая команда: republish-dlq --topic=orders --filter="error_type=DeserializationException". У большинства команд этот скрипт пишется только когда уже накопилась тысяча сообщений и больно — пишите его сразу.

Архивация. Если сообщение в DLQ старше N дней и команда не разобралась — архив в S3 + удаление. Иначе DLQ растёт и тормозит брокер.

Подводные камни

Несколько ситуаций, на которые я наступал.

Бесконечный retry без DLQ. Команда не настроила max_retries или не сделала разделение на транзиентные и перманентные. Перманентная ошибка крутится годами в логах.

DLQ для всего. Слишком агрессивная настройка: первая же ошибка — в DLQ. В результате DLQ забивается транзиентными сбоями, которые могли пройти со второго раза. Лекарство — несколько ретраев перед DLQ.

DLQ без поллинга. Сообщения копятся, но никто не смотрит. Через год — миллион записей с одной и той же ошибкой пятилетней давности. Алерты обязательны, не «при возможности».

Replay без идемпотентности. Сообщение из DLQ возвращается в основную очередь, обрабатывается, но обработчик не идемпотентен. Получаем дубль эффекта. Inbox pattern должен покрывать и обычный поток, и replay.

Чувствительные данные в DLQ. Payload может содержать персональные данные. DLQ часто живёт в общем брокере с более широким доступом, чем production-данные. Подумайте об этом до того, как туда попадёт хеш паспорта.

«DLQ — это вечно». Сообщения в DLQ должны иметь TTL. После 30 дней — архив или удаление. Иначе DLQ — это «куда складываем то, что забываем», и она вырастает до состояния, когда уже бесполезна для разбора.

Что запомнить

DLQ — это не отдельный механизм брокера, это процесс работы с ошибками. Технически — отдельная очередь/топик. Семантически — изолятор + процесс разбора + replay.

Без DLQ poison messages блокируют обработку. С DLQ, но без процесса — DLQ становится мусорным контейнером, в который никто не заглядывает. Работает — это «настроенный DLQ + алерты + регулярный разбор + replay-инструмент + TTL». Все пять элементов нужны вместе.

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

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.