Опирается на правила: R-KFK-RTRY-1 … R-KFK-RTRY-4 и R-KFK-RTRY-X1 … R-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 основной *.retry-1m через 1 мин *.retry-10m через 10 мин *.retry-1h через 1 час *.dlq разбор руками

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

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.

Таблица в базе вместо DLQ

DLQ-топик отвечает на вопрос «куда деть сообщение, которое не обработалось», но не на вопрос «что с ним делать дальше». Дальше нужно посмотреть глазами, найти по ключу заказа, повторить одно конкретное, отметить «разобрано вручную» и знать, кто и когда это сделал. У топика ничего из этого нет: сообщения лежат по offset, удалить одно нельзя, retention сотрёт его через семь дней вне зависимости от того, разобрались вы или нет, а «повтор» — это новая публикация, после которой оригинал остаётся в DLQ и путает следующего дежурного. Поэтому часть команд вместо DLQ-топика или рядом с ним держит таблицу.

Таблица лучше, когда:

  • разбор ручной и нужен UI: дежурный без доступа к брокеру видит список, ошибку, payload;
  • повтор по одному: UPDATE ... SET status = 'retry' WHERE id = ?, а не пересылка топика целиком;
  • нужен аудит: resolved_by, resolved_at, комментарий — и запись живёт до решения, а не до retention;
  • отказ надо связать с доменными данными в одной транзакции: сообщение в таблицу и заказ в статус needs_attention атомарно, до ack (см. idempotent consumer).

Таблица хуже, когда:

  • объём: отказ downstream на час отправляет в DLQ десятки тысяч сообщений — для топика это штатный сценарий, для таблицы горячая точка записи, раздутый индекс и autovacuum, который не успевает;
  • порядок: топик хранит сообщения по ключу в порядке партиции; при повторе из таблицы порядок восстанавливают сами — ORDER BY partition, "offset" и по одному ключу за раз;
  • изоляция от брокера: если упала база, писать «в таблицу отказов» некуда — а отказ базы и есть частая причина попадания в DLQ. Топик живёт на брокере, который уже является транспортом.

Отсюда рабочая схема: DLQ-топик остаётся конечной точкой retry (брокер, объём, порядок), а таблицу наполняет отдельный небольшой потребитель DLQ — для разбора, UI и аудита. Запись идемпотентна по (topic, partition, "offset"): повторная доставка после ребаланса не создаёт дубль.

create table kafka_failed_event (
    id            bigint generated always as identity primary key,
    topic         text        not null,
    partition     int         not null,
    "offset"      bigint      not null,
    message_key   text,
    payload       jsonb       not null,
    error_class   text        not null,
    error_message text        not null,
    status        text        not null default 'new',
    received_at   timestamptz not null default now(),
    resolved_at   timestamptz,
    resolved_by   text,
    unique (topic, partition, "offset")
);

R-KFK-RTRY-4 при этом не меняется: replay — ручная операция, теперь по одной строке.

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

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

Куда дальше