Опирается на правила:
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.@RetryableTopicSpring 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:
- Listener бросает
RetryableException. - Spring публикует event в
orders.confirmed.retry-1mс задержкой 60 секунд. - Через минуту другой listener потребляет retry-topic, обрабатывает.
- Если опять exception —
orders.confirmed.retry-10m. - И так до 4-х попыток.
- После исчерпания —
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 в listener | R-KFK-RTRY-X1 | @RetryableTopic non-blocking |
try/catch + log.error + ack | R-KFK-RTRY-X2 | throw + retry-topic либо DLQ |
| Retry topic с attempts > 10 | R-KFK-RTRY-X3 | 3-5 attempts → DLQ |
| DLQ без алерта | R-KFK-RTRY-X4 | size alert обязателен |
| Retry на 4xx/validation | R-KFK-RTRY-2 | NonRetryable → DLQ |
| Auto-replay из DLQ | R-KFK-RTRY-4 | manual review через admin-API |
autoCreateTopics = true в проде | R-KFK-RTRY-1 | explicit topic creation |
| Один retry-topic для всех событий сервиса | R-KFK-RTRY-1 | per-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 иерархия.