Привет! Меня зовут Дмитрий Михеев, я ведущий разработчик в MAGNIT OMNI – бизнес-группе ритейлера «Магнит», которая отвечает за развитие омниканального опыта для клиентов.

В своих сервисах для межсервисных коммуникаций помимо gRPC-запросов мы используем брокер сообщений Kafka. Если описывать его в двух словах, Kafka — это распределённый журнал событий (event log), через который сервисы обмениваются данными в реальном времени.

Не буду подробно останавливаться на устройстве Kafka — это хорошо описано в документации. В этой статье хочу подсветить один неочевидный момент, который может привести к проблемам при работе с consumer’ами — повторную обработку сообщений (retry).

Проблема

Мне этот кейс встретился в сервисе-адаптере, который принимал события из Kafka (например, создание заказа), обрабатывал сообщение и отправлял запросы во внешние API партнёров.

Во время обработки могли возникать временные ошибки:

  • кратковременная недоступность внешнего сервиса;

  • сетевые сбои;

  • временные ошибки базы данных;

  • ошибки авторизации из-за истёкшего токена.

В моём случае проблема была связана именно с токеном авторизации. Логика была примерно такой:

  • токен хранится в базе;

  • если срок его действия ещё не истёк — используем его;

  • если истёк —  запрашиваем новый у партнёра и сохраняем в БД.

Но мог возникнуть неприятный сценарий:

  • на момент чтения из базы токен ещё валиден;

  • пока доходим до внешнего API — токен уже истёк;

  • время на нашей стороне и у партнёра может немного расходиться.

В результате сообщение полностью валидное, но обработка падает из-за временной ошибки.

Очевидно, отменять заказ из-за кратковременного сетевого сбоя или истёкшего токена не хочется, поэтому такое сообщение логично попробовать обработать повторно.

Интуитивное решение, которое не работает

Первой идеей было не коммитить offset и повторно прочитать это же сообщение. Интуитивно звучит разумно. Но в Kafka это не работает как retry-механизм.

Почему?

Важно понимать: Kafka consumer коммитит не «сообщение», а offset — позицию в журнале.

При этом у consumer есть две разные позиции:

  1. Current position — текущая позиция чтения, которую consumer локально держит в памяти.

  2. Committed offset — сохранённая позиция в Kafka, с которой consumer начнёт читать после restart или rebalance.

Важный нюанс: даже если сообщение не закоммитить, consumer обычно всё равно сдвигает свою текущую локальную позицию чтения. Это значит, что следующий fetch, как правило, прочитает уже следующее сообщение, а не текущее повторно. Именно поэтому отсутствие commit само по себе не даёт повторной обработки сообщения.

Иными словами, Kafka не предоставляет встроенного retry-механизма для отдельных сообщений «из коробки», и эту логику должен реализовывать сам разработчик.

Как решили проблему

На данном этапе решили выбрать самый простой вариант:

  • без retry topics;

  • без DLQ;

  • без отдельного delayed retry pipeline.

Повторная обработка выполняется локально на уровне кода обработчика. Для этого использовали пакет:

github.com/avast/retry-go/v4

У него уже есть всё необходимое для bounded retry (Bounded Retry = ограниченное количество повторных попыток для временных ошибок):

  • количество попыток;

  • backoff; (задержка между повторными попытками).

  • jitter; (случайное отклонение задержки между ретраями)  

  • retry только на transient ошибках;

  • исключение unrecoverable ошибок.

Нужно лишь описать, на каких ошибках допустим retry.

Схема получилась простой:

  1. получили сообщение;

  2. обработали;

  3. если transient ошибка

    а. локальный retry (несколько попыток)

  4. успех или ошибка → commit offset

Почему этого оказалось достаточно

Для нашего сценария это хорошо подходит, потому что ошибки были временные:

  • истекший токен;

  • кратковременные сетевые ошибки;

  • временная недоступность внешнего API.

То есть это именно transient failures, для которых локальный bounded retry — хороший инструмент.

Важно только не уходить в бесконечные повторы и использовать ограниченное число попыток с backoff.

Почему не пересоздание consumer session

Встречал и другой подход: при ошибке пересоздавать consumer session. Тогда consumer начинает читать с последнего committed offset, и сообщение действительно может прийти повторно. Технически это работает, но я бы рассматривал это скорее как аварийный fallback, а не полноценный retry-паттерн.

Почему это плохой основной подход:

  • rebalance-механизм используется не по назначению;

  • дорого по накладным расходам;

  • может тормозить consumer group;

  • может приводить к повторной обработке диапазона сообщений, а не одного события.

Поэтому как production-grade стратегия такой подход выглядит спорно.

Важный нюанс — идемпотентность

Любой retry безопасен только если обработчик идемпотентен либо побочные эффекты происходят только после успешного завершения операции.

Иначе повторная обработка может привести к:

  • дублирующим операциям;

  • повторным вызовам внешнего API;

  • двойному созданию сущностей;

  • финансовым ошибкам.

Retry без идемпотентности — обычно плохая идея.

Итог

Для нашего кейса простой локальный retry оказался достаточным решением.

Пока нет необходимости усложнять систему retry topics и DLQ, bounded retry на уровне consumer-кода хорошо закрывает transient ошибки.

Главный вывод: в Kafka повторная обработка сообщений не появляется автоматически из-за отсутствия commit. Offset management и retry — это разные вещи, и retry-механику нужно проектировать отдельно.