Питання 25 · Розділ 15

Що таке DLQ (Dead Letter Queue) в Kafka

Уявіть фабрику з пакування подарунків. Працівник бере подарунок, намагається упакувати. Якщо подарунок пошкоджений (зламаний, розбитий):

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Просте визначення

DLQ (Dead Letter Queue) — це спеціальний Kafka топик, куди відправляються повідомлення, які не вдалося обробити після кількох спроб. Це “ізолятор” для проблемних даних.

Аналогія

Уявіть фабрику з пакування подарунків. Працівник бере подарунок, намагається упакувати. Якщо подарунок пошкоджений (зламаний, розбитий):

  1. Спробувати ще раз (retry)
  2. Якщо після 3 спроб все ще зламаний — покласти в кошик “брак” (DLQ)
  3. Продовжити пакувати наступні подарунки

Конвеєр не зупиняється, а браковані подарунки можна пізніше вивчити і полагодити.

Приклад з Spring Kafka

@Configuration
public class KafkaConfig {

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
            ConsumerFactory<String, String> consumerFactory,
            KafkaTemplate<String, String> kafkaTemplate) {

        var factory = new ConcurrentKafkaListenerContainerFactory<String, String>();
        factory.setConsumerFactory(consumerFactory);

        // Налаштування DLQ
        factory.setCommonErrorHandler(new DefaultErrorHandler(
            new DeadLetterPublishingRecoverer(kafkaTemplate),
            new FixedBackOff(1000L, 3)  // 3 retry, 1 секунда між спробами
        ));

        return factory;
    }
}

Що потрапляє в DLQ

  • Corrupt дані (невалідний JSON, schema mismatch)
  • Неможливо обробити (БД недоступна > max retries)
  • Business rule violations (negative price, invalid email)
  • External service errors (API rate limited, timeout)

Коли використовувати

  • Критичні дані — не можна втрачати повідомлення
  • Необхідність аналізу — потрібно зрозуміти чому обробка не вдається
  • Compliance — аудит і traceability
  • Production — завжди мати DLQ plan

🟡 Middle Level

Архітектура DLQ

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│   Producer  │────>│  Main Topic  │────>│  Consumer   │
└─────────────┘     └──────────────┘     └──────┬──────┘
                                                │
                                    ┌───────────┼───────────┐
                                    │           │           │
                               Success    Error → Retry   │
                                                  │        │
                                          Max retries?    │
                                             Yes    │     │
                                                  ▼     │
                                          ┌──────────────┴──┐
                                          │    DLQ Topic     │
                                          │  (orders-dlq)    │
                                          └──────────────────┘

DLQ Message Enrichment

Просто відправити оригінальне повідомлення в DLQ — недостатньо. Потрібно додати контекст:

public class DLQEnricher {

    public ProducerRecord<String, String> enrichForDLQ(
            ConsumerRecord<String, String> original,
            Exception error) {

        ProducerRecord<String, String> dlqRecord = new ProducerRecord<>(
            original.topic() + "-dlq",  // orders → orders-dlq
            original.key(),
            original.value()
        );

        // Метадані для debugging
        dlqRecord.headers().add("x-original-topic", original.topic().getBytes(StandardCharsets.UTF_8));
        dlqRecord.headers().add("x-original-partition", String.valueOf(original.partition()).getBytes());
        dlqRecord.headers().add("x-original-offset", String.valueOf(original.offset()).getBytes());
        dlqRecord.headers().add("x-original-timestamp", String.valueOf(original.timestamp()).getBytes());
        dlqRecord.headers().add("x-consumer-group", "order-service".getBytes());
        dlqRecord.headers().add("x-error-message", error.getMessage().getBytes(StandardCharsets.UTF_8));
        dlqRecord.headers().add("x-error-stacktrace", getStackTrace(error).getBytes(StandardCharsets.UTF_8));
        dlqRecord.headers().add("x-retry-count", "3".getBytes());
        dlqRecord.headers().add("x-dlq-timestamp", String.valueOf(System.currentTimeMillis()).getBytes());

        return dlqRecord;
    }
}

DLQ Patterns

Паттерн Опис Коли використовувати
Single DLQ Один DLQ топик для всіх помилок Прості системи
Per-topic DLQ topic-dlq для кожного topic Різні команди відповідальності
Per-error DLQ topic-dlq-validation, topic-dlq-timeout Тонкий analysis
Multi-level DLQ DLQ → DLQ2 → Manual review High-compliance системи

DLQ Consumer — обробка DLQ

// DLQ Consumer — читає DLQ і вирішує що робити
@KafkaListener(topics = "orders-dlq", groupId = "dlq-handler")
public void handleDLQ(ConsumerRecord<String, String> record) {
    String originalTopic = getHeader(record, "x-original-topic");
    String errorMessage = getHeader(record, "x-error-message");
    String stacktrace = getHeader(record, "x-error-stacktrace");

    // Класифікація помилки
    if (errorMessage.contains("Timeout")) {
        // Тимчасова помилка — retry
        retryMessage(record);
    } else if (errorMessage.contains("ConstraintViolation")) {
        // Data issue — manual review
        sendToManualReview(record);
    } else if (errorMessage.contains("Schema")) {
        // Schema mismatch — fix producer
        alertProducerTeam(record);
    } else {
        // Unknown — manual review
        sendToManualReview(record);
    }
}

Таблиця типових помилок

Помилка Симптоми Наслідки Рішення
Без DLQ Poison pill блокує consumer Consumer lag ∞, pipeline stall Додати DLQ
DLQ без monitoring DLQ росте до нескінченності Disk fill, missed errors Alert на DLQ size
DLQ без metadata Неможливо debug Години на investigation Enrich headers
DLQ = оригінальний topic Infinite loop Message bounce між topics Окремий DLQ topic
DLQ без retention DLQ займає диск вічно Disk fill Налаштувати retention
Без DLQ consumer DLQ messages накопичуються Без автоматичного recovery DLQ consumer для auto-retry

Коли НЕ використовувати DLQ

  • Non-recoverable дані — логи, метрики які можна втратити
  • Ідемпотентні операції — retry безпечно повторювати нескінченно
  • Тестові оточення — можна просто перезапустити
  • Дуже low-volume (< 10 msg/min) — простіше alert і manual fix
  • Для повністю ідемпотентних операцій з автоматичним retry DLQ може бути зайвим — достатньо alerting на retry exhaustion.

🔴 Senior Level

Глибокі внутрішності

DLQ в Spring Kafka — DeadLetterPublishingRecoverer

// Internal implementation
public class DeadLetterPublishingRecoverer implements ConsumerRecordRecoverer {

    private final Function<ConsumerRecord<?, ?>, TopicPartition> destinationResolver;
    private final KafkaTemplate<Object, Object> template;

    @Override
    public void accept(ConsumerRecord<?, ?> record, Exception exception) {
        // 1. Determine DLQ topic
        TopicPartition dest = destinationResolver.apply(record);

        // 2. Create enriched record
        ProducerRecord<Object, Object> dlqRecord = createProducerRecord(record, exception);

        // 3. Send to DLQ (sync to guarantee delivery)
        template.send(dlqRecord).get();  // Blocking!

        // 4. Log
        log.error("Sent to DLQ: topic={}, partition={}, offset={}",
                  record.topic(), record.partition(), record.offset());
    }

    // Header enrichment
    private ProducerRecord<Object, Object> createProducerRecord(
            ConsumerRecord<?, ?> record, Exception exception) {
        // Копіює key, value
        // Додає headers:
        //   - KafkaHeaders.DLT_EXCEPTION_FQCN (exception class name)
        //   - KafkaHeaders.DLT_EXCEPTION_MESSAGE (exception message)
        //   - KafkaHeaders.DLT_ORIGINAL_TOPIC
        //   - KafkaHeaders.DLT_ORIGINAL_PARTITION
        //   - KafkaHeaders.DLT_ORIGINAL_OFFSET
        //   - KafkaHeaders.DLT_ORIGINAL_TIMESTAMP
    }
}

Kafka Streams — Branching для DLQ

// Kafka Streams не має built-in DLQ
// Реалізуємо через branching

KStream<String, String> orders = builder.stream("orders");

// Split: valid vs invalid
KStream<String, String>[] branches = orders.branch(
    (key, value) -> isValid(value),   // valid stream
    (key, value) -> !isValid(value)   // invalid → DLQ
);

// Valid → process
branches[0].process(() -> orderProcessor);

// Invalid → DLQ topic
branches[1].mapValues(value -> enrichWithMetadata(value, "validation-error"))
            .to("orders-dlq");

Apache Camel / Kafka Connect DLQ

# Kafka Connect — built-in DLQ support
errors.tolerance=all
errors.deadletterqueue.topic.name=connect-dlq
errors.deadletterqueue.context.headers.enable=true
errors.log.enable=true
errors.log.include.messages=true

# Connect автоматично відправляє failed records в DLQ
# з context headers

Trade-offs

Аспект Без DLQ З DLQ
Data safety Низька (poison pill data loss) Висока
System availability Низька (stall on error) Висока
Complexity Низька Середня
Debugging time Години-дні Хвилини
Storage overhead 0% 0.1–1% (DLQ messages)
Operational overhead High (manual investigation) Low (automated)

Edge Cases

1. DLQ overflow — коли DLQ сам стає проблемою:

Сценарій:
  Producer bug → всі messages invalid
  100K messages/min → DLQ
  DLQ topic: replication factor 3, retention 7 днів
  DLQ storage: 100K × 60 × 24 × 7 × 1KB = 10GB/day

Проблема: DLQ fills disk faster than main topic!

Рішення:
  1. DLQ retention.ms = 86400000 (1 день, не 7)
  2. DLQ consumer для швидкого drain
  3. Alert on DLQ rate > threshold
  4. Circuit breaker: stop sending to DLQ, alert only

2. DLQ ordering і causality:

Сценарій:
  Partition 0: order-1 (success), order-2 (DLQ), order-3 (success)
  order-3 залежить від order-2 (update після create)
  order-2 в DLQ → order-3 processed but fails (no order-2)

Проблема: Causality broken — downstream processing залежить від DLQ'd message

Рішення:
  1. Saga pattern: order-3 checks prerequisite
  2. Compensating action: якщо order-2 в DLQ → rollback order-1
  3. Partition by correlation key — related messages в same partition

3. DLQ replay — перевідправлення з DLQ:

// DLQ replay consumer
@KafkaListener(topics = "orders-dlq", groupId = "dlq-replayer")
public void replay(ConsumerRecord<String, String> record) {
    String errorMessage = getHeader(record, "x-error-message");
    String originalTopic = getHeader(record, "x-original-topic");

    if (errorMessage.contains("Timeout") && isDatabaseHealthy()) {
        // Retry — помилка була тимчасовою
        originalProducer.send(new ProducerRecord<>(
            originalTopic, record.key(), record.value()
        ));
        log.info("Replayed from DLQ: {}", record.offset());
    } else {
        // Не retryable — manual review
        log.warn("Non-replayable DLQ message: {}", record.offset());
    }
}
// При повторній помилці — відправити в manual-review queue, НЕ нескінченний retry.
// DLQ-for-DLQ pattern: після N retry → друга DLQ → manual intervention.

4. DLQ і exactly-once semantics:

Проблема:
  Consumer читає message → error → send to DLQ → commit offset
  Consumer crash AFTER DLQ send, BEFORE commit
  При restart: message прочитан знову → знову в DLQ → duplicate in DLQ

Рішення:
  1. Transactional DLQ producer:
     transaction {
       process(record);
       if (error) dlqProducer.send(dlqRecord);
       consumer.commitSync();
     }
  2. DLQ deduplication key = original topic + partition + offset
  3. Idempotent DLQ producer

Highload Best Practices

✅ Окремий DLQ topic (не re-use original topic)
✅ DLQ enrichment з metadata (topic, partition, offset, exception)
✅ DLQ retention policy (1–7 днів, не infinite)
✅ Alert on DLQ messages (never silent DLQ)
✅ DLQ consumer для automated retry/replay
✅ DLQ dashboard (message count, error types, age)
✅ Schema validation на producer side (prevent poison pills)
✅ Per-error-type DLQ routing для automated classification
✅ Idempotent DLQ producer (prevent duplicate DLQ messages)
✅ Circuit breaker на DLQ send rate (prevent DLQ overflow)

❌ Без DLQ в production
❌ DLQ без monitoring/alerting
❌ DLQ без retention policy (disk fill)
❌ DLQ = оригінальний topic (infinite loop)
❌ Без DLQ consumer (messages accumulate forever)
❌ Без metadata в DLQ (impossible to debug)
❌ DLQ без enrichment (lost context)

Архітектурні рішення

  1. DLQ — не просто топик, а процес — producer → DLQ → consumer → replay → fix
  2. Enrichment critical — без metadata DLQ марний для debugging
  3. Auto-classification — categorize DLQ errors для automated handling
  4. Prevention > cure — schema validation, producer-side checks
  5. DLQ retention — balance між debugging needs і disk usage

Резюме для Senior

  • DLQ — insurance policy проти poison pill і data loss
  • DLQ без monitoring = no DLQ (false sense of security)
  • Enrichment headers — критичні для debugging і replay
  • DLQ consumer потрібен для automated retry/replay capability
  • Per-error routing → automated classification → reduced manual review
  • Schema validation on producer — найкращий спосіб зменшити DLQ volume
  • DLQ retention policy обов’язкова для disk management
  • Transactional DLQ для exactly-once DLQ semantics
  • At highload: DLQ rate може стати проблемою — circuit breaker потрібен
  • DLQ dashboard + on-call process — operational necessity

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • DLQ — спеціальний топик для повідомлень, не оброблених після max retries
  • DLQ enrichment: original topic, partition, offset, exception message, stacktrace, timestamp
  • Паттерни: Single DLQ, Per-topic DLQ (topic-dlq), Per-error DLQ, Multi-level DLQ
  • DLQ consumer: читає DLQ, класифікує помилки, retry або manual review
  • DLQ retention policy (1-7 днів) — без неї disk fill guaranteed
  • DLQ без monitoring = no DLQ (false sense of security)
  • Transactional DLQ producer запобігає duplicate DLQ messages

Часті уточнюючі запитання:

  • Навіщо enrich DLQ messages? — Без metadata неможливі debugging і replay.
  • Що якщо DLQ producer тоже впаде? — Retry limit → manual-review queue.
  • Як replay з DLQ? — DLQ consumer читає, перевіряє fix, resends в original topic.
  • DLQ = original topic? — НІ! Infinite loop: message bounce між topics.

Червоні прапорці (НЕ говорити):

  • «DLQ без monitoring — нормально» — messages accumulate forever unnoticed
  • «DLQ без retention — зберігати вічно» — disk fill guaranteed
  • «DLQ = оригінальний топик» — infinite retry loop
  • «DLQ не потрібен для ідемпотентних операцій» — poison pill все одно потрібен

Пов’язані теми:

  • [[24. Як обробляти помилки при читанні повідомлень]]
  • [[26. Як моніторити lag консьюмера]]
  • [[11. Як налаштувати exactly-once семантику]]
  • [[10. У чому різниця між at-most-once, at-least-once і exactly-once]]