20. Очереди и брокеры
«Лог — это, пожалуй, простейшая из возможных абстракций хранения данных: неизменяемая, упорядоченная последовательность записей».
— Джей Крепс
Архитектура Apache Kafka: Топики, Партиции и Группы Потребителей
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
subgraph Producers["Продюсеры (Go Services)"]
P1["Producer 1 (Key: User-123)"]:::entry
P2["Producer 2 (Key: User-456)"]:::entry
end
subgraph KafkaTopic["Kafka Topic: orders (3 Partitions)"]
Part0["Partition 0: [0][1][2][3]..."]:::accent
Part1["Partition 1: [0][1][2]..."]:::accent
Part2["Partition 2: [0][1][2][3][4]..."]:::accent
end
subgraph Consumers["Consumer Group: order-processors"]
C1["Consumer Worker A (Партиция 0)"]:::process
C2["Consumer Worker B (Партиция 1)"]:::process
C3["Consumer Worker C (Партиция 2)"]:::process
end
P1 -->|"hash(key) % 3"| Part0
P2 -->|"hash(key) % 3"| Part2
Part0 --> C1
Part1 --> C2
Part2 --> C3
C1 --> Done["Коммит смещения (Commit Offset)"]:::success
Модели семантики доставки сообщений
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
subgraph AtMostOnce["At-Most-Once (Максимум один раз)"]
AMO1["1. Чтение сообщения"]:::process --> AMO2["2. Немедленный коммит Offset"]:::warning
AMO2 --> AMO3["3. Бизнес-обработка"]:::process
AMO3 -->|"Сбой воркера"| AMOFail["Потеря сообщения навсегда!"]:::error
end
subgraph AtLeastOnce["At-Least-Once (Минимум один раз — Дефолт)"]
ALO1["1. Чтение сообщения"]:::process --> ALO2["2. Бизнес-обработка"]:::process
ALO2 --> ALO3["3. Коммит Offset в брокер"]:::success
ALO2 -->|"Сбой воркера до коммита"| ALOFail["Повторная доставка (Duplicate Message)"]:::warning
end
subgraph ExactlyOnce["Exactly-Once Processing (Ровно один раз)"]
EO1["At-Least-Once доставка"]:::process --> EO2["Идемпотентный консьюмер (БД транзакция / Inbox паттерн)"]:::accent
EO2 --> EOSuccess["Эффект ровно одного применения"]:::success
end
Навигатор по вопросам главы
- Что такое брокер сообщений (message broker)?
- Что такое Kafka?
- Что такое RabbitMQ?
- Что такое как минимум одна доставка (at least once)?
- Что такое ровно одна доставка (exactly once)?
- Что такое партиция (partition)?
- Что такое consumer group?
- Как работает коммит offset'а в Kafka?
- Как гарантировать порядок сообщений?
- Как обрабатывать дубликаты (идемпотентность)?
- Что такое backpressure?
- Как работает
sarama(Go клиент для Kafka)? - Как работает
confluent-kafka-go? - Как работает
amqpдля RabbitMQ? - Что такое dead letter queue (DLQ)?
- Как реализовать retry pattern?
- Что такое message deduplication?
- Как масштабировать консьюмеров?
- Как работает перебалансировка (rebalancing)?
- Как реализовать producer с batching?
- Как реализовать consumer с graceful shutdown?
- Как работать с
NATSилиNATS JetStream? - Что такое "log compaction" в Kafka?
- Как работает
schema registryи зачем он нужен? - Как тестировать интеграцию с брокерами?
- Как реализовать паттерн "transactional outbox"?
- Как реализовать "idempotent consumer" с помощью БД?
- Как обрабатывать "poison pill" сообщения?
- Как Kafka Streams и KSQL соотносятся с Go-сервисами?
- Как реализовать "saga" паттерн с помощью брокера?
- Как работать с
google pub/subв Go? - Как реализовать "pull-based" vs "push-based" консьюмер?
- Что такое "watermark" в стриминговых системах?
- Как работает
segmentio/kafka-goи его преимущества? - Как устроены "offsets" и "consumer lag" с точки зрения мониторинга?
- Как работает "leader election" в Kafka и как это влияет на продюсеров и консьюмеров?
- Как реализовать "backpressure" с помощью каналов в Go для обработки потока сообщений?
- Как устроены "топики" (exchanges) в RabbitMQ (direct, fanout, topic, headers)?
- Как работает "подтверждение публикации" (publisher confirms) в RabbitMQ?
- Что такое "QoS" (prefetch count) в RabbitMQ и как его настроить?
- Как обрабатывать "network partition" в RabbitMQ кластере?
- Как работает "retention policy" в брокерах сообщений (Kafka, Pulsar)?
- Что такое "Pulsar" и чем он отличается от Kafka?
- Как работает "async producer" и "sync producer" с точки зрения производительности?
- Как бороться с "head-of-line blocking" на стороне консьюмера?
- Что такое "choreography" vs "orchestration" в контексте очередей и саг?
- Как использовать "Redis Streams" в качестве легковесного брокера сообщений?
- Как реализовать "fan-out" на уровне брокера (Kafka topic -> multiple consumer groups)?
- Как управлять "serialization format" (Avro, Protobuf, JSON) в сообщениях?
- Как обеспечить "end-to-end latency" мониторинг для сообщений?
Что такое брокер сообщений (message broker)?
Краткий ответ (30–60 секунд)
Брокер сообщений — это промежуточное связующее ПО, которое принимает сообщения от продюсеров, маршрутизирует их и доставляет консьюмерам, обеспечивая асинхронную, надёжную и слабосвязанную коммуникацию между сервисами. Он гарантирует сохранность сообщений при сбоях, позволяет буферизовать нагрузку и реализовывать различные паттерны доставки: точка-точка, publish/subscribe, запрос-ответ. Основные представители: Kafka (распределённый лог), RabbitMQ (очереди на AMQP), NATS, Pulsar, облачные Pub/Sub решения.
Развёрнутый ответ
Брокер выступает посредником, изолируя отправителя от получателя. Продюсер публикует сообщение в логическую сущность (топик/обменник/канал), не зная, кто и когда его обработает. Брокер принимает сообщение, сохраняет на диск (или в память) и доставляет подписчикам согласно семантике доставки. Ключевые возможности:
- Маршрутизация: на основе ключа, заголовков, паттернов (direct, topic, fanout в RabbitMQ; ключ партиции в Kafka).
- Гарантии доставки: at-most-once, at-least-once, exactly-once (с оговорками).
- Упорядочение: внутри партиции/очереди порядок обычно сохраняется.
- Масштабирование: партиционирование, группы потребителей.
- Надёжность: репликация данных, подтверждения записи/чтения, политики хранения.
В Go для работы с брокерами используют специализированные клиенты (sarama, confluent-kafka-go, segmentio/kafka-go, amqp091-go), которые управляют соединениями, rebalance, retry, heartbeats. Понимание внутренней модели брокера критически важно для построения высоконагруженных систем.
Под капотом (internals)
Внутренне большинство брокеров реализуют хранилище в виде сегментированного лога (Kafka) или очередей на базе индексов и файлов (RabbitMQ использует message store и queue index). Kafka: каждая партиция — это последовательность сегментов на диске, запись всегда в конец, чтение последовательное, что даёт высокую пропускную способность благодаря page cache ОС. Offset — позиция сообщения в партиции. RabbitMQ: сообщения хранятся в очередях, которые могут быть долговременными или transient; используется Mnesia для метаданных кластера. Сетевой протокол: AMQP 0-9-1 (RabbitMQ), собственный протокол на TCP (Kafka). Современные Go-клиенты используют высокопроизводительный event loop с системным вызовом epoll/kqueue (например, confluent-kafka-go на базе librdkafka), минимизируя аллокации и копирование данных.
Типичные ошибки кандидатов
-
Смешивание моделей брокеров
Кандидаты применяют паттерны RabbitMQ (например, direct exchange с routing key) к Kafka, не учитывая, что Kafka — это распределённый лог, а не очередь сообщений с гибкой маршрутизацией. Это приводит к неверному проектированию топиков. Исправление: всегда анализировать, нужна ли сложная маршрутизация или достаточно партиционированного лога с повторным чтением. -
Игнорирование семантики exactly-once
Заявления вроде «Kafka поддерживает exactly-once из коробки» без понимания, что это достигается через транзакции продюсера и идемпотентность, а также требует кооперации с консьюмером и транзакционных записей во внешнем хранилище. Исправление: чётко различать exactly-once на уровне семантики доставки и exactly-once в смысле дедупликации обработки; подробно изучить Kafka EOS (Exactly Once Semantics) и паттерн transactional outbox. -
Предположение о глобальном порядке в топике
Считают, что Kafka топик сохраняет порядок всех сообщений. На деле порядок гарантирован только внутри одной партиции при условии использования одного продюсера и записи без повторов. Исправление: для глобального порядка использовать одну партицию (что ограничивает throughput) или ключ маршрутизации, который гарантирует попадание связанных сообщений в одну партицию.
Возможные дополнительные вопросы интервьюера
-
Чем Kafka принципиально отличается от RabbitMQ на уровне модели данных?
Kafka — это распределённый commit log, где сообщения сохраняются даже после обработки всеми консьюмерами, поддерживает повторное чтение и временной сдвиг offset; RabbitMQ — традиционный брокер очередей, удаляющий сообщение после подтверждения. Это определяет разные варианты использования: потоковая обработка и event sourcing против RPC-подобной доставки задач. -
Как выбрать брокер для микросервисной архитектуры?
Оцениваем требования: нужна ли буферизация больших потоков и воспроизводимость истории (Kafka), сложная маршрутизация (RabbitMQ), низкая задержка и простота (NATS), managed service в облаке (Google Pub/Sub, AWS SQS/SNS). Также важны гарантии доставки, сохранность порядка и требования к масштабированию consumer’ов.
Что такое Kafka?
Краткий ответ (30–60 секунд)
Apache Kafka — это распределённая, реплицированная, горизонтально масштабируемая система, реализующая модель распределённого commit-лога. Она оптимизирована под высокую пропускную способность и низкую задержку, используется для потоковой передачи событий, интеграции данных и построения event-driven архитектур. Ключевые компоненты: продюсеры, брокеры, топики, партиции, консьюмер-группы и ZooKeeper/Raft-метаданные (KRaft).
Развёрнутый ответ
Kafka работает как распределённый лог: каждое сообщение добавляется в конец определённой партиции и идентифицируется монотонно возрастающим offset’ом. Брокеры объединяются в кластер; каждый топик состоит из нескольких партиций, распределённых по брокерам. Для отказоустойчивости партиции реплицируются (leader/follower модель). Продюсеры записывают сообщения в лидера партиции; консьюмеры читают сообщения с лидера (или с реплик, если настроено). Консьюмер-группы обеспечивают балансировку: одна партиция обрабатывается только одним членом группы в каждый момент. Хранение данных на диске и последовательное чтение дают пропускную способность порядка миллионов сообщений в секунду.
Под капотом (internals)
- Storage: партиция — это набор файлов-сегментов (.log и .index). Запись всегда аппенд; удаление происходит по политике удержания (время/размер) или через log compaction. Благодаря активному использованию page cache и sendfile() система избегает копирования данных в пользовательское пространство.
- Протокол: Kafka wire protocol — бинарный протокол поверх TCP; запросы и ответы сериализуются в компактный формат. Сообщения содержат заголовок с ключом, значением, временной меткой и опциональными кастомными заголовками.
- EOS: идемпотентный продюсер использует PID (producer ID) и sequence number для дедупликации. Транзакции используют координатор транзакций и маркеры (commit/abort).
- Metadata: начиная с версии 2.8 постепенно убирается ZooKeeper, вводится KRaft (Raft consensus), где контроллер и метаданные хранятся внутри брокеров.
Типичные ошибки кандидатов
-
Неправильное количество партиций
Часто выбирают слишком мало партиций, что ограничивает параллелизм консьюмеров (один консьюмер на партицию), или слишком много, что увеличивает накладные расходы выборов лидера и метаданных. Оптимальное число определяется требуемым throughput и количеством консьюмеров, но следует помнить про ограничения на число партиций на брокер (обычно 2000–4000 на один процесс). -
Использование Kafka как очереди задач
Пытаются реализовать per-message acknowledgment и удаление отдельных сообщений, что противоречит архитектуре лога. Kafka не позволяет удалить обработанное сообщение без нарушения структуры партиции. Вместо этого следует использовать offset коммит и перечитывать с определённого смещения. -
Пренебрежение сериализацией ключа
Если не задавать ключ сообщения при записи в партиционированный топик, распределение будет round-robin, что ломает порядок связанных событий и может привести к перекрёстному упорядочению. Важно всегда назначать осмысленный ключ для обеспечения когерентности данных в рамках партиции.
Возможные дополнительные вопросы интервьюера
-
Как работает репликация в Kafka и что такое ISR?
Лидер обслуживает все записи и чтения; фолловеры подтягивают записи и поддерживают in-sync replica list (ISR). Сообщение считается закоммиченным, когда все реплики из ISR подтвердили получение. Если фолловер отстаёт, он исключается из ISR. Ack-настройки продюсера (acks=all) требуют подтверждения от всего ISR. -
Почему Kafka достигает высокой пропускной способности?
Последовательный ввод-вывод, нулевое копирование (sendfile) при передаче данных от диска к сети, пакетная обработка, эффективное сжатие (gzip, snappy, lz4, zstd), разделение партиций и отсутствие жёсткой координации между консьюмерами.
Что такое RabbitMQ?
Краткий ответ (30–60 секунд)
RabbitMQ — это популярный брокер сообщений с открытым исходным кодом, реализующий протокол AMQP 0-9-1 и множество расширений. Он основан на модели обменников (exchanges) и очередей, гибкой маршрутизации и подтверждений доставки. Идеален для сложной маршрутизации, RPC, уведомлений в реальном времени и сценариев, где требуется тонкая настройка поведения сообщений (TTL, DLX, приоритеты).
Развёрнутый ответ
Центральными концепциями являются:
- Producer отправляет сообщение в exchange с routing key.
- Exchange маршрутизирует сообщение в одну или несколько очередей на основе типа (direct, topic, fanout, headers) и привязок (bindings).
- Queue хранит сообщения, пока их не получит consumer.
- Consumer подписывается на очередь и потребляет сообщения.
RabbitMQ предоставляет богатые гарантии доставки: подтверждения публикации (publisher confirms), подтверждения потребителя (consumer acknowledgements), транзакции. Благодаря динамическому созданию очередей и обменников подходит для сценариев с переменным числом подписчиков.
Под капотом (internals)
- Хранилище: использует собственную базу данных сообщений (message store) с поддержкой сохранения на диск или хранения в памяти. Очереди индексируются для быстрого доступа. При потреблении сообщение удаляется из очереди после ack.
- Кластеризация: RabbitMQ поддерживает кластеры с общей репликацией очередей (mirrored queues в классическом варианте, quorum queues на основе Raft для современных версий). Метаданные обменников, очередей и привязок реплицируются на все узлы.
- Протокол: AMQP 0-9-1 — симметричный, бинарный; управление состоянием открывается через каналы внутри TCP-соединения. Потоковый контроль и QoS (prefetch) реализованы на уровне канала.
- Эрлангова природа: RabbitMQ написан на Erlang, использует легковесные процессы и модель акторов, что обеспечивает высокую конкурентность и надёжность.
Типичные ошибки кандидатов
-
Сравнение очереди Kafka и очереди RabbitMQ как эквивалентов
Считают, что Kafka топик — это очередь. В RabbitMQ очередь работает как push-модель и удаляет сообщение после ack; Kafka же сохраняет сообщения и позволяет перечитывать их разными группами независимо. Эта ошибка приводит к выбору неверного инструмента. -
Отсутствие контроля за длиной очереди
Неконтролируемый рост очереди при медленных консьюмерах может привести к исчерпанию памяти или диска и краху узла. Необходимо настраивать max-length, TTL, dead-lettering для предотвращения переполнения. -
Игнорирование publisher confirms
Публикация без confirms означает риск потери сообщений при сбоях сети или брокера. В критичных системах обязателен режим подтверждения и повторная отправка при неудаче.
Возможные дополнительные вопросы интервьюера
-
Чем quorum queue отличается от классической mirrored queue?
Quorum queue основана на алгоритме Raft, обеспечивает более сильную согласованность и автоматическое восстановление после сбоев без потери данных, жертвуя при этом небольшой производительностью на запись. -
Как организовать приоритетную обработку в RabbitMQ?
Добавить аргумент x-max-priority к очереди, а в сообщениях указывать приоритет; брокер будет доставлять сообщения с более высоким приоритетом в первую очередь, однако это увеличивает накладные расходы и не гарантирует строгого порядка при равных приоритетах.
Что такое как минимум одна доставка (at least once)?
Краткий ответ (30–60 секунд)
Семантика at-least-once гарантирует, что каждое сообщение будет доставлено консьюмеру один или более раз, но ни одно сообщение не будет потеряно. Это достигается за счёт повторных отправок со стороны продюсера при отсутствии подтверждения и переобработки консьюмером, если подтверждение не было зафиксировано брокером. Плата за это — возможные дубликаты, требующие идемпотентной обработки на стороне потребителя.
Развёрнутый ответ
At-least-once — самый распространённый и надёжный режим доставки. В Kafka он реализуется через:
- Продюсер:
acks=1илиacks=all+ retries. - Консьюмер: читает сообщения, обрабатывает, коммитит offset после обработки. Если обработка прошла, но коммит упал, после перебалансировки сообщение будет прочитано повторно.
В RabbitMQ: publisher confirm + consumer ack после обработки. Если ack не получен, сообщение возвращается в очередь и может быть доставлено заново (requeue).
Для Go-сервисов необходимо всегда проектировать обработчики с учётом возможного повторения. Сочетание at-least-once с идемпотентностью даёт эффективную exactly-once обработку.
Под капотом (internals)
Внутренне Kafka брокер хранит offset коммиты для каждой consumer group в специальном внутреннем топике __consumer_offsets. Консьюмер отправляет коммит, записывающий смещение, до которого обработаны сообщения. При перезапуске консьюмер запрашивает последний коммит. Если коммита не было или он был для более раннего смещения, сообщения перечитываются с прошлого закоммиченного offset. RabbitMQ хранит информацию о неподтверждённых сообщениях на узле очереди; при обрыве соединения или канала они помечаются как ready для повторной доставки.
Типичные ошибки кандидатов
-
Коммит offset до обработки
Если сначала коммитить, а потом обрабатывать, то при падении обработки сообщение будет потеряно (at-most-once). Обратная ситуация даёт at-least-once. Правильно: сначала завершить обработку и зафиксировать результат во внешнем хранилище, потом коммитить. Если порядок не важен, допустим async коммит после обработки. -
Отсутствие идемпотентности
Полагаться на «авось не будет дубликатов». Даже при exactly-once от брокера могут возникать дубли на уровне сети или ретраев. Идемпотентность (уникальные ключи сообщений, условная вставка в БД) — обязательный паттерн.
Возможные дополнительные вопросы интервьюера
-
Можно ли с помощью at-least-once получить exactly-once обработку?
Да, если потребитель идемпотентен, а операция сохранения результата и коммит offset происходят атомарно (например, в одной транзакции БД, где offset записывается вместе с бизнес-данными). -
Чем at-least-once отличается от exactly-once в Kafka?
Exactly-once в Kafka дополнительно гарантирует, что сообщение не будет дублироваться в рамках транзакционной записи продюсера и идемпотентных чтений/записи в Kafka Streams, но это требует транзакционного продюсера и isolation level read_committed для консьюмера.
Что такое ровно одна доставка (exactly once)?
Краткий ответ (30–60 секунд)
Exactly-once — семантика, при которой каждое сообщение обрабатывается конечным потребителем ровно один раз, без дублирования и без потерь. В распределённых системах это сложная задача; она решается комбинацией идемпотентной обработки, транзакций и атомарных записей offset-ов вместе с результатами. Важно различать exactly-once delivery (практически невозможно в условиях сбоев сети) и exactly-once processing (достижимо).
Развёрнутый ответ
Реальная exactly-once обработка подразумевает идемпотентность операций: дубликат сообщения даёт тот же результат, что и первая обработка. В Kafka для этого используется:
- Идемпотентный продюсер (enable.idempotence=true): гарантирует, что сообщение будет записано в лог ровно один раз в рамках сессии продюсера, присваивая ему PID и sequence number.
- Транзакционные записи: атомарная публикация в несколько топиков и коммит offset для consumer’а в одной транзакции.
- Isolation level
read_committed: консьюмер видит только закоммиченные транзакционные сообщения.
В Go, используя segmentio/kafka-go или sarama, можно реализовать транзакционный outbox: в одной транзакции БД пишем бизнес-сущность и событие, затем асинхронно отправляем событие и коммитим offset.
Под капотом (internals)
Внутренности Kafka: продюсер при идемпотентности отправляет вместе с сообщением PID (producer ID) и sequence number. Брокер отслеживает последние номера для каждого PID и отбрасывает дубликаты с меньшим или равным номером. Транзакции координируются через координатор транзакций, который добавляет маркеры commit/abort в лог. Консьюмеры с read_committed пропускают сообщения, помеченные как незакоммиченные или отменённые. Эта инфраструктура требует дополнительной задержки и ресурсов, но даёт exactly-once семантику для потоковой обработки «из Kafka в Kafka».
Типичные ошибки кандидатов
-
Утверждение, что Kafka гарантирует exactly-once delivery между независимыми системами
Это неверно. Kafka Streams достигает exactly-once только в границах своего кластера и при использовании поддерживаемых коннекторов. При интеграции с внешними БД/сервисами exactly-once требует двухфазного коммита или идемпотентности на стороне получателя. -
Неправильное использование транзакций
Создают транзакционного продюсера, но не задают transactional.id или не используют уникальный идентификатор, что приводит к невозможности корректного восстановления после сбоев и дублирования. Идентификатор должен быть стабильным и уникальным для экземпляра приложения. -
Пренебрежение таймаутами транзакции
Слишком долгое удержание транзакции открытой приводит к таймауту координатора (transaction.timeout.ms) и отмене транзакции, что может нарушить целостность данных.
Возможные дополнительные вопросы интервьюера
-
Объясните, как реализовать exactly-once при записи в PostgreSQL из Kafka-потребителя.
Используем таблицу consumed_offsets и бизнес-таблицу в одной транзакции: вставляем/обновляем бизнес-данные и записываем (topic, partition, offset) с уникальным constraint. При повторе сообщения вставка offset упадёт по constraint violation, что откатит бизнес-операцию, обеспечивая идемпотентность. -
В чём разница между exactly-once processing и exactly-once delivery?
Delivery гарантирует, что сообщение физически доставлено ровно один раз, что невозможно в условиях сбоев (Two Generals Problem). Processing достигается через дедупликацию и идемпотентность потребителя, так что результат эквивалентен exactly-once.
Что такое партиция (partition)?
Краткий ответ (30–60 секунд)
Партиция — это фундаментальная единица параллелизма и масштабирования в брокерах сообщений, представляющая собой упорядоченный, неизменяемый лог (Kafka) или сегмент хранения в распределённых очередях. Каждая партиция может находиться на отдельной ноде и обрабатываться независимым консьюмером, обеспечивая линейную масштабируемость. Порядок сообщений гарантируется только в пределах партиции.
Развёрнутый ответ
В Kafka топик физически разделён на партиции, каждая из которых — это отдельный commit log с собственным смещением (offset). Партиции распределяются по брокерам кластера и реплицируются. Продюсер выбирает партицию для сообщения либо явно (указывая ключ), либо по round-robin. Ключ хешируется, все сообщения с одинаковым ключом попадают в одну партицию, сохраняя порядок. Партиция обрабатывается одним консьюмером внутри consumer group, что позволяет масштабировать чтение до количества партиций.
В RabbitMQ аналогом служит шардирование очередей через плагин rabbitmq-sharding, который создаёт несколько очередей за одной логической «очередью». В Pulsar партиции присутствуют явно.
Под капотом (internals)
В Kafka партиция — это набор сегментов файлов .log (записи) и .index (смещение -> позиция в файле). Активный сегмент открыт на запись; когда он превышает максимальный размер или время, становится закрытым и может быть удалён согласно retention policy. Каждое сообщение внутри партиции нумеруется монотонно возрастающим 64-битным offset. Партиция использует page cache для сверхбыстрого чтения. При чтении консьюмер указывает offset, и брокер находит ближайший индексный файл, затем последовательно сканирует лог.
Типичные ошибки кандидатов
-
Считать, что сообщения без ключа тоже сохраняют порядок внутри партиции
При round-robin записи сообщения от одного отправителя могут чередоваться с сообщениями другого, и порядок их следования не детерминирован с точки зрения прикладной логики. Для порядка событий одного агрегата обязательно использовать ключ. -
Перераспределение партиций без учёта семантики
Увеличение числа партиций после того, как в топик уже были записаны данные с ключами, разрушает порядок: то, что раньше было в одной партиции, теперь может разделиться на две. Лучше сразу проектировать с запасом. -
Связывание количества партиций с числом машин
Партиция — это единица распределения данных и консьюмеров. Увеличение количества брокеров не увеличит параллелизм, если партиций мало. Наоборот, много партиций требует больше памяти и времени на выборы лидера.
Возможные дополнительные вопросы интервьюера
-
Как влияет размер партиции на задержку и пропускную способность?
Много маленьких партиций увеличивает накладные расходы на координацию, но улучшает параллелизм. Слишком большая одна партиция может стать узким местом по сети/диску. Оптимально десятки-сотни мегабайт в секунду на партицию. -
Можно ли читать одну партицию несколькими консьюмерами в одной группе?
Нет, Kafka гарантирует, что каждая партиция назначается только одному активному члену consumer group. Для параллельной обработки одной партиции нужно либо использовать несколько потоков внутри одного консьюмера, либо создать отдельные группы.
Что такое consumer group?
Краткий ответ (30–60 секунд)
Consumer group — это объединение консьюмеров, которые совместно потребляют сообщения из топиков, имеющих несколько партиций. Каждая партиция назначается ровно одному члену группы, что позволяет распределять нагрузку и обеспечивать масштабирование обработки. В случае отказа одного консьюмера происходит перебалансировка (rebalance), передача его партиций другим членам группы. Consumer group также идентифицирует смещение обработки (offset) для каждой партиции.
Развёрнутый ответ
В Kafka консьюмеры подключаются к группе, идентифицируемой строкой group.id. При подписке на топики координатор группы (один из брокеров) назначает партиции членам. Если число консьюмеров в группе превышает количество партиций, лишние консьюмеры простаивают. Группа гарантирует, что каждое сообщение из одной партиции будет обработано только одним консьюмером в группе, но разные группы могут независимо читать одни и те же данные — это обеспечивает pub-sub.
Потребители периодически посылают heartbeat’ы; при длительном отсутствии heartbeat запускается rebalance. Управление смещениями хранится в топике __consumer_offsets, коммит может быть автоматическим или ручным.
Под капотом (internals)
При создании группы координатор группы (group coordinator) избирает лидера группы среди консьюмеров. Лидер получает от координатора список доступных членов и назначает партиции, затем отправляет назначение координатору, который распространяет его всем членам. В процессе ребалансировки используется протокол JoinGroup/SyncGroup. Стратегии назначения партиций: Range, RoundRobin, Sticky, Cooperative Sticky (инкрементальная). Начиная с Kafka 2.4+ предпочтительна Cooperative Sticky, минимизирующая остановку обработки.
Типичные ошибки кандидатов
-
Длительная обработка без пауз для heartbeat
Если обработка сообщения занимает большеmax.poll.interval.ms, брокер исключает консьюмера из группы и запускает перебалансировку, что ведёт к дублированию и остановке. Решение: уменьшитьmax.poll.records, вынести обработку в отдельный пул воркеров, а poll-loop держать быстрым, либо настроитьsession.timeout.msи heartbeat интервал. -
Неверная стратегия коммита offset
Автокоммит (enable.auto.commit=true) коммитит смещение после получения batch, но до обработки; это вызывает at-most-once при сбое. Для at-least-once обязателен ручной коммит после успешной обработки всей партии. -
Смешивание consumer group и нескольких приложений
Если разные логические приложения используют однуgroup.id, они будут мешать друг другу при перебалансировке и позициях offset. Для каждого независимого потребителя нужна своя группа.
Возможные дополнительные вопросы интервьюера
-
Как выполнить перебалансировку без остановки консьюмеров?
Использовать Cooperative Sticky стратегию с Kafka версии 2.4+, которая позволяет постепенно передавать партиции без полной остановки консьюмера, что снижает задержки. -
Что происходит при добавлении нового консьюмера в группу?
Запускается ребалансировка: новому члену выделяются некоторые партиции, у существующих членов они отзываются. На время ребалансировки консьюмеры могут временно остановить обработку (зависит от стратегии).
Как работает коммит offset'а в Kafka?
Краткий ответ (30–60 секунд)
Коммит offset — это операция сохранения в Kafka текущей позиции чтения консьюмера для каждой партиции. Это позволяет при перезапуске или ребалансировке продолжить обработку с последнего закоммиченного смещения. Коммиты могут быть автоматическими (по таймауту) или ручными (синхронные/асинхронные), и от момента коммита зависят гарантии доставки.
Развёрнутый ответ
Offset хранится во внутреннем сжатом топике __consumer_offsets. Ключ — (group.id, topic, partition), значение — (offset, metadata, timestamp). Когда консьюмер вызывает commitSync() или commitAsync(), он отправляет специальный запрос к координатору группы, который продюсирует сообщение в этот топик.
- Автокоммит: интервал задаётся
auto.commit.interval.ms. Каждые N мс коммитится позиция, возвращённая в последнемpoll(), независимо от того, обработаны ли сообщения. Опасно для надёжности. - Ручной коммит: после обработки батча вызываем
CommitOffsets()(segmentio/kafka-go) илиconsumer.CommitMessages(ctx, msgs...)(confluent-kafka-go). Это даёт контроль над гарантиями.
Ручной коммит после обработки обеспечивает at-least-once; если коммит зафейлится, сообщения будут обработаны повторно.
Под капотом (internals)
Координатор группы получает запрос OffsetCommitRequest, проверяет, что консьюмер является владельцем партиции, и записывает смещение в __consumer_offsets с помощью обычного продюсера с сжатием. Этот топик имеет настройку cleanup.policy=compact, чтобы хранить только последний коммит для каждой группы/партиции. При recovery консьюмер посылает OffsetFetchRequest и получает последний коммит. Механизм точно такой же, как обычная запись и чтение, но изолирован.
Типичные ошибки кандидатов
-
Коммит до обработки сообщений
Приводит к потере сообщений (at-most-once). Даже если использовать ручной коммит, но вызывать его в начале обработки, крах приведёт к непоправимой потере. Исправление: переместить коммит после успешной обработки. -
Синхронный коммит внутри горячего цикла
commitSync()блокирует поток до ответа от брокера, замедляя обработку. Лучше использовать асинхронный коммит с callback для ошибок или накапливать коммиты для нескольких партиций и выполнять их раз в секунду. -
Игнорирование ошибок коммита
При асинхронном коммите ошибки могут игнорироваться, что ведёт к непредсказуемому состоянию offset. Необходимо обрабатывать исключения (например, RetriableCommitFailedException) и выполнять retry с учётом возможного изменения владельца партиции.
Возможные дополнительные вопросы интервьюера
-
Как атомарно сохранить offset вместе с результатом обработки во внешней БД?
Использовать транзакцию БД: вставить бизнес-данные и запись смещения (topic, partition, offset) в одну транзакцию. При старте консьюмер читает последний offset из БД и устанавливает позицию чтения. Коммиты в Kafka можно не использовать или делать фоновый низкоприоритетный коммит для мониторинга. -
Может ли консьюмер коммитить offset для партиции, которую он больше не владеет?
Да, если он потерял владение во время ребалансировки, но отправляет коммит с задержкой. Брокер примет такой коммит, что может привести к потере сообщений, обработанных новым владельцем. Защита — проверка generation/epoch в некоторых клиентах.
Как гарантировать порядок сообщений?
Краткий ответ (30–60 секунд)
Порядок сообщений в распределённой системе гарантируется только в пределах одной партиции (Kafka) или одной очереди (RabbitMQ) при условии однопоточного продюсера и консьюмера. Для глобального порядка все связанные события должны иметь один ключ, направляющий их в одну и ту же партицию, либо весь поток помещается в одну партицию (жертвуя масштабируемостью). Также требуется идемпотентная отправка и аккуратная обработка повторов.
Развёрнутый ответ
В Kafka поведение:
- Продюсер должен использовать ключ сообщения (например, ID агрегата). Хеш ключа определяет партицию.
- Продюсер должен иметь enable.idempotence=true, чтобы избежать дублирования и нарушения порядка из-за повторов.
- max.in.flight.requests.per.connection необходимо установить в 1 (или 5 с идемпотентностью), чтобы гарантировать порядок при отправке.
- Консьюмер в пределах одной партиции всегда читает сообщения последовательно. Обработка может быть параллельной внутри партиции только с ручным управлением порядком (например, сохранение порядка по ключу в пуле горутин, где на ключ одна горутина).
В RabbitMQ: очередь сохраняет порядок FIFO (при отсутствии приоритетов и requeue). Чтобы не нарушить порядок из-за повторной доставки, requeue должно использоваться с осторожностью. Шардированные очереди разделяют данные по ключу, аналогично партициям.
Под капотом (internals)
В основе порядка Kafka лежит порядок добавления в лог партиции и присвоение монотонного offset. При идемпотентном продюсере брокер проверяет sequence number, в случае дубликатов или нарушения порядка (gap) запись отклоняется с ошибкой, что гарантирует отсутствие пропусков и перестановок в логе. При retry без идемпотентности сообщения могут быть переупорядочены (если max.in.flight > 1). Установка max.in.flight=1 устраняет проблему ценой снижения производительности.
Типичные ошибки кандидатов
-
Использование разных ключей для логически связанных сообщений
Например, для заказа применяют ключuser_id, а неorder_id, что разбивает события заказа по разным партициям. Необходимо выбрать такой ключ, который объединяет все события, которые должны быть упорядочены относительно друг друга. -
Параллельная обработка внутри партиции без сохранения порядка
Создают несколько горутин, читающих из одной партиции, и обрабатывают конкурентно. Порядок теряется. Правильно: либо обрабатывать последовательно, либо шардировать обработку по вторичному ключу, но с учётом того, что это может нарушить причинно-следственные связи. -
Игнорирование ретраев и идемпотентности при отказе брокера
Если продюсер не идемпотентен и включает retries, он может записать сообщение дважды в лог, нарушив ожидаемую последовательность событий с одинаковым offset, что сломает семантику уникальности.
Возможные дополнительные вопросы интервьюера
-
Можно ли гарантировать глобальный порядок всех сообщений в топике?
Да, если топик состоит из одной партиции, продюсер идемпотентен и max.in.flight=1. Но пропускная способность будет ограничена одним брокером и одним консьюмером. -
Как в Go обрабатывать упорядоченные сообщения из Kafka с высокой пропускной способностью?
Реализовать канал горутин, привязанных к ключу (например, map key -> chan), сохраняя порядок для каждого ключа. Консьюмер в главном цикле читает сообщение и направляет в нужный канал, где оно обрабатывается асинхронно, но последовательно для ключа.
Как обрабатывать дубликаты (идемпотентность)?
Краткий ответ (30–60 секунд)
Дубликаты неизбежны в любой системе с гарантией at-least-once. Идемпотентность достигается присвоением каждому сообщению уникального идентификатора и проверкой его наличия в хранилище перед выполнением бизнес-логики. Это позволяет повторно обработать сообщение без изменения конечного состояния системы.
Развёрнутый ответ
Ключевой паттерн: сообщение содержит поле idempotency_key (или используем естественный ключ бизнес-операции). Потребитель при получении сообщения:
- Начинает транзакцию.
- Проверяет в таблице
processed_messages(или в Redis) наличие ключа с состоянием «обработано». - Если ключ отсутствует, выполняет бизнес-логику и вставляет запись об обработке (статус «processing» / «done»).
- Если уже есть и статус завершён, пропускает.
- Если статус «processing» (залипло из-за сбоя), применяет стратегию: повторная обработка с условным обновлением либо таймаут и откат.
В Go можно реализовать с помощью БД: INSERT ... ON CONFLICT DO NOTHING или upsert с проверкой. Например:
_, err := db.Exec(`
INSERT INTO idempotency_keys (key, status, created_at)
VALUES ($1, 'processing', now())
ON CONFLICT (key) DO UPDATE SET status = 'processing' WHERE idempotency_keys.status = 'completed'
`)
Затем выполняем бизнес-логику, обновляем статус на 'done'. Эта схема гарантирует, что даже при параллельных запросах только один пройдёт.
Под капотом (internals)
Идемпотентность на уровне БД использует уникальный индекс по ключу и условные запросы. При использовании Kafka транзакции: координатор транзакций добавляет маркеры, но идемпотентная обработка вне Kafka всё равно требуется, так как консьюмер может прочитать одно и то же сообщение повторно из-за ребалансировки до коммита offset. Fail-fast подход: в таблице processed добавляем колонку offset (topic, partition, offset) и используем уникальный constraint (topic, partition, offset). Тогда повторное сообщение вызовет конфликт, что автоматически делает операцию идемпотентной, если бизнес-операция выполнена в той же транзакции.
Типичные ошибки кандидатов
-
Использование только uuid без проверки состояния
Генерация UUID на стороне продюсера без проверки дублирования на приёмнике не помогает, если продюсер сам отправил дубликат (из-за ретраев). Нужен идентификатор, уникальный для каждой бизнес-операции, а не для попытки отправки. -
Проверка ключа в коде без транзакции
Между проверкой и вставкой может вклиниться конкурентный запрос, что приведёт к двойной обработке. Только атомарная вставка или пессимистическая блокировка гарантирует идемпотентность. -
Игнорирование состояния «processing»
Если запись застряла в статусе processing из-за падения предыдущей попытки, новый запрос должен либо повторить обработку, либо отменить прежнюю, иначе сообщение застрянет навсегда.
Возможные дополнительные вопросы интервьюера
-
Как идемпотентный потребитель работает с Kafka и PostgreSQL?
В одной транзакции INSERT в таблицу событий с уникальным constraint по (topic, partition, offset), затем бизнес-обновление. При конфликте откатываем транзакцию — идемпотентность достигнута. -
Как выбрать подходящий idempotency key для события?
Использовать комбинацию идентификатора источника события (order_id) и типа события (ORDER_CREATED). Если допустимы несколько одинаковых событий подряд, добавлять монотонную версию.
Что такое backpressure?
Краткий ответ (30–60 секунд)
Backpressure — это механизм управления потоком данных, при котором потребитель сигнализирует продюсеру или брокеру о необходимости снизить темп отправки, чтобы не перегрузить систему. В системах сообщений он проявляется через ограничение числа неподтверждённых сообщений (prefetch в RabbitMQ), заполнение каналов в Go, торможение poll-цикла или отказ от новых сообщений до обработки существующих.
Развёрнутый ответ
В RabbitMQ backpressure реализуется через channel.qos(prefetchCount). Брокер не будет отправлять потребителю больше сообщений, чем prefetchCount неподтверждённых. Если потребитель медленный, сообщения остаются в очереди, что замедляет продюсера (publisher confirms) косвенно: если лимит очереди исчерпан, подтверждение публикации задерживается.
В Kafka backpressure на стороне потребителя реализован самим приложением: метод poll() возвращает максимум max.poll.records сообщений. Пока приложение не вызовет следующий poll(), новые сообщения не приходят. Если обработка медленная, поток останавливается. Однако брокер не знает о медленном консьюмере; он продолжает принимать сообщения от продюсеров. Backpressure здесь — на уровне сетевого буфера и возможного заполнения диска, что может потребовать мониторинга.
В Go при реализации конвейера обработки сообщений backpressure естественно выражается через буферизованные каналы: если канал заполнен, горутина-читатель блокируется, замедляя темп вычитки из брокера.
Под капотом (internals)
В библиотеке segmentio/kafka-go механизм reader internally использует буфер, контролируемый QueueCapacity. Когда буфер заполнен, FetchMessage блокируется до тех пор, пока сообщения не будут считаны и закоммичены. Это предохраняет приложение от бесконтрольного потребления памяти. В confluent-kafka-go consume() блокируется, если внутренние очереди librdkafka заполнены (из-за медленной обработки callback). Внутренне librdkafka использует асинхронный event-driven подход; если приложение не успевает вызвать ReadMessage, очередь input buffer растёт, но ограничена queued.max.messages.kbytes.
Типичные ошибки кандидатов
-
Бесконечное потребление без ограничения скорости
Консьюмер читает сообщения в небуферизованный канал, а горутина-обработчик работает медленно. Это может привести к неконтролируемому росту внутренних буферов или голоданию CPU. Исправление: использовать буферизованный канал с разумной ёмкостью и блокировку чтения при заполнении, либо паттерн worker pool с фиксированным количеством воркеров. -
Отсутствие обработки backpressure со стороны продюсера
Продюсер не обрабатывает ошибки типа «очередь переполнена» (в RabbitMQ) или «превышен лимит записи» и продолжает слать, что приводит к отказу. Необходимо реализовать retry с экспоненциальной задержкой и circuit breaker. -
Игнорирование конфигурации prefetch в RabbitMQ
Значение prefetch по умолчанию может быть большим, и медленный потребитель будет получать много сообщений, забивая память. Настройка prefetch=1 обеспечивает равномерное распределение и наименьшую задержку для отдельных сообщений.
Возможные дополнительные вопросы интервьюера
-
Как организовать backpressure в цепочке микросервисов, общающихся через Kafka?
Брокер сам по себе не обеспечивает backpressure между сервисами напрямую. Но можно ввести реактивный поток: consumer следующего сервиса приостанавливает poll, пока его downstream не обработает накопившиеся запросы, что вызывает накопление в Kafka и потенциальный мониторинг lag’а. -
Как в Go реализовать адаптивный backpressure с graceful degradation?
Контролировать длину канала; при превышении порога отбрасывать сообщения с низшим приоритетом или возвращать временную ошибку продюсеру, чтобы он мог переключиться на резервный канал.
Как работает sarama (Go клиент для Kafka)?
Краткий ответ (30–60 секунд)
Sarama — это чистый Go-пакет для работы с Kafka, реализующий протокол на уровне TCP без C-зависимостей. Он предоставляет как низкоуровневый доступ (Client, Broker), так и высокоуровневые Consumer Group и async/sync Producer. Благодаря отсутствию cgo упрощает сборку и развёртывание, но при экстремальных нагрузках может уступать confluent-kafka-go по пропускной способности из-за невозможности использовать zero-copy системные вызовы.
Развёрнутый ответ
Sarama — самый популярный Go-клиент (используется в CockroachDB, InfluxDB). Он полностью написан на Go и следует идиомам языка: каналы, контексты, горутины. Основные компоненты:
Config— детальная конфигурация (версия Kafka, таймауты, acks, retries).SyncProducer— синхронная отправка, блокирует до подтверждения от брокера.AsyncProducer— сообщения передаются в каналInput(), успехи/ошибки приходят в каналыSuccesses()иErrors().ConsumerGroup— реализация группового потребителя с обработчиком (ConsumerGroupHandler), управление сессией и перебалансировкой.
Пример consumer group:
handler := MyHandler{}
consumerGroup.Consume(ctx, []string{"my-topic"}, handler)
В handler.ConsumeClaim(session, claim) мы читаем из claim.Messages() и после обработки вызываем session.MarkMessage(msg, "").
Под капотом (internals)
Sarama реализует бинарный Kafka wire protocol, версии запросов автоматически согласуются с версией брокера. Соединения управляются пулом; для каждого брокера открывается TCP-соединение, по которому мультиплексируются запросы (каждый с sequence ID). Сериализация/десериализация делается вручную для скорости. Отсутствие cgo означает, что используется Go netpoller, что хорошо интегрируется с горутинами, но нет системного sendfile для zero-copy передачи данных. Однако для большинства приложений производительности хватает.
Планировщик consumer group: Sarama сам реализует протокол join/sync, хранит состояние в памяти, обрабатывает heartbeat’ы в отдельной горутине.
Типичные ошибки кандидатов
-
Игнорирование закрытия каналов AsyncProducer
После остановки продюсера необходимо закрытьAsyncProducer, чтобы дренировать каналыSuccesses/Errors. Утечка горутин и памяти. Правильно: при shutdown сначала вызватьAsyncClose()илиClose(), затем в цикле читать подтверждения. -
Параллельная обработка сообщений без MarkMessage
Если внутри ConsumeClaim запускаются горутины, то вызовMarkMessageиз нескольких горутин не потокобезопасен. Нужно либо маркировать в главном цикле, либо использовать канал для маркировки. -
Неверное управление offset в ConsumerGroup
Некоторые вручную коммитят offset черезsession.Commit(), хотяMarkMessage+ автоматический коммит достаточно. Ручной коммит без синхронизации с ребалансировкой может привести к потере или дублированию.
Возможные дополнительные вопросы интервьюера
-
Почему в high-load проектах иногда выбирают confluent-kafka-go вместо sarama?
librdkafka (на C) использует zero-copy и собственную реализацию асинхронного ввода-вывода, что даёт выше пропускную способность и ниже задержки. Sarama удобнее в сборке, но при миллионах сообщений в секунду проигрывает. -
Как в sarama реализовать exactly-once консьюмер?
Sarama поддерживает транзакции и изоляциюReadCommitted, но полный exactly-once требует внешней координации: используем транзакции БД и запись offset в БД, sarama используем только как транспорт.
Как работает confluent-kafka-go?
Краткий ответ (30–60 секунд)
confluent-kafka-go — это Go-биндинг к librdkafka, высокопроизводительной C-библиотеке, написанной создателями Kafka. Он обеспечивает наилучшую производительность и полную поддержку возможностей Kafka (транзакции, idempotent producer, SASL/SSL, множество стратегий ребалансировки). Платой является необходимость cgo и линковка с librdkafka, что усложняет сборку и может вызывать проблемы с производительностью GC при блокирующих вызовах.
Развёрнутый ответ
Модель работы:
ProducerпредоставляетProduce(msg, deliveryChan)для асинхронной отправки иFlushдля ожидания подтверждений.ConsumerиспользуетSubscribeTopicsиReadMessage(timeout)для pull-based обработки.- Под капотом все сетевые взаимодействия, сжатие, идемпотентность, транзакции обрабатываются в потоках librdkafka, отделённых от Go runtime.
Пример консьюмера:
c.SubscribeTopics([]string{"myTopic"}, nil)
for {
msg, err := c.ReadMessage(-1)
if err == nil {
process(msg)
c.CommitMessage(msg)
}
}
Под капотом (internals)
librdkafka внутри использует event-driven модель с epoll/kqueue и пул потоков для сетевого взаимодействия, что позволяет достигать микросекундных задержек. Сообщения передаются в Go через callback'и, куда копируются данные (переход через cgo). Это создаёт накладные расходы на копирование и конвертацию, но обеспечивает изоляцию: блокировка в обработчике Go не блокирует сетевые потоки. Обратите внимание: вызов ReadMessage с большим таймаутом удерживает goroutine, но не блокирует librdkafka.
Управление памятью: librdkafka выделяет память для сообщений, Go сторона должна освобождать её специальным вызовом (обычно через finalizer, но лучше явно). CommitMessage отдаёт offset в librdkafka, который отправляет коммит асинхронно.
Типичные ошибки кандидатов
-
Блокировка обработки в callback продюсера
Установка длительной операции в delivery-канал может исчерпать внутренние буферы librdkafka, приводя к блокировке. Доставку следует подтверждать быстро, а тяжёлую работу делать в отдельных горутинах. -
Утечка памяти из-за неосвобождения сообщений
В ранних версиях требовалосьmsg.Destroy(), сейчас GC собирает, но при высоких нагрузках явный вызовDestroyпомогает снизить давление на GC. Невыполнение может привести к задержкам GC. -
Неправильная конфигурация потокового режима
Использованиеgo.application.rebalance.enableи cooperative sticky требует соответствующих настроек; их игнорирование ведёт к затяжным ребалансировкам.
Возможные дополнительные вопросы интервьюера
-
Как избежать проблем с cgo при использовании confluent-kafka-go в бессерверных средах?
Сборка с librdkafka требует статической линковки и совместимости с libc. Альтернативы: чисто Go клиенты (segmentio/kafka-go, sarama) или Kubernetes с подготовленным Docker-образом. -
Можно ли использовать confluent-kafka-go без CGO?
Нет, библиотека фундаментально привязана к librdkafka. Для pure-Go окружений нужен другой клиент.
Как работает amqp для RabbitMQ?
Краткий ответ (30–60 секунд)
amqp091-go (ранее streadway/amqp) — де-факто стандартная Go-библиотека для работы с RabbitMQ по протоколу AMQP 0-9-1. Она реализует концепцию каналов, обменников и очередей, предоставляет синхронные методы для публикации/потребления и полный контроль над подтверждениями. Библиотека требует ручного управления соединениями и восстановления после сбоев.
Развёрнутый ответ
Типичный рабочий процесс:
amqp.Dial(url)открывает TCP-соединение.conn.Channel()создаёт виртуальное соединение (канал) внутри TCP.- Объявляем exchange (
DeclareExchange), queue (DeclareQueue), привязываем (Bind). - Публикация:
ch.PublishWithContext(ctx, exchange, routingKey, mandatory, immediate, msg). - Потребление:
ch.Consume(queue, consumer, autoAck, exclusive, ...) => <-chan Delivery. - После обработки вызываем
delivery.Ack(false)для подтверждения.
Библиотека потокобезопасна на уровне каналов: один канал — одна горутина, но несколько горутин могут использовать разные каналы.
Под капотом (internals)
AMQP-клиент в Go реализует фреймы протокола (method, header, body). Каждый канал мультиплексируется с уникальным channel-id. Фреймы читаются/пишутся через bufio.Reader/Writer поверх net.Conn. Для публикаций поддерживается потоковый контроль (flow control) и подтверждения. При подтверждениях публикации используется асинхронная модель с каналом подтверждений (NotifyPublish), куда приходят подтверждения от сервера, позволяя не блокировать публикацию.
Важная особенность: автоматического восстановления соединений нет — забота разработчика отслеживать NotifyClose и переподключаться с редекларацией топологии.
Типичные ошибки кандидатов
-
Использование одного канала из многих горутин без синхронизации
Канал не потокобезопасен для конкурентных вызовов. Нарушение приводит к повреждению данных или панике. Решение: выделять горутину-владельца канала или использовать mutex. -
AutoAck=true в production
Это отключает подтверждения потребителя, сообщение удаляется сразу после доставки, до обработки. При крахе консьюмера сообщение теряется. Всегда использовать ручные подтверждения иAckпосле обработки. -
Отсутствие обработки закрытия канала/соединения
Если канал закрывается из-за ошибки брокера (например, неправильно объявлен exchange), программа продолжает пытаться публиковать, генерируя бесконечные ошибки. Нужно слушатьNotifyCloseи корректно пересоздавать канал и топологию.
Возможные дополнительные вопросы интервьюера
-
Как реализовать гарантированную публикацию с повторами?
Включить publisher confirms (Confirm), после публикации ждать подтверждения из канала. При nack или таймауте выполнять retry с экспоненциальной задержкой. Retry лучше осуществлять в горутине, не блокируя основной поток. -
Что делать, если соединение упало во время ожидания подтверждения?
Необходимо переподключиться и повторно отправить неподтверждённые сообщения. Библиотека не хранит отправленные сообщения; нужно вести свой лог ожидающих сообщений и после переподключения переигрывать их (идемпотентные).
Что такое dead letter queue (DLQ)?
Краткий ответ (30–60 секунд)
Dead Letter Queue (очередь недоставленных сообщений) — специальная очередь, куда перенаправляются сообщения, которые не могут быть обработаны после заданного числа попыток или при невозможности маршрутизации. DLQ позволяет изолировать «ядовитые» сообщения, предотвращая бесконечные повторы и блокировку основной обработки, и даёт возможность позже проанализировать и исправить ошибки вручную.
Развёрнутый ответ
В RabbitMQ DLQ реализуется через параметр x-dead-letter-exchange на очереди. Если сообщение отклонено (reject/nack с requeue=false), истекло время жизни (TTL) или очередь превысила максимальную длину, сообщение направляется в указанный обменник и далее в другую очередь (собственно DLQ). Потребитель DLQ может записывать сообщения в лог или хранилище для последующего анализа и ручного вмешательства.
В Kafka понятие DLQ встроено частично. Можно реализовать собственный механизм: если обработка сообщения несколько раз завершилась ошибкой, продюсируем его в специальный dead-letter топик, а оригинальный offset коммитим, чтобы не блокировать партицию. Это позволяет продолжить обработку последующих сообщений.
Под капотом (internals)
В RabbitMQ при срабатывании условия dead-lettering брокер создаёт новое сообщение с заголовками x-death (счётчик смертей, причина, исходная очередь, timestamp), публикует его в dead-letter exchange согласно привязкам. Это требует дополнительных ресурсов, но защищает основную очередь от заторов. В Kafka DLQ — это обычный топик, куда продюсирует приложение. Order гарантируется только в пределах партиции, поэтому если poison pill тормозит потребителя, важно быстро отправить его в DLQ и коммитнуть, не блокируя очередь.
Типичные ошибки кандидатов
-
Отсутствие мониторинга DLQ
Сообщения молча попадают в DLQ и копятся, теряется бизнес-значимость. Обязательно настраивать алерты на рост DLQ и автоматическую переотправку после фикса проблемы. -
Бесконечный requeue без DLQ
В RabbitMQ reject с requeue=true или nack возвращает сообщение в очередь, и оно будет повторно доставлено, возможно немедленно. Это создаёт бесконечный цикл и загружает систему. Нужно считать попытки в заголовках и после N попыток отправлять в DLQ. -
Игнорирование порядка обработки при использовании DLQ в Kafka
Если poison pill находится в середине партиции, нельзя просто пропустить его и продолжить, не потеряв порядок для последующих сообщений. Нужно либо блокировать обработку партиции до разрешения poison pill, либо, если порядок не критичен, отправлять poison в DLQ и коммитить offset, но допускать нарушение порядка.
Возможные дополнительные вопросы интервьюера
-
Как реализовать retry с задержкой с помощью DLQ в RabbitMQ?
Создать промежуточные очереди с разными TTL: после первой ошибки отправляем в очередь с TTL 1 мин, после истечения она перенаправляется обратно в основную очередь через dead-letter exchange. Это реализует отложенную повторную обработку. -
Какие метаданные стоит сохранять в DLQ в Kafka?
Оригинальный топик, партиция, offset, timestamp, стек ошибок, заголовки, количество попыток, идентификатор consumer group. Это помогает в диагностике и возможности replay сообщения после исправления.
Как реализовать retry pattern?
Краткий ответ (30–60 секунд)
Паттерн retry обеспечивает повторную обработку сообщения при временных сбоях (сетевые ошибки, таймауты БД). Реализуется с помощью подсчёта попыток (в заголовках сообщения или внешнем хранилище), экспоненциальной задержки и ограничения максимального числа попыток, после чего сообщение отправляется в DLQ. В Go это делается через переотправку в брокер с задержкой или через спящий режим внутри консьюмера с циклом for.
Развёрнутый ответ
Два подхода:
-
Отложенная переотправка в брокере (лучше):
- В RabbitMQ: настраиваем цепочку очередей с TTL → dead letter → исходная очередь. При ошибке отправляем в очередь задержки.
- В Kafka: при ошибке продюсируем сообщение в retry-топик (
mytopic.retry.1s,mytopic.retry.10sи т.д.) с увеличенным таймаутом. Consumer группы подписан на retry-топики и через заданное время откладывает обработку. Это не блокирует основную партицию.
-
Sleep в консьюмере (проще, но опаснее):
В случае ошибки выполняемtime.Sleep(backoff)и затемnack(requeue) или не коммитим offset и re-polls. Может заблокировать партицию/очередь и нарушить порядок, поэтому применимо только если порядок не важен и нагрузка низкая.
В Go для реализации retry-топиков удобно использовать sarama/sarama с отдельным consumer’ом для retry топиков с обработкой через ticker.
Под капотом (internals)
При использовании retry-топиков нужно контролировать глубину retry очередей. Для этого в сообщение добавляется заголовок retry-count и максимальное значение. При достижении лимита — в DLQ. В RabbitMQ для задержек можно использовать плагин rabbitmq-delayed-message-exchange, который задерживает сообщение внутри обменника без дополнительных очередей. В Kafka можно применить scheduled job (например, с Redis или БД) для повторной доставки со смещением времени.
Типичные ошибки кандидатов
-
Бесконечный цикл retry в одной горутине
for { err := process(); if err != nil { time.Sleep(backoff); continue } }держит партицию или канал брокера занятым, тормозит всю обработку. Правильно: перемещать проблемное сообщение в retry-очередь и немедленно продолжать. -
Экспоненциальный рост задержки без джиттера
При одновременном сбое всех консьюмеров retry будет синхронным, создавая всплески нагрузки на БД/сервис (thundering herd). Добавление случайной составляющей (jitter) решает проблему. -
Retry без идемпотентности
Если операция не идемпотентна, повтор может вызвать двойное списание средств. Необходимо делать retry только для идемпотентных операций или предусмотреть компенсацию.
Возможные дополнительные вопросы интервьюера
-
Как реализовать retry с гарантией порядка?
В Kafka порядок внутри ключа критичен: нельзя пропустить сообщение и вернуться. Тогда retry должен блокировать обработку этого ключа до успеха. Реализуется через отдельный канал на ключ: пока сообщение не обработано, другие сообщения с тем же ключом ставятся в очередь ожидания. -
Какую стратегию retry выбрать для внешнего API?
Экспоненциальная задержка с ограничением и circuit breaker. В случае отказа API на длительное время быстрый retry бесполезен; circuit breaker предотвращает каскадные отказы.
Что такое message deduplication?
Краткий ответ (30–60 секунд)
Message deduplication — это процесс обнаружения и устранения дубликатов сообщений, возникающих из-за повторов продюсера, ретрансляции или ребалансировки консьюмеров. Дедупликация реализуется либо на стороне брокера (идемпотентный продюсер в Kafka, deduplication в Pulsar), либо на стороне потребителя через проверку уникального идентификатора сообщения в хранилище обработанных сообщений.
Развёрнутый ответ
В Kafka продюсер может быть идемпотентным, что исключает дубликаты в рамках сессии записи. Однако консьюмер может получить дубли из-за повторного чтения (at-least-once). Поэтому потребительская дедупликация необходима почти всегда. Основной способ: хранить набор обработанных идентификаторов (или (topic, partition, offset)) в быстром хранилище (Redis, in-memory LRU-кэш) или в БД с уникальным constraint. При получении сообщения проверяем, не обрабатывалось ли оно ранее; если да — пропускаем.
В RabbitMQ дедупликация менее встроена; можно включить плагин rabbitmq-message-deduplication или делать на стороне потребителя.
Под капотом (internals)
В Kafka идемпотентный продюсер работает за счёт хранения на брокере последних sequence number для каждого PID+partition. Дубликаты отбрасываются до записи в лог. На стороне потребителя самый надёжный метод — сохранять offset в БД атомарно с бизнес-транзакцией; тогда offset — уникальный идентификатор сообщения (в пределах партиции). Для топиков с log compaction offset может повторно использоваться при удалении более старых записей? Нет, offset монотонно растёт, но после compaction более старые сообщения с тем же ключом могут быть удалены, новые сообщения с тем же ключом получат больший offset, так что offset уникален всегда.
Типичные ошибки кандидатов
-
Использование message body в качестве ключа дедупликации
Если два одинаковых бизнес-события допустимы (например, два заказа от разных пользователей), проверка по хешу тела отбросит второе, что неправильно. Ключом должен быть business-scoped identifier (order_id + event_type + version). -
In-memory TTL кэш без персистентности
Хранение обработанных ID только в памяти приведёт к потере информации при рестарте и дублированию. Для критичных систем нужна персистентная БД или Kafka offset store. -
Ожидание глобальной уникальности от UUID без синхронизации
Даже UUID, сгенерированный продюсером, при повторной отправке того же логического события создаст новый UUID — дубликат не детектируется. Идемпотентность требует, чтобы продюсер прикреплял один и тот же ключ для одного и того же события при всех повторах.
Возможные дополнительные вопросы интервьюера
-
Как реализовать deduplication с окном в 24 часа и высокой пропускной способностью?
Использовать Redis сSETEX idempotency_key "1" 86400, но с осторожностью: при большом потоке память Redis может переполниться. Альтернатива: партиционированный по дате dedup-топик в Kafka, куда стримятся ключи, и bloom-фильтр. -
Можно ли полагаться только на идемпотентность продюсера?
Нет, потому что дубли могут возникать на стороне консьюмера (rebalance, повторные коммиты). Идемпотентность продюсера защищает от дублей в логе, но не от повторного чтения.
Как масштабировать консьюмеров?
Краткий ответ (30–60 секунд)
Масштабирование консьюмеров достигается за счёт увеличения числа экземпляров приложения внутри consumer group, но только до количества партиций. В Kafka максимальный параллелизм = количество партиций. Дополнительно можно масштабировать вертикально (горутины на один консьюмер) для обработки в несколько потоков внутри одной партиции, но это ломает порядок.
Развёрнутый ответ
Горизонтальное масштабирование: запускаем N экземпляров сервиса с одинаковым group.id. Брокер назначит каждому экземпляру набор партиций. Если N > партиций, лишние будут простаивать. Поэтому важно заранее задать число партиций с учётом будущего роста.
Вертикальное масштабирование: в одном процессе создаём несколько горутин-обработчиков, которые забирают сообщения из канала, наполняемого главным консьюмером. Это увеличивает throughput для партиции, но порядок сообщений может быть нарушен, если только не шардировать обработку по ключу.
В RabbitMQ масштабирование консьюмеров происходит за счёт конкурентного потребления из одной очереди: несколько экземпляров могут подписаться на одну очередь, брокер будет распределять сообщения по round-robin. Пределом становится пропускная способность самой очереди (одной партиции).
Под капотом (internals)
Kafka consumer group перебалансировка при добавлении нового члена заставляет координатора перераспределить партиции. Протокол позволяет инкрементальное перераспределение (cooperative sticky), минимизируя паузы. Внутренне каждый консьюмер держит TCP-соединения с лидерами партиций, поэтому увеличение числа консьюмеров увеличивает общий сетевой трафик, но позволяет распараллелить обработку.
Типичные ошибки кандидатов
-
Запуск консьюмеров в одной группе, но с разными
client.idи без учётаgroup.instance.id
В Kafkagroup.instance.idделает консьюмера статическим членом группы, что предотвращает ненужные ребалансировки при временном отключении. Без этого каждый перезапуск вызывает полный rebalance. -
Использование auto scaling на основе метрик CPU без учёта числа партиций
Автоскейлинг может добавить инстансы, но если партиций фиксированное число, лишние экземпляры будут бездействовать. Масштабирование должно координироваться с перераспределением партиций (что сложно). -
Игнорирование backpressure при масштабировании
Если обработчик медленный, увеличение числа консьюмеров может не помочь из-за узкого места в БД. Сначала нужно профилировать и устранять bottleneck, а потом масштабировать.
Возможные дополнительные вопросы интервьюера
-
Как масштабировать консьюмеров Kafka в Kubernetes?
Использовать HPA на основе consumer lag (доступен через metrics API). Но ограничение — число реплик не должно превышать количество партиций. Для автомасштабирования нужно предварительно увеличить партиции либо использовать динамическое пересоздание consumer’ов с разными ключами. -
Расскажите про Scale-out Kafka Streams.
Kafka Streams масштабируется так же: каждый инстанс обрабатывает одну или несколько партиций stateful операции, состояние партиционируется и реплицируется.
Как работает перебалансировка (rebalancing)?
Краткий ответ (30–60 секунд)
Перебалансировка — процесс перераспределения партиций между членами consumer group при добавлении/удалении консьюмера, отказе или изменении топологии топика. Во время ребалансировки все члены группы приостанавливают обработку (или частично), координатор назначает новые владения партициями. Это критический момент, влияющий на задержки и непрерывность обработки.
Развёрнутый ответ
Механизм (Kafka):
- При старте или падении консьюмера координатор группы инициирует перебалансировку, отправляя
JoinGroupзапросы оставшимся. - Члены группы снова присоединяются, и один из них становится лидером.
- Лидер выполняет назначение партиций согласно стратегии (Range, RoundRobin, Sticky, Cooperative Sticky) и отправляет
SyncGroup. - Все получают новые назначения и возобновляют потребление.
Стандартная стратегия Eager (до Kafka 2.4) останавливает всех консьюмеров. Cooperative Sticky (начиная с 2.4) позволяет перераспределять только изменившиеся партиции без полной остановки.
Под капотом (internals)
Координатор группы — это брокер, избранный для конкретной группы. Он хранит состояние группы в памяти и в топике __consumer_offsets. Heartbeat-таймаут (session.timeout.ms) определяет, как быстро обнаружится отказ. Периодические poll’ы с max.poll.interval.ms гарантируют liveness. Ребалансировка может затянуться, если потребитель долго обрабатывает последний батч до вызова poll. Реализация в Go: sarama обрабатывает всё в отдельных горутинах, предоставляя callback’и Setup / Cleanup на начало/конец потребления партиции.
Типичные ошибки кандидатов
-
Слишком короткий session.timeout
Временные всплески GC или сетевых задержек приводят к ложным перебалансировкам, вызывая шторм. Увеличивают до 30-60 секунд в стабильных средах. -
Длительная обработка без вызова poll
Еслиmax.poll.interval.msпревышен, брокер исключает консьюмера, запуская ребалансировку и возможные дубликаты. Нужно уменьшатьmax.poll.recordsили выносить обработку за пределы poll-потока. -
Использование автоматического коммита во время Cooperative Sticky
При частичной передаче партиции ещё не переданные сообщения могут быть закоммичены, но не обработаны новым владельцем, если коммит происходит до завершения обработки. Лучше ручной коммит после обработки блока.
Возможные дополнительные вопросы интервьюера
-
Как отслеживать задержки из-за ребалансировок?
Использовать метрикиkafka.consumer:type=consumer-fetch-manager-metrics,client-id(fetch-latency-avg) и JMX мониторинг, но также внедрять логирование start/end rebalance в коде с измерением длительности. -
Что делать, если перебалансировка происходит слишком часто?
Проанализировать логи consumer group, увеличить session.timeout и heartbeat.interval, проверить сетевые проблемы, использовать статические членства (group.instance.id) для снижения перебалансировок при плановых рестартах.
Как реализовать producer с batching?
Краткий ответ (30–60 секунд)
Batching в продюсере группирует несколько сообщений для отправки одним сетевым запросом, что значительно увеличивает пропускную способность и снижает задержки. В Kafka это настраивается через batch.size, linger.ms и compression.type. В Go-клиенте (sarama) можно использовать AsyncProducer с настройкой Flush.Frequency или ручное накопление слайса и вызов SendMessages.
Развёрнутый ответ
Конфигурация батчинга в sarama:
config := sarama.NewConfig()
config.Producer.Flush.Frequency = 10 * time.Millisecond // максимальная задержка перед отправкой
config.Producer.Flush.MaxMessages = 1000 // максимальный размер батча
config.Producer.Compression = sarama.CompressionSnappy
При использовании AsyncProducer сообщения сначала попадают во внутренний буфер, а затем отправляются либо при его заполнении, либо по истечении Flush.Frequency. Это позволяет аккумулировать сообщения.
В segmentio/kafka-go writer также поддерживает BatchSize и BatchTimeout. В confluent-kafka-go batching управляется параметрами batch.num.messages и queue.buffering.max.ms.
Самописный batching: приложение само накапливает сообщения в слайсе и вызывает SendMessages (пакетная отправка) по таймеру или по размеру, улучшая производительность.
Под капотом (internals)
Внутри sarama AsyncProducer использует горутину-диспетчер, которая принимает сообщения из канала Input(), накапливает их в буферах для каждого брокера и топика+партиции. Когда буфер достигает лимита или срабатывает таймер, формируется ProduceRequest с несколькими записями, и отправляется асинхронно. Ответы обратно разбираются в каналы Successes/Errors. Такой дизайн позволяет достигать 100k+ сообщений в секунду.
Типичные ошибки кандидатов
-
Слишком большой linger.ms без учёта latency требований
Если задатьFlush.Frequency= 1 секунда, задержка сообщений станет как минимум 1 сек, что недопустимо для real-time приложений. Нужно находить баланс между throughput и latency. -
Отсутствие обработки ошибок для батчей
При отправке батча отдельные сообщения могут упасть. Если просто игнорировать ошибки, потеряются целые пачки. В sarama каждое сообщение даёт свой результат вErrors()/Successes(), нужно обрабатывать каждое. -
Одновременное использование Flush.Frequency и собственного таймера
Может вызвать двойную отправку или преждевременный flush. Лучше полагаться на встроенные механизмы клиента.
Возможные дополнительные вопросы интервьюера
-
Как изменить размер батча динамически в зависимости от нагрузки?
В sarama конфиг неизменяем после создания продюсера. Можно использовать несколько продюсеров с разными настройками и адаптивный выбор. Альтернативно, реализовать свой batching поверх синхронного продюсера. -
Что делать, если брокер возвращает ошибку для батча сообщений?
Необходимо повторить только те сообщения, которые не были записаны. В sarama в каналErrors()приходит*ProducerErrorсMsg, содержащим оригинальное сообщение, и только для упавших. Повтор осуществляется отдельно.
Как реализовать consumer с graceful shutdown?
Краткий ответ (30–60 секунд)
Graceful shutdown потребителя подразумевает корректное завершение обработки текущих сообщений, коммит offset и закрытие соединений при получении сигнала ОС (SIGTERM/SIGINT). В Go это делается через context.Context с отменой, которая передаётся в цикл обработки, ожидание завершения активных горутин (sync.WaitGroup) и вызов Close() у консьюмера.
Развёрнутый ответ
Шаблон:
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
consumer := // создание
wg := &sync.WaitGroup{}
loop: for {
select {
case <-ctx.Done():
break loop
default:
msg, err := consumer.FetchMessage(ctx)
if err != nil {
// обработка ошибки контекста
continue
}
wg.Add(1)
go func(msg Message) {
defer wg.Done()
process(msg)
consumer.CommitMessages(ctx, msg)
}(msg)
}
}
// по завершении цикла:
consumer.Close()
wg.Wait() // ожидаем завершения всех запущенных горутин
Это обеспечивает, что после получения сигнала:
- Не принимаются новые сообщения (cancel контекста делает
FetchMessageвозвращающим ошибку). - Активные горутины завершат обработку.
- После обработки коммитим offset и закрываем потребителя.
Под капотом (internals)
При вызове consumer.Close() клиент отправляет брокеру LeaveGroupRequest, инициируя перебалансировку для оставшихся членов. В sarama Close() блокируется до завершения всех текущих операций, внутренние горутины останавливаются. Если остались запущенные горутины с MarkMessage после Close(), возможны паники или потеря коммитов, поэтому важно сначала дождаться завершения обработки.
Типичные ошибки кандидатов
-
Принудительное завершение без ожидания обработки
Просто вызываютos.Exitилиconsumer.Close()немедленно при сигнале, оставляя часть сообщений неподтверждёнными и вызывая дубликаты после перезапуска. Необходимо дождаться активных обработчиков. -
Коммит offset после завершения всех горутин, но до Close
Если коммитить после обработки, но до Close, всё хорошо. Но иногда пытаются коммитить финальное смещение послеClose, что невозможно — к этому моменту сессия закрыта. Лучше коммитить внутри каждой горутины после обработки. -
Использование бесконечного контекста для FetchMessage
Если у FetchMessage нет дедлайна, отмена не сработает вовремя. Нужно передавать контекст с таймаутом или отменой.
Возможные дополнительные вопросы интервьюера
-
Как быть, если обработка одного сообщения занимает несколько минут и мы не можем ждать при shutdown?
Установить общий таймаут для graceful shutdown (например, 30 секунд). По истечении таймаута принудительно завершать горутины (с использованием контекста с таймаутом) или отправлять сообщения в DLQ для последующей обработки. -
Как гарантировать, что offset закоммичен ровно для обработанных сообщений?
Коммитить после обработки каждой единицы или батча, но аккуратно: при конкурентной обработке нужно либо сериализовать коммиты в порядке offset, либо использовать синхронизацию.
Как работать с NATS или NATS JetStream?
Краткий ответ (30–60 секунд)
NATS — быстрейший, легковесный брокер с моделью pub/sub и запрос-ответ, написанный на Go. NATS JetStream добавляет персистентность, replay и гарантии доставки, приближаясь к возможностям Kafka. В Go используется официальный клиент nats.go, который предоставляет асинхронные подписки, очереди и управление потоками JetStream.
Развёрнутый ответ
NATS Core идеален для сервисов с требованиями ультранизкой задержки, fire-and-forget, и где потеря сообщений при сбоях допустима. JetStream надстраивает поверх концепцию Stream (сохраняемый лог) и Consumer (подписка с offset, как в Kafka). Пример:
nc, _ := nats.Connect("nats://localhost:4222")
js, _ := nc.JetStream()
// Публикация
js.Publish("orders.new", []byte("data"))
// Потребление с durable консьюмером
sub, _ := js.PullSubscribe("orders.new", "durable-name")
msgs, _ := sub.Fetch(10)
for _, m := range msgs {
process(m)
m.Ack()
}
JetStream поддерживает at-least-once, exactly-once через идемпотентность, de-duplication, replay, retention policy.
Под капотом (internals)
NATS сервер написан на Go и использует горутины для каждого соединения. JetStream хранит данные в файлах в формате WAL (write-ahead log) с индексацией. Поток (stream) может состоять из множества subject’ов. Потребители бывают push или pull. Протокол текстовый, что упрощает отладку. Клиент nats.go использует горутины для доставки сообщений в callback’и (асинхронные подписки) или каналы.
Типичные ошибки кандидатов
-
Смешивание Core NATS и JetStream без понимания гарантий
Использованиеnc.Publishи ожидание сохранности — сообщение может быть потеряно. Для персистентности всегда нужно JetStream. -
Неправильная настройка ack wait в JetStream
Если consumer не успевает вызватьAck()в течениеAckWait, сообщение будет перепослано, вызывая дубликаты. Необходимо настраивать достаточный ack wait и обрабатывать быстро. -
Отсутствие idempotency key при дедупликации JetStream
JetStream поддерживает дедупликацию на основеNats-Msg-Id, но если его не задавать, дубликаты не будут отфильтрованы.
Возможные дополнительные вопросы интервьюера
-
Когда выбрать NATS вместо Kafka?
NATS — для случаев низкой задержки (<1 мс), простоты развёртывания, меньшего объёма данных, не требующих долгого хранения и повторного чтения истории. Kafka — для больших объёмов данных, событийного sourcing, интеграции данных. -
Как в NATS JetStream обеспечить порядок сообщений?
Порядок гарантирован внутри одного subject в потоке при использовании одного publisher с синхронной отправкой. Для распределённого порядка можно использовать ключ партиции (subject mapping), но это менее выражено, чем партиции Kafka.
Что такое "log compaction" в Kafka?
Краткий ответ (30–60 секунд)
Log compaction — это политика очистки топика Kafka, которая сохраняет только последнее значение для каждого уникального ключа, удаляя старые записи с тем же ключом. Это позволяет использовать Kafka как источник истины для текущего состояния сущностей (CDC, снапшоты), не теряя возможности replay. Compaction происходит асинхронно в фоновом потоке, создавая компактный лог.
Развёрнутый ответ
При обычной политике удаления сообщения удаляются по времени или размеру. Compaction (настраивается cleanup.policy=compact) сохраняет последнее состояние. Внутри брокера процесс Log Cleaner работает: он выбирает сегмент, сканирует его, создавая карту смещений по ключам; затем копирует только самые свежие записи в новый сегмент, удаляя дублирующиеся ключи. В результате лог сохраняет все ключи, каждый с последним значением. Чтение компактного топика похоже на обычное, но порядок ключей теперь не гарантированно последовательный по offsetам? Offset остаются монотонными, просто некоторые offset пропускаются (удалены).
Под капотом (internals)
Log Cleaner состоит из нескольких потоков, каждый из которых обрабатывает отдельную партицию. Для каждого сегмента строится map в памяти с ключами и их последними offset. Память ограничена log.cleaner.dedupe.buffer.size. Старые сегменты заменяются компактными, процесс прозрачен для потребителей. Важно: компактированный топик всё равно имеет retention по времени для последнего значения (delete.retention.ms), чтобы ключ, чьё последнее значение — tombstone (null), через некоторое время был полностью удалён.
Типичные ошибки кандидатов
-
Ожидание, что компакция даёт exactly-once доставку
Log compaction не имеет отношения к семантике доставки; это только способ хранения. Дубликаты всё равно могут появиться из повторных чтений. -
Неверные ожидания по времени компакции
Не думают, что compaction не мгновенен, старые значения ещё могут быть прочитаны в течение некоторого времени. Приложение должно быть готово обрабатывать устаревшие данные. -
Использование компакции для топиков с большим количеством уникальных ключей
Если ключей миллиарды, map в памяти log cleaner’а может переполниться, что приведёт к отказу компакции или падению. Нужно следить за числом уникальных ключей.
Возможные дополнительные вопросы интервьюера
-
Чем отличается log compaction от KTable в Kafka Streams?
KTable — это абстракция, построенная поверх компактного топика, предоставляющая изменяемую таблицу с автоматическим обновлением состояния. Log compaction — нижележащий механизм хранения. -
В каких use-case применять log compaction?
CDC (Change Data Capture), хранение последнего состояния агрегата, таблицы лимитов и справочники, где нужно иметь возможность восстановить полное состояние, не читая всю историю.
Как работает schema registry и зачем он нужен?
Краткий ответ (30–60 секунд)
Schema Registry — централизованное хранилище схем сериализации (Avro, Protobuf, JSON Schema) для сообщений в Kafka. Он обеспечивает эволюцию схем, обратную совместимость и экономию места за счёт хранения схемы один раз и передачи вместе с сообщением только идентификатора схемы. Это критически важно для надёжного десериализации данных разными сервисами.
Развёрнутый ответ
Confluent Schema Registry — стандарт де-факто. Работает так:
- Продюсер при отправке сообщения сериализует его с помощью Avro, регистрирует схему в реестре (если ещё нет) и отправляет payload, предваряемый magic byte + schema ID (4 байта).
- Консьюмер при получении сообщения извлекает schema ID, запрашивает схему у реестра и десериализует.
Совместимость схем настраивается: BACKWARD, FORWARD, FULL.
В Go используют github.com/confluentinc/confluent-kafka-go/v2/schemaregistry или sarama в паре с hamba/avro. Пример:
client, _ := schemaregistry.NewClient(...)
serializer, _ := avro.NewSpecificSerializer(client, ...)
payload, _ := serializer.Serialize(topic, &myStruct)
producer.Produce(...)
Под капотом (internals)
Schema Registry — отдельное HTTP-приложение (Java), хранит схемы в Kafka топике _schemas (компактный топик). Кэширует схемы локально. Клиенты также кэшируют, чтобы избежать запросов при каждом сообщении. Идентификатор схемы глобален. При эволюции важно соблюдать compatibility rules, иначе реестр отклонит регистрацию.
Типичные ошибки кандидатов
-
Использование JSON без Schema Registry в больших системах
JSON без схемы не даёт гарантий структуры, сложнее обрабатывать изменения. Schema Registry + Avro/Protobuf даёт строгую типизацию и плавную эволюцию. -
Игнорирование совместимости схем
Деплой сервиса с несовместимыми изменениями (например, удаление поля) ломает потребителей. Нужно следовать правилам эволюции, использовать дефолтные значения. -
Кэширование схем навечно без инвалидации
Если схема изменилась (новая версия), а consumer кэшировал старую навсегда, он не сможет прочитать новые сообщения. Нужно периодически обновлять или подписываться на изменения.
Возможные дополнительные вопросы интервьюера
-
Как обрабатывать сообщения, когда schema registry недоступен?
Клиенты должны иметь fallback-кэш схем локально (на диск) и при старте загружать схемы. Если registry недоступен, использовать кэшированные схемы. Без схемы старых данных не прочитать. -
Что лучше: Avro или Protobuf для Kafka?
Оба поддерживаются. Avro исторически первый, зрелый, с развитой экосистемой. Protobuf популярен в gRPC окружениях, даёт единый формат для брокера и сервисных API. Выбор зависит от стека.
Как тестировать интеграцию с брокерами?
Краткий ответ (30–60 секунд)
Интеграционное тестирование с брокерами требует изолированной среды, максимально приближенной к production. Обычно используют встраиваемые или контейнеризованные брокеры (Kafka testcontainers, embedded Kafka, RabbitMQ Docker), либо библиотеки-заглушки (fake). Тесты проверяют корректность отправки, обработки, retry, DLQ и гарантий доставки.
Развёрнутый ответ
Популярные подходы для Go:
- Testcontainers: запускают реальный Kafka/RabbitMQ в Docker. Дают максимальную достоверность, но медленные. Рекомендуются для критичных сценариев.
- Embedded Kafka:
kafka-go/test(segmentio) или sarama mock. Позволяют запустить in-memory брокер. Быстро, но не на 100% соответствует поведению настоящего. - Mock/фейки: sarama предоставляет mock-продюсер/консьюмер, позволяя проверять аргументы. Подходит для unit-тестов логики обработки без реальной сети.
Пример с testcontainers:
func TestKafkaIntegration(t *testing.T) {
ctx := context.Background()
kafkaC, err := kafka.RunContainer(ctx, ...)
// создаём топик, продюсера, отправляем сообщение, читаем
}
Лучшая практика: писать acceptance-тесты, которые поднимают полноценный сервис с брокером и проверяют end-to-end сценарии.
Под капотом (internals)
Testcontainers поднимает образ Kafka, который требует ZooKeeper/KRaft. embedded Kafka в kafka-go/test реализует внутренний брокер с сетью в памяти, используя реальные структуры Kafka протокола, но без сети. Это облегчает параллельное выполнение тестов. Mock’и sarama просто записывают сообщения в каналы.
Типичные ошибки кандидатов
-
Тестирование только с моками без реального брокера
Пропускаются ошибки сериализации, неверной конфигурации, особенности поведения ребалансировки. Минимум один интеграционный тест с реальным брокером обязателен. -
Отсутствие очистки состояния между тестами
Остаются сообщения из предыдущих тестов, что приводит к флакинг-тестам. Нужно удалять топики или использовать уникальные имена. -
Игнорирование асинхронности в тестах
Ожидание отправки/получения без таймаутов и cycle-detection. Используютtime.Sleepвместоassert.Eventually.
Возможные дополнительные вопросы интервьюера
-
Как тестировать обработку дубликатов?
Отправить одно и то же сообщение дважды и проверить, что бизнес-логика выполнилась один раз, а в логах есть признак дедупликации. -
Какие метрики стоит проверять в интеграционном тесте?
Отсутствие ошибок в логах, время обработки, количество обработанных сообщений, отсутствие лишних сообщений в DLQ.
Как реализовать паттерн "transactional outbox"?
Краткий ответ (30–60 секунд)
Transactional outbox решает проблему двойной записи: атомарно сохраняет бизнес-сущность и исходящее событие в одной транзакции базы данных, затем асинхронный процесс публикует событие в брокер. Это гарантирует, что событие будет доставлено в точности при успешном изменении данных, без распределённых транзакций.
Развёрнутый ответ
Паттерн состоит из:
- Таблицы
outboxв БД (обычно в той же схеме, что и бизнес-данные):- id, aggregate_id, event_type, payload (JSON/Protobuf), status (PENDING, SENT), created_at.
- В транзакции: вставка/обновление бизнес-сущности и вставка записи в outbox.
- Отдельный процесс (poller) или change data capture (Debezium) читает записи со статусом PENDING и публикует в Kafka/RabbitMQ.
- После успешной публикации помечает запись как SENT.
В Go реализуется воркером, который периодически SELECT ... FOR UPDATE SKIP LOCKED выбирает пачку записей, публикует, обновляет статус. Пример:
rows, _ := db.Query(ctx, `SELECT * FROM outbox WHERE status='PENDING' LIMIT 100 FOR UPDATE SKIP LOCKED`)
for rows.Next() {
pub.Publish(evt)
db.Exec(`UPDATE outbox SET status='SENT' WHERE id=$1`, id)
}
Под капотом (internals)
Outbox таблица часто партиционируется для ускорения выборки. Ключевой момент — атомарность: никакая запись не попадёт в outbox, если транзакция откатилась. Публикация должна быть идемпотентной, так как процесс может упасть между публикацией и обновлением статуса (повторная отправка). Для Kafka хорошо сочетается с идемпотентным продюсером.
Типичные ошибки кандидатов
-
Публикация в брокер внутри бизнес-транзакции
Если брокер недоступен, транзакция БД зависает и блокирует потоки. Outbox решает именно это: быстрая запись в таблицу, а публикация — вне транзакции. -
Отсутствие гарантии порядка событий одного агрегата
Poller может обрабатывать записи параллельно, нарушая порядок. Нужно либо публиковать в партицию по aggregate_id последовательно, либо использовать один поток на агрегат. -
Неверная обработка дубликатов при сбое между publish и update status
Продюсер может отправить событие, но не успеть обновить статус. При повторе событие отправится снова. Нужно, чтобы консьюмер был идемпотентным, а лучше использовать транзакционный продюсер Kafka с exactly-once (чтение из outbox и публикация в транзакции Kafka).
Возможные дополнительные вопросы интервьюера
-
Как совместить transactional outbox с Kafka транзакциями?
Шаг 1: бизнес-транзакция + вставка в outbox. Шаг 2: poller в транзакции Kafka: читает из outbox, продюсирует в Kafka, коммитит offset. ИспользуетсяsendOffsetsToTransaction, чтобы атомарно записать offset и опубликовать сообщение. -
Когда использовать CDC вместо poller?
Debezium читает WAL и даёт низкую задержку, но сложность настройки. Poller проще, но добавляет минимальную задержку.
Как реализовать "idempotent consumer" с помощью БД?
Краткий ответ (30–60 секунд)
Идемпотентный потребитель с БД использует таблицу обработанных сообщений с уникальным constraint по идентификатору сообщения (или offset). Каждая обработка происходит в транзакции: сначала вставляется запись о получении/обработке, при конфликте транзакция откатывается, предотвращая дублирование бизнес-операции.
Развёрнутый ответ
Алгоритм:
- Получаем сообщение с уникальным идентификатором (например, Kafka
topic+partition+offset, или business id). - Начинаем транзакцию БД.
- Выполняем
INSERT INTO idempotency (msg_id, status) VALUES ($1, 'PROCESSING') ON CONFLICT (msg_id) DO NOTHING. - Проверяем количество затронутых строк: если 0, значит запись уже есть, выходим.
- Выполняем бизнес-логику.
- Обновляем статус на 'COMPLETED' (или удаляем запись).
- Коммитим транзакцию.
Пример в Go:
tx, _ := db.Begin(ctx)
tag, err := tx.Exec(ctx, `INSERT INTO idempotency (msg_id) VALUES ($1) ON CONFLICT DO NOTHING`, msgID)
if tag.RowsAffected() == 0 {
tx.Rollback()
return nil // дубликат
}
// business logic ...
tx.Commit()
Если бизнес-логика затрагивает внешние сервисы, идемпотентность сложнее; нужно компенсационное действие.
Под капотом (internals)
ON CONFLICT DO NOTHING (PostgreSQL) гарантирует атомарную вставку. При высоком параллелизме блокировки на уровне записи минимальны. Можно использовать SELECT ... FOR UPDATE для пессимистичного подхода, если требуется проверять статус обработки. Хранить все обработанные идентификаторы накладно — нужно периодически чистить старые записи, особенно если offset монотонно растут и можно удалять всё, что младше последнего коммита.
Типичные ошибки кандидатов
-
Вставка идемпотентности после бизнес-логики
Если бизнес-логика выполнилась, а вставка не удалась, повторный вызов выполнит бизнес-логику снова. Вставка должна предшествовать. -
Использование
READ COMMITTEDизоляции без проверки конфликтов
Может случиться фантомное чтение; лучшеREPEATABLE READили явный lock. -
Хранение всех обработанных ID в одной таблице без партиционирования
Рост таблицы замедляет вставки. Партиционирование по времени или ротация через TTL снижают накладные расходы.
Возможные дополнительные вопросы интервьюера
-
Как совместить идемпотентность и offset commit?
В одной транзакции: бизнес-операция, вставка идемпотентности и запись offset в таблицу offset. При перезапуске потребитель читает offset из БД, а не из Kafka, обеспечивая exactly-once. -
Что делать, если бизнес-операция не откатывается при ошибке вставки?
Использовать семантику «at-most-once» для идемпотентности: если не можем проверить дубликат, лучше отказаться от обработки и положить в DLQ.
Как обрабатывать "poison pill" сообщения?
Краткий ответ (30–60 секунд)
Poison pill — сообщение, которое всегда вызывает ошибку обработки (неверный формат, отсутствие необходимых данных), блокируя поток. Обработка включает: быстрое обнаружение, ограничение повторных попыток (retry), перемещение в dead letter очередь и продолжение обработки последующих сообщений.
Развёрнутый ответ
Стратегия в Kafka: если сообщение при чтении не может быть десериализовано или базовые проверки провалены, консьюмер немедленно направляет его в DLQ топик, коммитит offset, не пытаясь retry бесконечно. Для временных ошибок (БД недоступна) retry с backoff. Для явных бизнес-ошибок — DLQ. Реализация:
for msg := range msgs {
if err := process(msg); err != nil {
if isPoisonPill(err) {
dlqProducer.Produce(dlqTopic, msg.Key, msg.Value)
consumer.CommitMessages(ctx, msg)
continue
}
// retry logic
}
}
В RabbitMQ при reject с requeue=false сообщение может быть направлено в DLX.
Важно отличать poison pill от временной ошибки. Для детекции — по типу ошибки или количеству неудачных попыток.
Под капотом (internals)
Быстрое обнаружение poison pill означает, что сообщение не должно долго задерживаться в основном логе. Если используется транзакционный outbox, нужно откатывать транзакцию и перемещать в DLQ отдельной короткой транзакцией. В Kafka это проще, так как можем записать в другой топик и сразу коммитить offset.
Типичные ошибки кандидатов
-
Retry poison pill с надеждой на успех
Если сообщение битое, оно никогда не обработается. Повторение только забивает очередь задержки и расходует ресурсы. Требуется быстрая смерть и DLQ. -
Отсутствие мониторинга poison pill
Poison pill могут указывать на баги в upstream сервисе. Без алертов проблема останется незамеченной. DLQ должен быть под мониторингом и иметь процесс для разбора. -
Использование DLQ, нарушающего порядок в партиции
Если poison pill находится в середине партиции и порядок критичен, нельзя просто пропустить. Нужно либо остановить партицию до исправления, либо перебросить все последующие сообщения в DLQ для ручного восстановления порядка (что редко приемлемо). Для таких случаев порядок обычно не критичен.
Возможные дополнительные вопросы интервьюера
-
Как автоматически переигрывать сообщения из DLQ после исправления?
Создать инструмент, который читает DLQ, применяет исправление и публикует в исходный топик с сохранением ключа. Важно сохранять offset или порядок, если нужно. -
Можно ли использовать Kafka Streams для фильтрации poison pill?
Да, можно создать stream, который фильтрует сообщения по ошибкам десериализации и отправляет в боковой топик, но это уже другая топология.
Как Kafka Streams и KSQL соотносятся с Go-сервисами?
Краткий ответ (30–60 секунд)
Kafka Streams — Java-библиотека для потоковой обработки внутри приложений; KSQL — SQL-движок поверх Kafka. Оба работают на JVM. В экосистеме Go аналогов уровня «из коробки» нет, но Go-сервисы могут взаимодействовать с ними как источники/приёмники данных через топики, либо выполнять похожую потоковую логику с помощью готовых библиотек (watermill, goka, или собственных решений на базе sarama).
Развёрнутый ответ
Go-сервисы не запускают Kafka Streams напрямую, но могут использовать результаты их работы. Например, Kafka Streams может агрегировать события и материализовать представление в компактном топике, которое Go-сервис читает как таблицу. Или Go-сервис публикует события в топик, обрабатываемые KSQL для аналитики.
Для случаев, когда нужна потоковая обработка в Go, можно использовать:
- Goka: библиотека, вдохновлённая Kafka Streams, реализующая группировку, таблицы и обработку в Go.
- Watermill: общая библиотека pub/sub с поддержкой Kafka, без stateful обработки.
- Своя реализация с sarama + состояние в RocksDB/Badger.
Под капотом (internals)
Kafka Streams внутренне использует RocksDB для локального состояния и репликацию изменений через специальные changelog топики. Go-аналоги могут использовать встраиваемые key-value базы для хранения состояния и восстановления при ребалансировке через change log партиции.
Типичные ошибки кандидатов
-
Попытка полностью заменить Kafka Streams на самописный сервис без учёта stateful обработки
Kafka Streams гарантирует exactly-once stateful операции; самодельное решение легко допускает потерю или дублирование состояния при сбоях. Нужно использовать проверенные библиотеки. -
Игнорирование ребалансировки при использовании Goka
Goka реализует свои механизмы ребалансировки через consumer group, но требует правильной настройки. -
Использование KSQL как основного обработчика вместо Go-сервисов, когда нужна сложная логика
KSQL хорош для простых трансформаций и агрегаций, но для сложной бизнес-логики лучше Go-сервис, взаимодействующий через топики.
Возможные дополнительные вопросы интервьюера
-
Как обрабатывать стримы с использованием Go без JVM?
Использовать Goka для stateful операций, либо разбить потоковую обработку на stateless Go-сервисы, а состояние хранить в БД и кэшировать. Для простых трансформаций достаточно sarama. -
Можно ли вызывать Go-сервис из топологии Kafka Streams?
Да, через custom processor, который делает HTTP/gRPC запрос. Но следует учитывать блокировки и таймауты. Лучше асинхронно через формирование топика-запроса и топика-ответа.
Как реализовать "saga" паттерн с помощью брокера?
Краткий ответ (30–60 секунд)
Saga — паттерн управления распределёнными транзакциями, при котором каждая операция сервиса публикует событие успеха или отказа; сервисы-участники реагируют на события, выполняя компенсационные действия при неудаче. Брокер сообщений играет роль шины событий, обеспечивая асинхронную координацию (хореография) или централизованный оркестратор, отправляющий команды.
Развёрнутый ответ
Два стиля:
- Хореография: каждый сервис, выполнив шаг, публикует событие (например,
OrderCreated,PaymentApproved). Следующий сервис подписан на событие и выполняет свой шаг. Если какой-то шаг проваливается, он публикует событие отмены (PaymentFailed), на которое реагируют предыдущие сервисы, запуская компенсацию.
В Go: сервис слушает топик payment_events, выполняет списание, при успехе производит PaymentProcessed в другой топик; при ошибке — PaymentFailed. Откатчики подписаны на PaymentFailed.
- Оркестрация: центральный Saga orchestrator (отдельный сервис) отправляет команды (напрямую или через очередь) и отслеживает ответы, управляя компенсацией.
Брокер гарантирует доставку команд/событий at-least-once, поэтому все операции должны быть идемпотентными и компенсации — идемпотентными.
Под капотом (internals)
При использовании Kafka события обычно хранятся в топиках, сгруппированных по агрегату (saga id). Порядок внутри саги важен, поэтому все события саги направляются в одну партицию по ключу saga_id. Это гарантирует последовательную обработку. Компенсирующие события должны также попадать в ту же партицию, чтобы сохранить порядок.
Типичные ошибки кандидатов
-
Отсутствие идемпотентности обрабатываемых операций
Из-за повторов событий может дважды списаться деньги. Каждая операция (и компенсирующая) должна проверять состояние саги и не выполнять повторно завершённые шаги. -
Попытка выполнить синхронные запросы в хореографии
Если сервис, получив событие, синхронно вызывает другой сервис — это создаёт тесную связь и каскадные сбои. Только события. -
Неправильное управление состоянием саги
Состояние саги должно храниться в БД или таблице KTable. Без этого невозможно определить, какие шаги выполнены и нужно ли компенсировать при таймауте.
Возможные дополнительные вопросы интервьюера
-
Как обрабатывать таймауты в саге?
Использовать scheduler или Kafka streams с punctuator, который проверяет незавершённые саги и инициирует компенсацию. Можно также публиковать отложенное событие таймаута, которое проверит статус. -
Что предпочесть: хореографию или оркестрацию?
Хореография проще для простых flow, оркестрация — для сложных, с ветвлением и централизованным контролем. В Go оркестратор может быть реализован как отдельный сервис с Kafka consumer/producer.
Как работать с google pub/sub в Go?
Краткий ответ (30–60 секунд)
Google Cloud Pub/Sub — полностью управляемый асинхронный сервис обмена сообщениями. Go клиент cloud.google.com/go/pubsub предоставляет высокоуровневый API для публикации и потребления с поддержкой flow control, батчинга, ручных и автоматических подтверждений. Главные сущности: Topic, Subscription, Message.
Развёрнутый ответ
Публикация:
topic := client.Topic("my-topic")
result := topic.Publish(ctx, &pubsub.Message{Data: []byte("payload"), Attributes: map[string]string{"key": "val"}})
id, err := result.Get(ctx)
Потребление (pull-based):
sub := client.Subscription("my-sub")
sub.ReceiveSettings.MaxOutstandingMessages = 100
err := sub.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) {
process(msg)
msg.Ack()
})
Поддерживает push подписки (HTTP endpoint), ordering key для порядка, dead lettering, retry policy, фильтрацию сообщений, exactly-once processing (на основе Pub/Sub exactly-once доставки на стороне подписки).
Под капотом (internals)
Pub/Sub внутренне использует многоуровневую архитектуру с дисковыми журналами и репликацией. Клиент Go реализует потоковое pull соединение через gRPC стриминг, получая сообщения от сервера партиями. Flow control контролируется MaxOutstandingMessages. При ack/nack клиент отправляет поток подтверждений. Подписки с включённым exactly-once используют уникальные идентификаторы сообщений и дедупликацию на стороне сервиса в течение окна (обычно 24 часа).
Типичные ошибки кандидатов
-
Игнорирование order key при необходимости порядка
Pub/Sub гарантирует порядок только если сообщения с одинаковымOrderingKeyпубликуются в регионе и включён ordering. Без этого порядок может быть нарушен. -
Долгий ack deadline при быстрой обработке
Если обработка быстрая, но deadline большой, задержка между публикацией и подтверждением избыточна, что может увеличить буферизацию. Лучше настраивать минимальный acceptable deadline. -
Неправильная обработка ошибок Receive
ФункцияReceiveвозвращает ошибку при закрытии контекста или критическом сбое. После recover нужно создать новую подписку/переподключиться. Не делать retry на том же объекте.
Возможные дополнительные вопросы интервьюера
-
Чем Pub/Sub отличается от Kafka?
Pub/Sub — полностью управляемый, бессерверный, с автоматическим масштабированием, без необходимости управлять партициями. Однако менее гибкий в настройках хранения (retention до 7 дней), дороже при больших объёмах, нет replay с произвольного offset. -
Как реализовать exactly-once обработку в Pub/Sub?
Включить exactly-once delivery на подписке; клиент должен использоватьmsg.IDдля дедупликации, а Pub/Sub гарантирует, что сообщение не будет перепослано после ack. Но из-за ретраев возможны дубли до ack, поэтому идемпотентность всё равно нужна.
Как реализовать "pull-based" vs "push-based" консьюмер?
Краткий ответ (30–60 секунд)
Pull-based потребитель активно запрашивает сообщения у брокера (Kafka poll, RabbitMQ basic.get, Pub/Sub pull). Push-based — брокер сам отправляет сообщения подписчику (RabbitMQ push API, Google Pub/Sub push subscription, NATS push). Pull даёт лучший контроль над скоростью обработки и backpressure; push проще в реализации, но может перегрузить потребителя.
Развёрнутый ответ
Pull:
- Клиент вызывает метод с таймаутом, брокер возвращает батч сообщений. Пример в Kafka:
consumer.Poll(). В Go-клиентах sarama/segmentio это цикл сFetchMessage. - Преимущества: потребитель сам решает, когда и сколько взять, обеспечивая естественный backpressure.
- Недостатки: дополнительная задержка на цикл опроса, сложнее для serverless.
Push:
- Брокер инициирует HTTP/gRPC вызов к потребителю. В RabbitMQ это
basic.consumeдоставляет сообщения в канал горутине. В Pub/Sub push — HTTP POST на зарегистрированный эндпоинт. - Преимущества: низкая латентность, простота (нет цикла ожидания).
- Недостатки: если потребитель медленный или недоступен, сообщения накапливаются и могут потеряться; требуется авторизация входящих соединений.
Под капотом (internals)
В Kafka pull модель реализована через FetchRequest. Консьюмер отправляет запрос с offset, брокер отвечает данными, которые уже есть, или ждёт до fetch.max.wait.ms, если данных нет. Благодаря этому достигается высокая эффективность. Push в Pub/Sub: сервис Pub/Sub отправляет HTTP POST запрос к endpoint подписчика, пересылая сообщение. Для масштабирования endpoint должен быть за балансировщиком. В RabbitMQ push: брокер открывает TCP соединение и отправляет кадры AMQP; flow control через channel.flow.
Типичные ошибки кандидатов
-
Реализация push в нестабильной сети без обработки ошибок доставки
Если push-эндпоинт не отвечает, брокер будет повторять, что может привести к дубликатам. Нужен idempotent consumer и мониторинг ошибок. -
Использование pull без таймаута
Бесконечное ожидание блокирует горутину и мешает graceful shutdown. Всегда нужно задавать контекст с таймаутом. -
Смешение моделей в RabbitMQ: pull API (basic.get) в высоконагруженных системах
Pull в RabbitMQ (basic.get) на каждое сообщение создаёт отдельный запрос, что крайне неэффективно. Push подписки с prefetch гораздо лучше.
Возможные дополнительные вопросы интервьюера
-
Когда выбирать push вместо pull?
Когда потребитель — HTTP-сервис без долгоживущих соединений (например, Cloud Functions), и мы можем масштабировать его под нагрузку. Или когда задержка важнее контроля. -
Как в pull модели балансировать между задержкой и throughput?
Установить разумныйfetch.max.wait.ms(5-10 мс) и небольшойmax.poll.records, чтобы быстро получать сообщения при небольшом потоке, но и не генерировать много мелких запросов.
Что такое "watermark" в стриминговых системах?
Краткий ответ (30–60 секунд)
В стриминговой обработке watermark — это метка времени, указывающая, что все события с временной меткой меньше watermark уже получены системой и обработаны. Используется для управления окнами и допуском к опоздавшим данным (lateness). В Kafka watermarks не встроены напрямую, но концепция важна при использовании потоковых фреймворков (Flink, Spark) или Kafka Streams.
Развёрнутый ответ
Watermark генерируется на основе временных меток событий и позволяет оконным операциям закрываться и выдавать результаты, даже если события приходят неупорядоченно. Обычно он отстаёт от максимальной observed timestamp на некоторую допустимую задержку (bounded out-of-orderness). Например, при допуске 5 секунд watermark = max(timestamp) - 5s. Когда watermark проходит конец окна, окно срабатывает.
В Kafka Streams (Java) watermark называется grace period. В Go при самостоятельной реализации стриминга нужно самому поддерживать watermark, анализируя время событий.
В Go-сервисе, потребляющем из Kafka, можно вычислять watermark для каждого источника на основе времени обработки или метаданных.
Под капотом (internals)
В распределённой системе watermark должен быть согласованным между параллельными обработчиками. Например, в Flink используется обмен watermark между тасками. В Kafka Streams локальный state store управляет временем. Для Go приложения watermark обычно нужен, если реализуется оконная агрегация с допуском опозданий. Из брокера Kafka можно использовать timestamp сообщения (CreateTime или LogAppendTime).
Типичные ошибки кандидатов
-
Игнорирование watermark и использование системного времени обработки
Приводит к неверным оконным результатам при перебоях или переработке исторических данных. Нужно использовать event time и watermark. -
Установка слишком маленького допустимого опоздания
Многие данные считаются опоздавшими и отбрасываются, теряя аналитическую ценность. Настройка должна быть на основе 99-го перцентиля задержки доставки.
Возможные дополнительные вопросы интервьюера
-
Как реализовать простую оконную агрегацию в Go с watermark?
Хранить события в map по Key, периодически проверять watermark (текущее max время минус задержка), и для всех окон, окончившихся до watermark, вычислять агрегат и отправлять результат. -
Где брать watermark в Kafka топике без встроенных средств?
Можно использовать метаданные продюсера — application timestamp, либо регулярно внедрять специальные системные сообщения с timestamp в каждый раздел.
Как работает segmentio/kafka-go и его преимущества?
Краткий ответ (30–60 секунд)
segmentio/kafka-go — идиоматичный, производительный Go-клиент для Kafka, полностью написанный на Go без зависимостей. Предоставляет высокоуровневые Reader/Writer, встроенный backpressure через канальный буфер, TLS/SASL, поддержку транзакционного API и cooperative rebalancing. Часто предпочтительнее sarama из-за простого API и низких накладных расходов.
Развёрнутый ответ
Ключевые компоненты:
- Writer (producer): поддерживает синхронные и асинхронные (пакетные) записи,
BatchTimeout,AllowAutoTopicCreation, настраиваемый балансировщик партиций. Пример:
w := &kafka.Writer{...}
w.WriteMessages(ctx, kafka.Message{Key: []byte("key"), Value: []byte("value")})
- Reader (consumer): pull-based с буфером
QueueCapacity. Автоматически управляет коммитами (ручной:CommitMessages, or auto). Поддерживает consumer group черезReaderConfig.GroupIDс Cooperative Sticky стратегией. - Conn — низкоуровневый доступ.
Преимущества: API, дружественный к Go (чёткое управление контекстом, возврат ошибок), отсутствие тяжёлых C-зависимостей, что упрощает сборку и деплой. Производительность достаточна для большинства задач; уступает confluent-kafka-go только в сверхвысоких throughput.
Под капотом (internals)
Внутреннее устройство Reader: отдельные горутины для fetch’а с брокеров, наполняющие буферный канал. Управление смещениями происходит через коммиты в __consumer_offsets. При ребалансировке Reader реализует протокол join/sync, переназначает партиции. Поддерживает счетчики метрик.
Writer отправляет сообщения партиями, используя эффективную сериализацию. Использует page cache? Нет, передача идёт через обычное сетевое соединение без sendfile, но за счёт чистого Go это приемлемо.
Типичные ошибки кандидатов
-
Не задан
QueueCapacityдля Reader, а обработка медленная
По умолчанию буфер невелик, что может приводить к блокам fetch. Стоит увеличить, если воркеров много. -
Использование
Writer.WriteMessagesв горячем цикле без батчинга
Каждый вызов может отправлять отдельный запрос, если не настроенBatchTimeout. Лучше использоватьWriteMessagesс слайсом из нескольких сообщений. -
Игнорирование ошибок
io.EOFпри закрытии Reader
При graceful shutdown Reader может возвращатьio.EOF, что не является ошибкой; необходимо обрабатывать его как сигнал завершения.
Возможные дополнительные вопросы интервьюера
-
Как segmentio/kafka-go реализует cooperative rebalancing?
УказываетсяGroupBalancerв конфиге, по умолчаниюRangeGroupBalancer, но поддерживаетсяCooperativeSticky. Нужно явно задатьkafka.CooperativeStickyBalancer. -
Какие ограничения у транзакций в segmentio/kafka-go?
Поддерживается транзакционный Writer, но не полностью интегрирован с consumer offset commit (как в Kafka Streams). Для exactly-once требуется дополнительная координация.
Как устроены "offsets" и "consumer lag" с точки зрения мониторинга?
Краткий ответ (30–60 секунд)
Offset — позиция консьюмера в партиции. Consumer lag — разница между последним записанным offset (high watermark) и последним закоммиченным offset. Это ключевая метрика здоровья консьюмера: постоянный рост lag означает, что потребитель не справляется. В Go доступ к метрикам обычно через клиентские библиотеки или экспорт в Prometheus.
Развёрнутый ответ
Мониторинг consumer lag критичен. В Kafka lag виден через kafka-consumer-groups CLI или JMX. В Go sarama предоставляет ConsumerGroupSession с Claims, но метрики нужно собирать самостоятельно: запрашивать у брокера end offset и сравнивать с текущей позицией. segmentio/kafka-go имеет встроенный ReaderStats, который включает Lag, Offset, MinBytes, MaxBytes.
Пример Prometheus экспортера на основе ReaderStats:
go func() {
for stats := range reader.Stats() {
lagMetric.WithLabelValues(stats.Topic, stats.Partition).Set(float64(stats.Lag))
}
}()
Можно также использовать сторонние экспортеры (например, kafka_exporter), которые работают независимо и собирают данные с брокеров.
Под капотом (internals)
High watermark offset обновляется, когда ISR подтверждает запись. Consumer offset обновляется при коммите. Lag = HW - ConsumerOffset. При 0 lag означает, что консьюмер обработал все сообщения. Если lag отрицателен (невозможно) или резко скачет, это аномалия. Внутренний топик __consumer_offsets хранит коммиты. Мониторинг обычно опрашивает брокер через OffsetFetch и OffsetForLeaderEpoch запросы.
Типичные ошибки кандидатов
-
Полагаться только на метрику lag без учёта времени обработки
Lag может быть приемлемым, но если задержка (время от публикации до обработки) большая, система всё равно медленная. Нужно отслеживать end-to-end latency. -
Не мониторить lag для idle consumer groups
Если группа неактивна, lag может накапливаться незаметно. Алерты должны учитывать ожидаемый трафик. -
Использовать автоматический коммит без учёта того, что lag отражает позицию коммита, а не фактической обработки
Несколько сообщений могут быть уже отправлены в обработку, но ещё не закоммичены; показываемый lag будет выше реального. Лучше использовать ручной коммит сразу после обработки батча.
Возможные дополнительные вопросы интервьюера
-
Как получить consumer lag напрямую из Go приложения без внешнего мониторинга?
Можно использовать AdminClient в sarama для запросаListConsumerGroupOffsetsиListOffsets, затем вычислить разницу. Это даёт оперативный контроль. -
Что делать при росте lag?
Масштабировать консьюмеры (если позволяет число партиций), оптимизировать обработку, увеличитьmax.poll.records, проверить backpressure на downstream сервисы.
Как работает "leader election" в Kafka и как это влияет на продюсеров и консьюмеров?
Краткий ответ (30–60 секунд)
Каждая партиция Kafka имеет лидера (брокера), который обслуживает все чтения и записи. Выборы лидера происходят автоматически при отказе узла через контроллер кластера, который избирает нового лидера из ISR. Продюсеры и консьюмеры должны определить нового лидера, что вызывает временные блокировки, повторы и возможные дубликаты.
Развёрнутый ответ
Управляющий узел (controller) следит за живучестью брокеров через ZooKeeper/KRaft. При отказе брокера, являющегося лидером партиции, контроллер выбирает нового лидера из in-sync реплик (ISR), обновляет метаданные и оповещает все брокеры. Продюсер получает метаданные о новом лидере и повторяет отправку (если acks=all и не все реплики подтвердили, может произойти дублирование). Консьюмер также должен найти нового лидера для чтения. Время обнаружения и выбора лидера влияет на задержки.
В Go-клиентах (sarama, segmentio) при ошибке подключения к лидеру запрашиваются свежие метаданные, и соединение переключается на нового лидера.
Под капотом (internals)
Контроллер инициирует State Change: для партиции создаётся LeaderAndIsr request, рассылаемый брокерам. Новый лидер переходит в режим обслуживания. Продюсер обрабатывает ошибку NOT_LEADER_FOR_PARTITION, запрашивает метаданные и ретраит. Важно: если acks=1 и лидер падает после записи, но до репликации, новое сообщение может быть потеряно. acks=all гарантирует, что сообщение было закоммичено как минимум в ISR, но увеличивает задержку.
Типичные ошибки кандидатов
-
Неадекватный retry без учёта leader election задержки
При немедленном retry ошибка NOT_LEADER может повториться, быстро исчерпывая попытки. Надо использовать backoff. -
Игнорирование возможности потери сообщений при unclean leader election
Если разрешёнunclean.leader.election.enable=true, лидером может стать реплика не из ISR, что приведёт к потере сообщений. В критичных системах это должно быть false. -
Продюсер не обновляет метаданные
После длительного перерыва метаданные устаревают, и продюсер пытается слать на старый брокер. Клиенты сами обновляют черезmetadata.max.age.ms, но в некоторых реализациях нужно следить.
Возможные дополнительные вопросы интервьюера
-
Как Kafka гарантирует консистентность при смене лидера?
Только реплики, которые были в ISR на момент предыдущего лидерства, могут стать новым лидером, обеспечивая наличие всех закоммиченных сообщений. Контроллер отслеживает ISR и выбирает одну из них. -
Как это влияет на exactly-once семантику?
TX продюсер использует coordinator транзакций; при смене лидера coordinator тоже переизбирается, что может привести к аборту транзакции. Клиент повторяет под управлением нового координатора.
Как реализовать "backpressure" с помощью каналов в Go для обработки потока сообщений?
Краткий ответ (30–60 секунд)
Backpressure в Go достигается с помощью буферизованных каналов ограниченной ёмкости, соединяющих этапы пайплайна. Когда канал заполнен, горутина-продюсер блокируется, замедляя чтение из брокера. Это естественно регулирует скорость. Комбинируем с контекстами и воркер-пулом для гибкого контроля.
Развёрнутый ответ
Типичный пайплайн:
messages := make(chan kafka.Message, 1000) // ограниченный буфер
// reader горутина
go func() {
for {
msg, err := reader.FetchMessage(ctx)
if err != nil { break }
messages <- msg // блокируется, если канал полон
}
close(messages)
}()
// worker пул
for i := 0; i < 10; i++ {
go func() {
for msg := range messages {
process(msg)
reader.CommitMessages(ctx, msg)
}
}()
}
Здесь канал выступает как очередь с ограничением. Если воркеры медленные, канал заполняется, FetchMessage блокируется — backpressure на брокер.
Более продвинутая техника: использование семафора для контроля параллелизма и каналов с обратной связью. Также можно использовать библиотеки типа errgroup для управления.
Под капотом (internals)
Каналы Go реализованы через структуру hchan с буфером. Когда буфер полон, отправитель блокируется. Это обеспечивает простую и эффективную координацию между горутинами без внешней блокировки. Однако важно избегать deadlock’ов (например, если worker паникует и не читает).
Типичные ошибки кандидатов
-
Небуферизованные каналы для высоконагруженной передачи
Каждая операция send/receive блокируется до парной, снижая throughput. Буфер в 100-1000 элементов поглощает всплески. -
Отсутствие механизма завершения worker’ов при закрытии канала
Если канал закрыт, worker’ы должны завершиться, иначе утечка горутин. Использоватьfor rangeили проверку контекста. -
Переполнение буфера без контроля памяти
Слишком большой буфер может занять много памяти при медленной обработке, вызывая OOM. Буфер должен быть соразмерен ожидаемой нагрузке и RAM.
Возможные дополнительные вопросы интервьюера
-
Как сделать динамическую регулировку размера буфера?
Обычно не делают; размер фиксирован. Для адаптации лучше менять количество воркеров или применять алгоритм token bucket. -
Как избежать блокировки всего пайплайна из-за одного медленного worker’а?
Использовать fan-out: один канал для множества воркеров; если один медленный, другие забирают сообщения. Но это нарушает порядок.
Как устроены "топики" (exchanges) в RabbitMQ (direct, fanout, topic, headers)?
Краткий ответ (30–60 секунд)
В RabbitMQ обменники (exchanges) — точки маршрутизации, которые принимают сообщения от продюсера и направляют их в одну или несколько очередей по правилам, основанным на routing key и заголовках. Типы: direct (точное совпадение ключа), fanout (во все привязанные очереди), topic (ключ с wildcards * и #), headers (маршрутизация по заголовкам).
Развёрнутый ответ
- Direct exchange: доставляет сообщение в очереди, чей binding key точно равен routing key. Используется для одноадресной маршрутизации.
- Fanout: игнорирует routing key, сообщение копируется во все связанные очереди. Идеален для широковещательной рассылки.
- Topic exchange: routing key — строка, разделённая точками; binding key может содержать
*(ровно одно слово) или#(ноль и более слов). Гибкая маршрутизация по шаблону, напримерorder.*.created. - Headers exchange: маршрутизация по аргументам заголовков (
x-match: all или any). Игнорирует routing key. Используется, когда нужна сложная фильтрация.
В Go (amqp091-go):
ch.ExchangeDeclare("my_exchange", "topic", true, false, false, false, nil)
ch.QueueBind("my_queue", "order.#", "my_exchange", false, nil)
ch.Publish("my_exchange", "order.123.created", false, false, msg)
Под капотом (internals)
Обменник — это запись в базе данных Mnesia (или в Raft для современных), которая содержит тип, имя, аргументы. Таблицы привязок связывают обменник с очередями. При публикации сообщение попадает в exchange, который по таблице находит подходящие очереди и доставляет. В случае fanout — просто итерация по всем привязкам.
Типичные ошибки кандидатов
-
Использование topic exchange без понимания нагрузки на message routing
Сложные шаблоны могут замедлить маршрутизацию; но на практике это незначительно, однако стоит избегать излишней сложности. -
Попытка привязать очередь к нескольким exchange
В RabbitMQ очередь может быть привязана к нескольким exchange, но это может привести к сложностям в понимании источников сообщений. Лучше проектировать чёткую структуру. -
Забыть про durability и auto-delete
При объявлении exchange безdurable: true, после перезапуска брокера exchange пропадёт, нарушив публикации.
Возможные дополнительные вопросы интервьюера
-
Как реализовать задержанную доставку в RabbitMQ?
Установить TTL на сообщение или очередь + DLX; плагин delayed message exchange. -
Чем headers exchange отличается от topic с заголовками?
Topic работает только с routing key, headers — с произвольными заголовками, не влияя на routing key. Headers менее производителен, но даёт больше гибкости.
Как работает "подтверждение публикации" (publisher confirms) в RabbitMQ?
Краткий ответ (30–60 секунд)
Publisher confirms — механизм гарантированной публикации в RabbitMQ: после включения режима подтверждения брокер асинхронно уведомляет продюсера о получении сообщения. Это позволяет продюсеру узнать, что сообщение достигло брокера и не потерялось, и предпринять действия при сбое.
Развёрнутый ответ
Включается вызовом ch.Confirm(false) (no-wait). После этого канал переходит в режим подтверждения: каждое опубликованное сообщение получает уникальный delivery tag. Брокер присылает basic.ack (сообщение принято) или basic.nack (отклонено, возможно, только если сообщение persistent и не может быть записано на диск) через канал, привязанный к продюсеру. Продюсер должен отслеживать ack/nack и в случае nack повторять отправку.
Go-реализация с amqp091:
confirms := ch.NotifyPublish(make(chan amqp.Confirmation, 1))
ch.Confirm(false)
err := ch.Publish(...)
// ...
conf := <-confirms
if !conf.Ack {
// republish
}
Обычно используется вместе с хранением неподтверждённых сообщений в map, чтобы при переподключении переотправить их.
Под капотом (internals)
Когда очередь, в которую попало сообщение, сохраняет его (если persistent) или просто принимает, брокер отправляет ack с delivery tag. В случае mirrored queue ack посылается после фиксации на большинстве узлов. Для быстрой публикации можно отправлять подтверждения асинхронно и использовать пакетную обработку.
Типичные ошибки кандидатов
-
Ожидание подтверждения синхронно на каждое сообщение в hot loop
Продюсер ждёт<-confirmsсразу после Publish, что резко снижает throughput. Нужно разнести: отправка пачками, а подтверждения собирать параллельно. -
Не обрабатывать закрытие канала
Если канал закрывается из-за ошибки брокера, то ack/nack не придёт, и продюсер зависнет. СлушатьNotifyCloseи пересоздавать канал. -
Игнорирование nack для transient сообщений
Даже transient сообщения могут быть nack в случае перегрузки памяти; необходимо обрабатывать.
Возможные дополнительные вопросы интервьюера
-
В чём разница между publisher confirms и транзакциями AMQP?
Транзакции блокируют канал до commit, снижая производительность. Publisher confirms асинхронны и более производительны, рекомендуются. -
Как переотправлять только неподтверждённые сообщения после переподключения?
Хранить отправленные сообщения в map[key:deliveryTag], после восстановления канала заново публиковать все неподтверждённые. При высоком темпе это сложно; лучше использовать идемпотентность.
Что такое "QoS" (prefetch count) в RabbitMQ и как его настроить?
Краткий ответ (30–60 секунд)
Quality of Service (prefetch count) ограничивает число неподтверждённых сообщений, которые брокер может отправить потребителю по каналу. Это ключевой механизм backpressure и справедливого распределения: предотвращает перегрузку медленных консьюмеров. Настраивается через channel.Qos(prefetchCount, prefetchSize, global).
Развёрнутый ответ
В RabbitMQ без QoS брокер отправляет сообщения как можно быстрее. Если потребитель медленный, сообщения накапливаются в сетевом буфере, что неэффективно. QoS решает это:
- prefetchCount: максимум неподтверждённых сообщений (обычно 1-100).
- prefetchSize: максимум октетов (0 — не используется).
- global: применять ли ко всему соединению или к каналу (обычно false).
Установка prefetchCount=1 обеспечивает round-robin распределение между консьюмерами: каждому по одному сообщению, следующий получает только после ack. Это балансирует нагрузку. Для повышения пропускной способности можно увеличить, но тогда обработка может стать неравномерной.
В Go:
ch.Qos(10, 0, false)
Под капотом (internals)
Брокер для каждого канала отслеживает delivery_tag и неподтверждённые сообщения. Когда число доставленных, но не подтверждённых, достигает prefetch, брокер приостанавливает отправку на этот канал. После получения ack освобождается квота. Это реализовано на уровне Erlang-процессов.
Типичные ошибки кандидатов
-
Установка prefetch=0 (без ограничений)
Нагрузка может неконтролируемо нарастать, вызывая out-of-memory у потребителя. Всегда задавать разумное ограничение. -
Слишком низкий prefetch при высокой задержке сети
Если обработка быстрая, но сеть медленная, prefetch=1 убьёт throughput, т.к. консьюмер будет простаивать в ожидании отправки/ack. Нужно увеличить. -
Использование global=true без понимания
При global=true лимит действует на все каналы соединения, что редко нужно. Обычно false.
Возможные дополнительные вопросы интервьюера
-
Как настроить prefetch для конкурентных потребителей на одном соединении?
Создавать отдельный канал для каждой горутины и настраивать QoS на каждом канале. Так каждый сможет получать свой набор сообщений. -
Влияет ли prefetch на порядок сообщений?
Порядок сохраняется для одного потребителя на очередь, но при prefetch > 1 параллельная обработка нарушить порядок может только сам потребитель, если обрабатывает асинхронно.
Как обрабатывать "network partition" в RabbitMQ кластере?
Краткий ответ (30–60 секунд)
Network partition (разрыв сети между узлами RabbitMQ) приводит к разделению кластера на изолированные части. RabbitMQ предлагает несколько стратегий: ignore (работать автономно, риск split-brain), pause_minority (приостановить узлы в меньшинстве), autoheal (перезапустить узлы меньшинства). Правильный выбор и настройка критичны для сохранности данных и доступности.
Развёрнутый ответ
При разделении сети узлы теряют связь. По умолчанию (ignore) обе стороны продолжают принимать соединения, что ведёт к расхождению данных. Лучшие практики:
- pause_minority: сторона с меньшим числом узлов приостанавливает работу (не принимает соединения), пока разделение не исчезнет. Минус: если кластер из 2 узлов, оба приостановятся (так как ни одна сторона не большинство). Требуется нечётное число узлов.
- autoheal: при восстановлении связи проигравшая сторона (по количеству узлов) перезапускается и теряет состояние. Это приводит к потере несохранённых сообщений.
- В критичных системах лучше использовать Pause minority и мониторинг.
Дополнительно: quorum queues (на Raft) более устойчивы, так как требуют консенсуса.
Под капотом (internals)
Механизм реализован в Erlang распределённой системе. Узлы обмениваются heartbeat’ами через net_ticktime. При недоступности узла генерируется событие partition, запускается выбранная стратегия. Pause minority приостанавливает все Erlang-процессы приложений, чтобы избежать split-brain.
Типичные ошибки кандидатов
-
Полное игнорирование настройки стратегии
Оставляют ignore, что в production приводит к неконсистентности данных после разрешения partition. -
Создание двухузлового кластера без понимания последствий
При pause_minority оба узла остановятся; при autoheal будет перезапуск и потеря данных. Минимум 3 узла для отказоустойчивости. -
Не мониторить partition и не использовать алерты
Разделённая сеть может долго существовать, нарушая бизнес, пока не вмешается человек.
Возможные дополнительные вопросы интервьюера
-
Как quorum queues меняют подход к partition?
Quorum queue использует Raft, поэтому при потере большинства узлов очередь становится недоступной для записи, предотвращая split-brain. Это предпочтительнее классических mirrored queues. -
Что такое split-brain и как его избежать?
Split-brain — ситуация, когда обе стороны считают себя главными и принимают записи. Избегается через pause_minority или Raft, где запись возможна только при наличии кворума.
Как работает "retention policy" в брокерах сообщений (Kafka, Pulsar)?
Краткий ответ (30–60 секунд)
Retention policy определяет, как долго брокер хранит сообщения. В Kafka основной механизм — временной или по размеру (log.retention.hours, log.retention.bytes) и log compaction. В Pulsar можно настроить хранение на уровне namespace: время, TTL, размер. Политика позволяет балансировать между необходимостью хранения истории и дисковым пространством.
Развёрнутый ответ
Kafka:
retention.ms(или hours) — удаление сегментов старше заданного времени.retention.bytes— удаление старых сегментов, если общий размер превышает лимит.cleanup.policy=compact— log compaction, оставляет последние значения по ключам, удаляя дубликаты.
Политика действует на уровне топика. Если не задана, наследуется от глобальных конфигураций. Удаление происходит асинхронно с учётом segment.ms. Консьюмер, читающий старые данные, получит ошибку OffsetOutOfRange, если offset был удалён.
Pulsar: retention управляется на уровне namespace: можно хранить данные после того, как они были подтверждены всеми подписками, задавая время и максимальный объём.
Под капотом (internals)
Kafka Log Manager проверяет сегменты партиций, сравнивая время последнего сообщения с retention. Подходящие сегменты удаляются. Для log compaction Log Cleaner строит компактные сегменты. Pulsar использует BookKeeper ledgers, удаляя старые по мере необходимости.
Типичные ошибки кандидатов
-
Установка слишком короткого retention без обсуждения с потребителями
Консьюмер, упавший на день, может пропустить данные. Retention должно быть больше максимального ожидаемого downtime. -
Забывают про retention.bytes, полагаясь только на время
Если трафик большой, диск может заполниться быстрее настроенного времени. Комбинация обеспечивает безопасность. -
Использование log compaction для топиков с большим количеством уникальных ключей
Чистильщик не справляется, потребляет много ресурсов.
Возможные дополнительные вопросы интервьюера
-
Что произойдёт, если потребитель отстал и offset за пределами retention?
Вернётся ошибка, придётся сбрасывать offset на earliest или latest. Это потеря данных. -
Как настроить бесконечное хранение в Kafka?
Установитьretention.ms=-1иretention.bytes=-1, но это опасно переполнением диска. Лучше использовать tiered storage (S3) в Kafka 3.0+.
Что такое "Pulsar" и чем он отличается от Kafka?
Краткий ответ (30–60 секунд)
Apache Pulsar — распределённая система обмена сообщениями и стриминга с разделённой архитектурой: хранение (BookKeeper) отделено от вычислительного уровня (брокеров). Это обеспечивает эластичное масштабирование, многоарендность, поддержку очередей и стриминга, geo-replication из коробки. В отличие от Kafka, где брокеры управляют и хранением, и вычислениями, Pulsar позволяет независимо масштабировать хранилище и обработку.
Развёрнутый ответ
Ключевые отличия:
- Архитектура: Kafka — coupled (данные на брокерах); Pulsar — двухуровневая: stateless brokers + BookKeeper (bookies) для хранения. Легче добавлять брокеры без перемещения данных.
- Модель подписки: Pulsar поддерживает Exclusive, Shared, Failover, Key_Shared подписки; можно в одном топике иметь несколько консьюмеров с разными моделями.
- Гарантии: Pulsar обеспечивает строгую дедупликацию и идемпотентность продюсера на уровне брокера.
- Multi-tenancy: иерархия tenant/namespace/topic изначально заложена.
- Geo-replication: встроенная, настраиваемая на уровне namespace.
Go клиент: github.com/apache/pulsar-client-go (ныне pulsar-client-go).
Под капотом (internals)
Pulsar хранит сообщения в сегментированных журналах (ledgers) в BookKeeper, распределённых по bookies с репликацией. Брокеры кэшируют данные от bookies. Потребительский offset хранится в отдельном топике metadata. Это позволяет быстро восстанавливаться после сбоев.
Типичные ошибки кандидатов
-
Перенос Kafka-паттернов на Pulsar один в один
Например, ожидание, что партиции эквивалентны, но в Pulsar партиция тоже есть, однако можно использовать Key_Shared для параллельной обработки без жёсткого числа консьюмеров = партиций. -
Игнорирование особенностей Geo-replication
Не все типы подписок поддерживают Geo-replication; нужна настройка async/sync.
Возможные дополнительные вопросы интервьюера
-
Когда выбирать Pulsar вместо Kafka?
Когда нужна гибкость в подписках, многоарендность, строгая дедупликация, отдельное масштабирование хранилища и вычислений, или встроенная geo-replication без дополнительных инструментов. -
Как Pulsar обеспечивает exactly-once?
Идемпотентный продюсер + использование Sequence ID. Потребители могут использовать Key_Shared и транзакции (поддержка транзакций появилась позже).
Как работает "async producer" и "sync producer" с точки зрения производительности?
Краткий ответ (30–60 секунд)
Sync producer блокирует вызывающий поток до получения подтверждения от брокера для каждого сообщения, что даёт простую семантику, но низкую пропускную способность. Async producer отправляет сообщения асинхронно, подтверждения приходят позже через callback или канал, позволяя достигать высокой пропускной способности и батчинга.
Развёрнутый ответ
В Kafka Go клиентах:
- SyncProducer (sarama):
SendMessageвызывает и ждёт ответ. Подходит для низких нагрузок, где важна простота. - AsyncProducer: сообщения отправляются в канал
Input(), результат — вSuccesses()/Errors(). Внутренний батчинг объединяет много сообщений в один запрос, что резко увеличивает throughput. Цена: более сложная обработка ошибок и потеря сообщений, если не читать канал ошибок.
Пример async:
for {
producer.Input() <- &sarama.ProducerMessage{Topic: "t", Value: sarama.ByteEncoder(msg)}
select {
case success := <-producer.Successes(): // подтверждено
case err := <-producer.Errors(): // ошибка
}
}
Async позволяет легко реализовать отложенную обработку и повысить throughput в 10-100 раз.
Под капотом (internals)
AsyncProducer в sarama под капотом содержит горутину-диспетчер, которая аккумулирует сообщения в буферы и отправляет их по триггеру (размер batch или таймаут). SyncProducer выполняет SendMessage за один синхронный вызов. В confluent-kafka-go sync/async поведение управляется через вызов Flush(), события доставки через delivery channel.
Типичные ошибки кандидатов
-
Использование sync producer для high-throughput системы
Каждый вызов ждёт сетевого round-trip, нагружая брокер и снижая пропускную способность до тысяч в секунду. -
Не читать канал Errors() у AsyncProducer
При заполнении канала внутренние буферы забиваются, продюсер блокируется или падает, сообщения теряются. Ошибки должны обрабатываться асинхронно с батчингом. -
Потеря сообщений при завершении без flush
AsyncProducer нужно обязательно закрывать с флагомClose()илиAsyncClose()и дожидаться drains.
Возможные дополнительные вопросы интервьюера
-
Как достичь баланса между производительностью и гарантиями в async?
ИспользоватьRequiredAcks = WaitForAll, контроль повторов, читать ошибки и ретраить. При этом оставаться async. -
Когда sync producer может быть предпочтительнее?
Когда каждое сообщение критично и нужно немедленно знать результат, в низкочастотных сценариях (например, фискальные транзакции).
Как бороться с "head-of-line blocking" на стороне консьюмера?
Краткий ответ (30–60 секунд)
Head-of-line blocking возникает, когда одно медленное или «ядовитое» сообщение задерживает обработку последующих в той же партиции. Борются с этим: 1) разделением обработки на независимые ключи через шардирование в горутинах, 2) детектированием poison pill и быстрой эвакуацией в DLQ без блокировки, 3) в крайнем случае, если порядок некритичен, можно перекладывать сообщение в другую очередь/топик.
Развёрнутый ответ
В Kafka консьюмер получает сообщения из партиции строго последовательно. Если одно сообщение обрабатывается 10 минут, всё остальное ждёт. Решения:
- Параллелизм по ключу: создаём map
key -> chan, каждая горутина обрабатывает свой ключ последовательно. Главный цикл читает сообщение и отправляет в канал по ключу, немедленно переходя к следующему. Это сохраняет порядок внутри ключа и предотвращает блокировку других ключей. - Таймауты и DLQ: если сообщение обрабатывается дольше допустимого таймаута, или бизнес-ошибка, переместить в DLQ топик и коммитнуть offset, не обрабатывая до конца.
- Использование топиков с большим количеством партиций + выбор key для равномерного распределения.
Под капотом (internals)
Потребительский poll возвращает батч сообщений. Можно обрабатывать батч конкурентно, но коммитить offset нужно с осторожностью: если offset 5 обработан, а offset 4 ещё нет, нельзя коммитить 5, т.к. в случае сбоя 4 потеряется. Для этого используют «скользящий окно» коммитов: отслеживают все непрерывно обработанные offsets и коммитят максимальный непрерывный.
Типичные ошибки кандидатов
-
Параллельная обработка всего батча с немедленным коммитом последнего
Нарушает гарантии at-least-once и порядок, вызывает потерю сообщений. -
Бесконечная блокировка на одном poison pill
Необходимо таймауты и отправка в DLQ, иначе партиция застрянет навсегда. -
Полагаться только на увеличение партиций
Не всегда возможно, и не решает проблему, если все сообщения идут с одним ключом.
Возможные дополнительные вопросы интервьюера
-
Как реализовать скользящее окно коммита в Go?
Использовать структуру данных, отслеживающую обработанные offsets, и при появлении непрерывной последовательности коммитить максимальный offset. Можно использоватьheapили отсортированное множество. -
Можно ли использовать несколько consumer groups для одной задачи, чтобы ускорить обработку?
Только если дублирование обработки допустимо. Обычно разделение бизнес-логики на независимые этапы и использование внутренних топиков лучше.
Что такое "choreography" vs "orchestration" в контексте очередей и саг?
Краткий ответ (30–60 секунд)
Choreography (хореография) — децентрализованный подход, где каждый сервис при завершении шага публикует событие, а другие сервисы, заинтересованные в этом событии, автономно выполняют свои действия. Orchestration (оркестрация) — централизованный координатор (saga orchestrator), который отправляет команды сервисам и отслеживает их выполнение, управляя компенсацией. Choreography проще и менее связана; orchestration даёт больше контроля и видимости.
Развёрнутый ответ
Хореография:
- Сервисы слабо связаны.
- Легко добавлять новых участников, подписываясь на события.
- Сложность в понимании общего потока и обработке ошибок: каждый сервис сам решает, когда компенсировать.
- Реализуется через брокер: Kafka топики с событиями.
Оркестрация:
- Оркестратор содержит логику всей саги, координирует шаги.
- Легче отслеживать состояние, управлять таймаутами.
- Недостаток: оркестратор становится единой точкой отказа и может увеличить связанность.
Выбор зависит от сложности: для простых flow — хореография; для сложных, с ветвлением и откатами — оркестрация.
Под капотом (internals)
В хореографии используются общие топики. Порядок событий гарантирован ключом саги. Оркестратор может быть реализован как stateful сервис с БД и Kafka consumer/producer. В Go оркестратор может использовать конечный автомат.
Типичные ошибки кандидатов
-
Хореография с синхронными вызовами
Превращает хореографию в оркестрацию, но распределённую, с проблемами таймаутов. -
Отсутствие обработки дубликатов событий в хореографии
Из-за at-least-once событие может быть обработано дважды, вызывая двойной бизнес-шаг. -
Оркестратор, не сохраняющий своё состояние
При падении теряет контекст саги.
Возможные дополнительные вопросы интервьюера
-
Как реализовать оркестратор в Go?
Создать сервис с таблицейsaga_instance, где хранится текущее состояние. Оркестратор слушает командный топик и топик ответов, обрабатывает конечный автомат. -
Какие паттерны компенсации используются в сагах?
Compensating transaction (компенсирующая транзакция), которая отменяет предыдущий шаг. Должна быть идемпотентной.
Как использовать "Redis Streams" в качестве легковесного брокера сообщений?
Краткий ответ (30–60 секунд)
Redis Streams — структура данных в Redis, реализующая append-only лог с потребительскими группами, ack-подтверждениями и диапазоном ID. Может выступать легковесным брокером с гарантией at-least-once. В Go используется клиент go-redis с командами XADD, XREADGROUP, XACK. Подходит для малых и средних нагрузок, простых задач, где не нужна персистентность на уровне Kafka.
Развёрнутый ответ
Пример использования:
// Producer
client.XAdd(ctx, &redis.XAddArgs{
Stream: "mystream",
Values: map[string]interface{}{"data": "value"},
})
// Consumer group init
client.XGroupCreateMkStream(ctx, "mystream", "group", "0")
// Consumer read
msgs, _ := client.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: "group",
Consumer: "consumer1",
Streams: []string{"mystream", ">"},
Block: 0,
})
for _, msg := range msgs[0].Messages {
process(msg)
client.XAck(ctx, "mystream", "group", msg.ID)
}
Поддерживает pending entries, retry через XCLAIM, dead letter (вручную).
Под капотом (internals)
Streams хранятся в виде radix tree с макросами для эффективного поиска по ID. Потребительские группы отслеживают последний доставленный ID и список ожидающих сообщений с временем бездействия. Это позволяет реализовать повторную обработку зависших сообщений. Однако Streams не рассчитаны на очень большие объёмы данных и не имеют встроенного партиционирования (можно шардировать вручную через несколько ключей).
Типичные ошибки кандидатов
-
Использование Redis Streams для долговременного хранения больших объёмов
Память Redis дорога и ограничена. Для больших потоков лучше Kafka/Pulsar. -
Забыть ограничить максимальную длину stream
БезMAXLENстрим будет бесконечно расти, потребляя память. Нужно установитьXADDсMAXLEN ~ 10000. -
Неправильная обработка pending сообщений
Если консьюмер упал после XREADGROUP, но до XACK, сообщение зависнет. Нужен периодический обход pending и повторная обработка или перенос в DLQ.
Возможные дополнительные вопросы интервьюера
-
Когда предпочесть Redis Streams вместо Kafka?
Когда уже есть Redis, нагрузка небольшая, нужна простота и низкая задержка, и допустимо ограниченное хранение. -
Как масштабировать консьюмеры Redis Streams?
Создавать несколько консьюмеров в одной группе, они будут распределять сообщения между собой, подобно consumer group Kafka, но без гарантии порядка.
Как реализовать "fan-out" на уровне брокера (Kafka topic -> multiple consumer groups)?
Краткий ответ (30–60 секунд)
Fan-out в Kafka достигается тривиально: разные consumer groups могут независимо читать один и тот же топик, каждая получая полную копию всех сообщений. Брокер хранит данные однократно, но каждая группа отслеживает собственный offset. Никакой дополнительной настройки не требуется — просто создаём группы с разными group.id.
Развёрнутый ответ
Это встроенная функция Kafka. Когда сервис A публикует в топик events, сервис B с group.id="serviceB" и сервис C с group.id="serviceC" получают все сообщения независимо. Если внутри группы несколько консьюмеров, они делят партиции, но для разных групп дублирования не происходит — каждая группа видит все партиции.
В RabbitMQ fan-out реализуется через fanout exchange и несколько очередей.
Под капотом (internals)
Брокер отслеживает offset для каждого (consumer_group, topic, partition). Когда консьюмер из группы запрашивает данные, ему выдаются сообщения от последнего закоммиченного offset этой группы. Таким образом, данные не дублируются физически, что крайне эффективно.
Типичные ошибки кандидатов
-
Путаница с партициями: думают, что fan-out требует больше партиций
Количество партиций влияет на параллелизм внутри группы, но не на возможность множественных независимых чтений. Можно иметь 1 партицию и множество групп. -
Создание огромного количества consumer groups без необходимости
Каждая группа требует хранения метаданных и offset в__consumer_offsets, что добавляет нагрузку. Но обычно это не проблема. -
Неверное понимание: использование одной группы для fan-out
Одна группа делит партиции, так что в сумме все сообщения будут обработаны, но каждый консьюмер получит только часть. Для fan-out нужны разные группы.
Возможные дополнительные вопросы интервьюера
-
Как ограничить доступ consumer group к партиции?
Права доступа задаются через ACL, но не через group. Можно фильтровать на уровне приложения, если нужно. -
Как реализовать fan-out с фильтрацией на брокере?
Kafka не предоставляет фильтрацию на брокере; фильтрация происходит на стороне потребителя. Для эффективной фильтрации можно направить разные типы событий в разные топики.
Как управлять "serialization format" (Avro, Protobuf, JSON) в сообщениях?
Краткий ответ (30–60 секунд)
Управление форматом сериализации включает выбор формата, регистрацию схемы (для Avro/Protobuf в Schema Registry), использование заголовков или версионирования для обеспечения прямой и обратной совместимости. В Go обычно применяют библиотеки Avro (hamba/avro), Protobuf (google.golang.org/protobuf) или encoding/json. Ключевой аспект — строгая эволюция схем без поломок потребителей.
Развёрнутый ответ
Рекомендации:
- Для внутренних сервисов с общим контрактом — Protobuf, так как он обеспечивает компактный бинарный формат, генерацию кода и совместимость с gRPC.
- Для долгоживущих событий, где много разных потребителей — Avro с Schema Registry, так как он позволяет гибкую эволюцию (добавление полей, default значения).
- Для простых случаев или где важна человекочитаемость — JSON, но с осторожностью: изменения ломают потребителей.
В сообщении желательно передавать идентификатор схемы или версию. В Kafka можно использовать кастомные заголовки (schema_id). Если в рамках одного топика могут быть разные схемы, нужна ветвь логики на основе заголовка.
Под капотом (internals)
Avro serialization/deserialization требует schema registry. Protobuf обычно встраивает схему в код. При использовании Schema Registry клиент кэширует схему. Сериализатор добавляет magic byte и schema ID перед данными.
Типичные ошибки кандидатов
-
Изменение JSON-структуры без координации
Консьюмеры ломаются на десериализации. В Protobuf/Avro добавление поля с default безопасно. -
Отсутствие стратегии для обработки неизвестных полей
В Protobuf по умолчанию неизвестные поля сохраняются, в JSON — потеряются. Нужно осознанное поведение. -
Хранение схемы внутри сообщения (self-describing)
Увеличивает размер сообщения и мешает централизованной эволюции.
Возможные дополнительные вопросы интервьюера
-
Как мигрировать схему в Kafka без даунтайма?
Добавить новое поле с default значением в Producer, обновить Consumer для игнорирования неизвестных полей, затем развернуть новую версию Producers, потом Consumers. -
Protobuf vs Avro: что выбрать?
Protobuf — если уже есть gRPC экосистема; Avro — если нужна более гибкая эволюция схем на этапе выполнения без перекомпиляции.
Как обеспечить "end-to-end latency" мониторинг для сообщений?
Краткий ответ (30–60 секунд)
End-to-end latency мониторинг измеряет время от публикации сообщения продюсером до завершения его обработки консьюмером. Для этого в сообщение включают временные метки на каждом этапе (producer ts, broker ingest ts, consumer start ts, consumer end ts), собирают их в централизованную систему телеметрии (Prometheus, Jaeger) и вычисляют перцентили задержки.
Развёрнутый ответ
Реализация:
- Продюсер добавляет заголовок
producer_timestamp(время до отправки). - В Kafka можно также записывать брокерский
LogAppendTime(enable на топике). - Консьюмер при получении фиксирует
consumer_receive_ts, после успешной обработки —consumer_done_ts. - Эти данные экспортируются в метрики и трейсинг (например, OpenTelemetry).
Пример метрики:
latency := time.Since(producerTimestamp)
metrics.Observe(latency)
С помощью трассировки можно сквозным контекстом покрыть все микросервисы.
Под капотом (internals)
OpenTelemetry позволяет передавать span context в заголовках Kafka. Библиотеки otelkafka интегрируются с sarama/segmentio. В metric pipeline можно настроить гистограммы с buckets. Важно отличать end-to-end latency от consumer lag. Lag — offset-based, не показывает время обработки одного сообщения.
Типичные ошибки кандидатов
-
Измерение только consumer lag и принятие его за latency
Lag не отражает время обработки внутри потребителя, задержки сети, и не измеряется в секундах. -
Отсутствие учёта разницы часов
Может быть рассинхронизация между серверами. Использование однообразного времени (UTC) и ntp критично. -
Слишком агрегированные метрики
Средняя задержка скрывает выбросы. Нужен p95/p99.
Возможные дополнительные вопросы интервьюера
-
Как внедрить трассировку в Kafka консьюмере на Go?
Использоватьotelkafka.NewHandler(segmentio) или sarama instrumentation, который создаёт span на каждое сообщение и связывает с producer span через propagation. -
Какие метрики стоит добавить помимо latency?
Throughput (сообщений в секунду), размер батча, ошибки обработки, retry rate, queue time (время между получением и началом обработки).