Опирается на правила: R-KFK-RTRY-1R-KFK-RTRY-4 и R-KFK-RTRY-X1R-KFK-RTRY-X4 из Kafka Style Guide → раздел 5. Retry topic + DLQ.

Важно знать

  • Blocking retry в listener (Thread.sleep + retry) — антипаттерн, блокирует poll-цикл, провоцирует rebalance.
  • Non-blocking retry через отдельные топики с возрастающим delay: *.retry-1m, *.retry-10m, *.dlq.
  • @RetryableTopic Spring Kafka — стандартная реализация.
  • Retry только для transient: сеть, 5xx, DB timeout. Не retry: 4xx, validation, NPE.
  • DLQ-monitoring — alert если за час > N сообщений.
  • Replay из DLQ — manual, не автоматический.
  • Игнорирование exception (try/catch + log.error + ack) — событие потеряно, никто не узнает.
  • Retry topic без max-attempts = бесконечный retry = lock-step с проблемной системой.

Кафка-consumer должен переживать временные сбои внешних систем без потери сообщений и без блокировки своего poll-цикла. Retry topic + DLQ — стандартный паттерн UCP: каждая попытка — отдельный topic с увеличивающимся delay, после исчерпания попыток событие падает в DLQ для ручного разбора.

Retry topics с backoff

R-KFK-RTRY-1: отдельные топики, не блокирующий retry.

orders.confirmed              ← основной
orders.confirmed.retry-1m     ← retry через 1 мин
orders.confirmed.retry-10m    ← retry через 10 мин
orders.confirmed.retry-1h     ← retry через 1 час
orders.confirmed.dlq          ← окончательный fail

Spring Kafka @RetryableTopic:

@RetryableTopic(
    attempts = "4",
    backoff = @Backoff(delay = 60_000, multiplier = 10),
    autoCreateTopics = "false",
    include = { RetryableException.class },
    exclude = { NonRetryableException.class }
)
@KafkaListener(topics = "orders.confirmed", groupId = "billing-service")
public void handle(OrderConfirmedEvent event, Acknowledgment ack) {
    billingService.charge(event.orderId(), event.totalAmount());
    ack.acknowledge();
}

@DltHandler
public void handleDlt(OrderConfirmedEvent event, Exception exception) {
    log.error("Event sent to DLT: orderId={}", event.orderId(), exception);
    dlqAlertService.notify(event, exception);
}

Что происходит при exception:

  1. Listener бросает RetryableException.
  2. Spring публикует event в orders.confirmed.retry-1m с задержкой 60 секунд.
  3. Через минуту другой listener потребляет retry-topic, обрабатывает.
  4. Если опять exception — orders.confirmed.retry-10m.
  5. И так до 4-х попыток.
  6. После исчерпания — orders.confirmed.dlq, @DltHandler уведомляет ops.

Главное преимущество — poll-цикл основного listener не блокируется. Между retry-ями consumer обрабатывает другие события.

autoCreateTopics = "false" — топики создаются явно через Liquibase-аналог или admin CLI. Auto-creation в проде — risk: typo в topic name создаст новый topic с дефолтными настройками.

Что retry-ить, что нет

R-KFK-RTRY-2: разные исключения — разные стратегии.

Тип ошибкиRetry?Причина
IOException, ConnectExceptionДасетевая проблема, временная
HTTP 5xx от downstreamДасервер перегружен или недоступен
DataAccessResourceFailureException (DB timeout)ДаDB лагает, восстановится
OptimisticLockingFailureExceptionДаконкурентный write, retry с reload
HTTP 4xx от downstreamНетконтракт нарушен, retry не починит
ValidationExceptionНетданные невалидны, retry бессмыслен
IllegalArgumentException, NullPointerExceptionНетbug в коде, retry не поможет
IllegalStateException (business invariant)Нетбизнес-логика отказала, не временно
public class RetryableException extends RuntimeException {
    public RetryableException(String message, Throwable cause) { super(message, cause); }
}

public class NonRetryableException extends RuntimeException {
    public NonRetryableException(String message, Throwable cause) { super(message, cause); }
}

@KafkaListener(topics = "orders.confirmed")
public void handle(OrderConfirmedEvent event, Acknowledgment ack) {
    try {
        billingService.charge(event);
    } catch (HttpServerErrorException e) {
        throw new RetryableException("payment-provider 5xx", e);
    } catch (HttpClientErrorException e) {
        throw new NonRetryableException("payment-provider 4xx, bad request", e);
    }
    ack.acknowledge();
}

В @RetryableTopic(include = RetryableException.class) — только Retryable идёт в retry-topic, NonRetryable — сразу в DLQ.

DLQ monitoring

R-KFK-RTRY-3: alert на размер DLQ.

- alert: KafkaDlqBacklog
  expr: kafka_topic_partition_current_offset{topic=~".*\\.dlq"} -
        kafka_consumer_group_current_offset{topic=~".*\\.dlq",group="ops-monitoring"} > 10
  for: 30m
  annotations:
    summary: "DLQ {{ $labels.topic }} has > 10 unprocessed events"
    runbook: https://runbooks.internal/kafka-dlq

Без алерта DLQ становится свалкой:

  • События накапливаются месяцами.
  • Команда узнаёт о проблеме когда клиенты жалуются.
  • Каждый DLQ event = непроведённая бизнес-операция.

DLQ alert обязателен. Threshold зависит от критичности: для money — > 1 (одно событие = инцидент); для analytics — > 1000 (потеря части аналитики приемлема).

Replay из DLQ

R-KFK-RTRY-4: ручная операция, не автоматическая.

@RestController
@RequestMapping("/admin/dlq")
@RequiredArgsConstructor
public class DlqReplayController {

    private final DlqReplayService replayService;

    @PostMapping("/{topic}/replay")
    @PreAuthorize("hasRole('ADMIN')")
    public ReplayResult replay(
        @PathVariable String topic,
        @RequestBody ReplayRequest request
    ) {
        return replayService.replay(topic, request);
    }
}

Почему не автоматический retry из DLQ:

  • В DLQ попало событие с bug-ом обработки. Если автоматически вернуть его в основной topic — снова bug, снова DLQ. Loop.
  • Может быть событие с corrupted payload. Replay бессмыслен, нужен DELETE.
  • Восстановление инфры (payment-provider вернулся через 6 часов) — операционное решение, не код.

Ops team смотрит в DLQ через admin-API или KafkaUI, по каждому событию решает: replay, drop, или manual remediation.

Что запрещено

Blocking retry через Thread.sleep

R-KFK-RTRY-X1: классический антипаттерн.

// КАТАСТРОФА
@KafkaListener(topics = "orders.confirmed")
public void handle(OrderConfirmedEvent event, Acknowledgment ack) {
    for (int i = 0; i < 5; i++) {
        try {
            billingService.charge(event);
            ack.acknowledge();
            return;
        } catch (Exception e) {
            Thread.sleep(60_000);
        }
    }
}

5 минут sleep × 500 records/poll = poll-цикл блокирован 2500 минут. Rebalance, дубликаты, cascade. Также @Retryable (Spring Retry) с @Backoff(delay = 30_000) — то же самое: блокирует thread.

Альтернатива — @RetryableTopic, который публикует в retry-topic и не блокирует основной listener.

Игнорирование exception

R-KFK-RTRY-X2: проглатывание.

// ПЛОХО — событие потеряно
@KafkaListener(...)
public void handle(...) {
    try {
        process(...);
        ack.acknowledge();
    } catch (Exception e) {
        log.error("Failed to process", e);
        ack.acknowledge();
    }
}

Сценарий: payment-provider лежит. Exception → log.error → ack → событие удалено. Через час провайдер ожил, но мы не пере-обрабатываем. Charge не произошёл, заказ висит.

Корректно — пробросить exception, дать @RetryableTopic сделать своё дело. Либо явно dlqProducer.send(record, e) + ack — пометить как unrecoverable.

Retry topic без max-attempts

R-KFK-RTRY-X3: бесконечный retry = lock-step с проблемной системой.

@RetryableTopic(attempts = "999999")

Если bug в коде (NonRetryable замаскированный как Retryable) — событие болтается в retry-топиках навсегда, никто не замечает. Всегда max-attempts 3-5, дальше DLQ.

DLQ без monitoring

R-KFK-RTRY-X4: без алерта — невидимая проблема.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Blocking retry через Thread.sleep в listenerR-KFK-RTRY-X1@RetryableTopic non-blocking
try/catch + log.error + ackR-KFK-RTRY-X2throw + retry-topic либо DLQ
Retry topic с attempts > 10R-KFK-RTRY-X33-5 attempts → DLQ
DLQ без алертаR-KFK-RTRY-X4size alert обязателен
Retry на 4xx/validationR-KFK-RTRY-2NonRetryable → DLQ
Auto-replay из DLQR-KFK-RTRY-4manual review через admin-API
autoCreateTopics = true в продеR-KFK-RTRY-1explicit topic creation
Один retry-topic для всех событий сервисаR-KFK-RTRY-1per-listener retry topics

Куда дальше

  • Kafka → раздел 5. Retry topic + DLQ — нормативные формулировки.
  • Consumer — почему blocking retry ломает poll-цикл.
  • Idempotent consumer — DLQ replay = дубль.
  • Observability — consumer lag, DLQ size alerts.
  • Resilience → retry — retry только при идемпотентности.
  • Error handling — Retryable vs NonRetryable иерархия.