Привет! Меня зовут Дмитрий Михеев, я ведущий разработчик в MAGNIT OMNI – бизнес-группе ритейлера «Магнит», которая отвечает за развитие омниканального опыта для клиентов.
В своих сервисах для межсервисных коммуникаций помимо gRPC-запросов мы используем брокер сообщений Kafka. Если описывать его в двух словах, Kafka — это распределённый журнал событий (event log), через который сервисы обмениваются данными в реальном времени.
Не буду подробно останавливаться на устройстве Kafka — это хорошо описано в документации. В этой статье хочу подсветить один неочевидный момент, который может привести к проблемам при работе с consumer’ами — повторную обработку сообщений (retry).
Проблема
Мне этот кейс встретился в сервисе-адаптере, который принимал события из Kafka (например, создание заказа), обрабатывал сообщение и отправлял запросы во внешние API партнёров.
Во время обработки могли возникать временные ошибки:
кратковременная недоступность внешнего сервиса;
сетевые сбои;
временные ошибки базы данных;
ошибки авторизации из-за истёкшего токена.
В моём случае проблема была связана именно с токеном авторизации. Логика была примерно такой:
токен хранится в базе;
если срок его действия ещё не истёк — используем его;
если истёк — запрашиваем новый у партнёра и сохраняем в БД.
Но мог возникнуть неприятный сценарий:
на момент чтения из базы токен ещё валиден;
пока доходим до внешнего API — токен уже истёк;
время на нашей стороне и у партнёра может немного расходиться.
В результате сообщение полностью валидное, но обработка падает из-за временной ошибки.
Очевидно, отменять заказ из-за кратковременного сетевого сбоя или истёкшего токена не хочется, поэтому такое сообщение логично попробовать обработать повторно.
Интуитивное решение, которое не работает
Первой идеей было не коммитить offset и повторно прочитать это же сообщение. Интуитивно звучит разумно. Но в Kafka это не работает как retry-механизм.
Почему?
Важно понимать: Kafka consumer коммитит не «сообщение», а offset — позицию в журнале.
При этом у consumer есть две разные позиции:
Current position — текущая позиция чтения, которую consumer локально держит в памяти.
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.
Схема получилась простой:

получили сообщение;
обработали;
если transient ошибка
а. локальный retry (несколько попыток)
успех или ошибка → 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-механику нужно проектировать отдельно.
