Dead letter queue: куда складывать сообщения, которые не получилось обработать
Сценарий, который я разбирал не раз: 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 не решает проблем, оно делает их видимыми. Что делать с видимыми проблемами — это уже инженерный процесс, который не настраивается через конфиг брокера.