Poison, DLQ, retry и идемпотентный consumer

О лекции

Poison message бесконечно валит воркера: в Kafka стопорит партицию на одном offset, в Rabbit крутится requeue. Retry с backoff и лимитом, затем DLQ. Консьюмер без идемпотентности удваивает деньги на каждом ретрае.

Надёжная доставка kafka rabbitmq собеседование

At-least-once есть почти в любой честной схеме. Kafka после rebalance. Rabbit после nack. HTTP-клиент после таймаута, когда сервер на самом деле уже записал. Сообщение могут доставить дважды.

Надёжная доставка состоит из двух половин. Первая — не потерять сообщение: ретраи, DLQ, алерты. Вторая — не применить побочный эффект дважды: inbox, идемпотентный ключ оплаты.

Poison — сообщение, которое всегда падает: сломанная схема, вечный FK, 400 от платёжки навсегда. Его нельзя лечить бесконечным retry. Transient — таймаут, 503, дедлок: повтор имеет смысл. Пока вы не развели эти классы, «надёжный консьюмер» неотличим от инцидента.

Пример. В payload нет обязательного поля order_id. Воркер каждый раз падает на разборе JSON. Это poison. Таймаут платёжки на 2 секунды — transient: имеет смысл подождать и попробовать ещё раз.

Как выглядит poison

В Kafka один плохой offset без пропуска стопорит партицию. Консьюмер падает, рестартует, снова падает. Лаг растёт. Соседние ключи в этой партиции не двигаются.

В Rabbit poison с requeue не стопорит очередь целиком так же явно. Очередь «живая», воркеры крутят одно и то же, бизнес стоит, CPU горит.

Retry с лимитом и DLQ

Retry: экспоненциальный backoff, jitter, конечное N. Без jitter все воркеры бьют зависимость в одной фазе.

В Rabbit hop через TTL и DLX, delayed plugin или отдельные retry-очереди с растущим TTL. В Kafka — retry-топик с timestamp в заголовке или пауза в консьюмере. Осторожно: долгая пауза ломает poll и вызывает rebalance.

После N попыток — мёртвая очередь или мёртвый топик плюс алерт человеку. Не молчаливый drop и не бесконечный круг. Replay из DLQ — ручной, после фикса бага, тем же идемпотентным консьюмером. DLQ без мониторинга — свалка, о которой вспомнят, когда сверка разойдётся.

Пример. Три неуспешных ретрая с паузами 1 с, 4 с, 16 с. Дальше сообщение в DLQ и алерт в чат. Человек чинит схему, потом руками возвращает пачку в основную очередь. Консьюмер по event_id не создаёт второй заказ.

func handle(ctx context.Context, ev Event) error {
	inserted, err := inbox.InsertOnce(ctx, ev.ID)
	if err != nil {
		return err
	}
	if !inserted {
		return nil // дубль: ack/commit без повторной оплаты
	}
	if err := charge(ctx, ev); err != nil {
		return err // transient: пусть брокер ретраит
	}
	return nil
}

Inbox с уникальным event_id: повтор at-least-once не вызывает вторую оплату.

Тонкое место в примере: если inbox записали, а charge упал, простой «уже есть event_id → skip» проглотит оплату навсегда. Inbox должен хранить статус: принят, оплачен, ошибка. Или charge должен быть идемпотентен на стороне платёжки тем же ключом. Иначе вы лечите дубль и создаёте дыру.

Идемпотентность — business id, не offset

Ключ идемпотентности — стабильный event_id или id агрегата. Не номер партиции и не offset. Offset меняется при reprocess, при новой группе, при ручном replay из DLQ.

Inbox-таблица с UNIQUE(event_id). Повтор — «уже обработано». Побочные эффекты наружу — Idempotency-Key на HTTP оплаты. Outbox и inbox — пара: одна сторона не теряет факт, другая не применяет его дважды.

Типичная ошибка

Бесконечный retry без DLQ назвать «надёжностью». В Kafka это стоп партиции, в Rabbit — горячий requeue poison. Идемпотентность по offset: ручной replay создаст второй эффект. Inbox «вставил и забыл» без статуса, когда побочный эффект после insert ещё не выполнен.

На собеседовании

Разведите poison и временный сбой. Цепочка: backoff с jitter, лимит N, DLQ плюс алерт, идемпотентный consumer по event_id. Kafka: не крутите один плохой offset вечно. Rabbit: не requeue poison без счётчика.