Содержание
- Откуда берётся потребность в корпоративной шине
- Что такое событийная архитектура
- Kafka или RabbitMQ: когда что
- Партиции, консьюмер-группы и параллелизм
- Гарантии доставки: at-most-once, at-least-once, exactly-once
- Throughput и backpressure под нагрузкой
- Producer и consumer на Spring Boot
- Типичные грабли: потеря, дубли, ребалансировка
- Мониторинг кластера и потребителей
- Как внедрять: этапы и риски
- FAQ
В крупной организации поток интеграций растёт быстрее, чем кажется. Один из архитекторов на конференции описывал это так: сервисов стало около сотни, а интеграций между ними — уже тысяча. И каждый следующий цифровой продукт должен был сделать тысячу интеграций, обойти тысячу команд, согласовать тысячу форматов данных. Подключение одной новой системы занимало не месяцы, а полгода и больше. Так выглядит точка, в которой архитектура «каждый с каждым» (full mesh) перестаёт работать, и команда переходит к «звезде» — ставит в центр единый узел, с которым интегрируются все.
Этот центральный узел и есть корпоративная шина данных. Чаще всего сегодня её строят на Apache Kafka. Эта статья — практический разбор того, зачем enterprise событийная шина, чем Kafka отличается от RabbitMQ, как устроены партиции и гарантии доставки, какие грабли ждут под нагрузкой и как корректно подключить всё это к микросервисам на Spring Boot. Стек Java/Kotlin — именно тот, с которым мы в Новакоме работаем чаще всего.
Откуда берётся потребность в корпоративной шине
Пока сервисов мало, они общаются напрямую — по HTTP, gRPC, через общую базу. Это работает ровно до того момента, пока число связей не начинает расти квадратично. Раньше у каждого проекта была одна база данных, теперь — базы данных и очереди для коммуникации между сервисами. Без очередей не обходится уже ни один сколько-нибудь крупный проект.
У синхронных вызовов «сервис зовёт сервис» под нагрузкой есть три фундаментальных проблемы:
- Связанность по доступности. Если сервис B лежит, сервис A, который синхронно его дёргает, тоже встаёт или копит ошибки. Отказ распространяется по цепочке. Защита вроде circuit breaker помогает, но не убирает корень проблемы — жёсткую сцепку во времени.
- Нет естественного буфера под пик. Когда приходит всплеск, синхронный потребитель должен переварить его прямо сейчас. Очередь же гасит пик: продюсер пишет, а потребитель вычитывает в своём темпе.
- Точечные интеграции не масштабируются организационно. Каждая новая связь — это договорённость двух команд о формате и протоколе. На сотне сервисов это превращается в проект на полгода.
Корпоративная шина решает это, разворачивая взаимодействие: вместо того чтобы сервис A знал про сервис B, A публикует факт «произошло событие», а кто и как на него реагирует — уже не его забота. От топологии «каждый с каждым» команда уходит к топологии «все пишут в шину и читают из шины».
При этом важно трезво понимать, что шина — не бесплатный обед. Это, по сути, единая точка, в которую завязано всё. Инженеры крупных внедрений формулируют риск прямо: если центральная шина откажет, перестанет работать не какой-то отдельный кусочек, а весь бизнес целиком. Поэтому к корпоративной шине предъявляются жёсткие требования по управляемости, масштабируемости и отказоустойчивости — и именно их разбор занимает большую часть этой статьи.
Что такое событийная архитектура
Событийная архитектура (event-driven architecture) строится на одной идее: компоненты обмениваются фактами о произошедшем, а не командами. Разница принципиальная.
- Команда — «спиши 100 рублей со счёта». Адресная, синхронная, отправитель ждёт ответа и знает получателя.
- Событие — «со счёта списано 100 рублей». Это факт в прошедшем времени. Отправитель не знает и не хочет знать, кто его обработает: подписчиков может быть ноль, один или десять, и они могут появиться позже.
В Kafka событие — это запись (record) в топике (topic) — именованном логе. Producer пишет в топик, consumer'ы читают. Ключевое отличие Kafka от классической очереди: сообщение не удаляется после прочтения. Лог хранится по времени или размеру (retention), и разные потребители читают его независимо, каждый со своей позицией (offset). Это и даёт суперспособность шины: к потоку событий можно подключить нового потребителя — например, аналитику или поисковый индекс — и он вычитает историю с нужной точки, не трогая остальных.
Типичный pipeline на практике выглядит так: продюсеры пишут события в Kafka, оттуда они расходятся к потребителям. Часть данных уходит в реалтайм-обработку (например, Spark Streaming откусывает микробатчи прямо из топика), часть складывается в хранилище для batch-аналитики (в HDFS или озеро данных). Один и тот же поток событий обслуживает и онлайн-сервисы, и аналитику — без дублирования интеграций.
Важный нюанс, о котором часто забывают: порядок и обогащение. На практике корпоративной шине мало просто прокинуть байты — нужна возможность на лету трансформировать или обогащать данные между источником и потребителем. Это закрывается слоем Kafka Streams / ksqlDB или отдельными сервисами-трансформерами.
Подробнее общую картину highload-бэкенда мы разбирали в материале про архитектуру высоконагруженных систем на Java — событийная шина там один из несущих элементов.
Kafka или RabbitMQ: когда что
Это самый частый вопрос на старте, и на него нет универсального ответа — у инструментов разная природа. Грубо: RabbitMQ — это умный брокер с глупыми потребителями, Kafka — глупый брокер с умными потребителями и распределённым логом.
RabbitMQ реализует модель брокера сообщений с гибкой маршрутизацией. Его сила — в exchange'ах и правилах роутинга: можно строить сложную маршрутизацию по ключам, по топикам, через consistent hashing. У RabbitMQ богатая семантика доставки: подтверждения, отложенные сообщения, dead-letter очереди, приоритеты. Это «надёжный и древний» протокол (AMQP), проверенный в банковской сфере. Минус — пропускная способность и поведение на больших сообщениях: на сообщениях больше мегабайта и на потоках в миллионы в секунду RabbitMQ начинает упираться.
Kafka изначально создавалась как брокер для больших данных. У неё проще семантика доставки, чем у RabbitMQ — нет богатой маршрутизации на стороне брокера, — но за счёт этой простоты она надёжнее и кратно производительнее на потоке. В Kafka много продюсеров могут писать и много потребителей читать один лог. Реальные цифры с продакшена дают масштаб: кластер из пяти серверов спокойно принимал порядка 3 млрд событий в день, а под пиком big-data-обработки (прогон десятков петабайт по ночам) поток доходил до 3 миллионов сообщений в секунду — и кластер из 12 серверов справлялся, оставаясь почти недогруженным.
| Критерий | Apache Kafka | RabbitMQ |
|---|---|---|
| Модель | Распределённый append-only лог | Брокер сообщений (AMQP) |
| Маршрутизация | Простая: топик + партиция по ключу | Гибкая: exchange, routing keys, consistent hash |
| Хранение | Лог с retention, повторное чтение | Очередь, сообщение удаляется после ack |
| Пропускная способность | Очень высокая (млн/сек) | Средняя, упирается на пике |
| Большие сообщения | Плохо переваривает > 1 МБ | Тоже не любит, но гибче |
| Порядок | Гарантирован внутри партиции | Гарантирован внутри очереди |
| Повторное чтение / replay | Да, по offset | Нет (после ack сообщение ушло) |
| Семантика доставки | at-least-once, exactly-once (транзакции) | at-least-once, ручные ack/nack, DLQ |
| Где силён | Стриминг, event sourcing, аналитика, шина | Сложная маршрутизация, RPC, task-очереди |
Практическое правило выбора:
- Берите Kafka, если нужны высокий поток, хранение и повторное проигрывание событий, несколько независимых потребителей одного потока, аналитика и event sourcing — то есть классическая корпоративная шина.
- Берите RabbitMQ, если задача — распределение задач между воркерами, сложная адресная маршрутизация, request/reply поверх очередей, и поток умеренный.
И ещё одно наблюдение из практики: иногда соблазн «написать свой брокер на голых сокетах» или взять что-то ультра-лёгкое вроде ZeroMQ кажется привлекательным из-за пропускной способности из коробки. Но как только понадобятся персистентность очереди на диск, гибкая маршрутизация и консистентное хеширование, вы обречены написать свой брокер — и не факт, что он выйдет лучше зрелого Kafka или RabbitMQ. Для корпоративной шины это почти всегда плохая идея.
Партиции, консьюмер-группы и параллелизм
Вся масштабируемость Kafka держится на партициях (partitions). Топик физически разбит на несколько партиций — независимых упорядоченных логов. Это даёт две вещи сразу:
- Параллелизм. Партиции распределяются по брокерам кластера, запись и чтение идут параллельно.
- Единица упорядочивания. Порядок сообщений гарантируется внутри одной партиции, но не между ними. Если порядок важен (например, события по одному заказу), все они должны попадать в одну партицию — обычно через ключ сообщения (key). Kafka раскладывает записи по партициям через хеш ключа.
Консьюмер-группа (consumer group) — механизм горизонтального масштабирования потребления. Несколько экземпляров одного сервиса объединяются в группу с общим group.id, и Kafka делит партиции между ними: каждая партиция в любой момент читается ровно одним консьюмером группы. Отсюда фундаментальное ограничение: степень параллелизма потребления ограничена числом партиций. Если в топике 6 партиций, больше 6 активных консьюмеров в группе работать не будут — лишние простаивают. Поэтому число партиций закладывают с запасом под будущую нагрузку.
При этом несколько разных групп читают один топик независимо: сервис биллинга и сервис аналитики оба вычитывают все события, каждая группа со своими offset'ами.
Репликация отвечает за отказоустойчивость. У каждой партиции есть лидер и реплики (replication.factor). Продюсеры пишут лидеру, реплики синхронизируются. Если брокер с лидером падает, лидерство переходит к реплике, и поток не прерывается. Без репликации картина под нагрузкой неприятная: весь трафик может сойтись на один брокер, ему станет плохо, он перестаёт отвечать в таймаут, Kafka снимает с него нагрузку и перекидывает на другой — и тот тоже начинает захлёбываться. Нагрузку «дико колбасит» по нодам. Репликация (фактор хотя бы 3) сглаживает это: помогает Kafka не ошибаться при распределении нагрузки. Но за балансировкой всё равно нужно следить руками — Kafka может ошибаться, и ваша задача ей в этом помогать.
Гарантии доставки: at-most-once, at-least-once, exactly-once
Один из главных источников боли — непонимание, какие гарантии вы реально настроили. Гарантий три.
- At-most-once (не более одного раза). Сообщение либо доставлено, либо потеряно, дублей нет. Получается, если консьюмер коммитит offset до обработки: упал в процессе — сообщение пропало. Подходит для метрик и телеметрии, где потеря отдельной записи не критична.
- At-least-once (хотя бы один раз). Сообщение точно не потеряется, но может прийти повторно. Это режим по умолчанию для надёжных систем: продюсер ретраит при сбое, консьюмер коммитит offset после обработки. Цена — возможные дубли, с которыми борются идемпотентностью (см. ниже).
- Exactly-once (ровно один раз). Ни потерь, ни дублей. В Kafka достигается через идемпотентного продюсера (
enable.idempotence=true) и транзакции — атомарную запись «прочитал из топика A → записал в топик B → закоммитил offset». Работает по-настоящему только в пределах Kafka (паттерн read-process-write). Стоит дороже по latency и сложности.
Критичный момент: exactly-once в Kafka не распространяется автоматически на внешние системы. Если консьюмер пишет в БД и шлёт письмо, Kafka-транзакция не откатит письмо. Для связки «база + Kafka» правильное решение — не пытаться натянуть распределённую транзакцию, а использовать паттерн Outbox: сервис в одной локальной транзакции пишет и бизнес-данные, и запись в таблицу-outbox, а отдельный процесс надёжно доставляет её в Kafka. Это даёт согласованность без двухфазного коммита и снимает большую часть проблем с потерей и дублированием.
Throughput и backpressure под нагрузкой
Производительность Kafka на стороне продюсера определяется батчингом. Продюсер не шлёт каждое сообщение по отдельности — он копит их в буфер и отправляет пачками. Управляют этим два параметра:
batch.size— максимальный размер батча на партицию;linger.ms— сколько ждать, добивая батч, прежде чем отправить. Небольшойlinger.ms(5–20 мс) заметно поднимает throughput ценой минимальной задержки.
Под нагрузкой узких мест обычно три, и за всеми тремя нужно следить: сеть, диски и распределение нагрузки по партициям. Диски и сеть упираются раньше CPU. Гигабитные пики на чтение (в реальном кейсе — порядка 5 Гбит/с на пять машин) — это в первую очередь нагрузка на сеть и дисковый I/O.
Backpressure — что происходит, когда потребитель не успевает за продюсером. У Kafka здесь архитектурное преимущество перед синхронными вызовами: лог уже является буфером. Если consumer тормозит, сообщения не теряются и не давят на продюсера — они просто копятся в логе, а растёт consumer lag (отставание потребителя, разница между последним offset в партиции и закоммиченным offset группы). Lag — главная метрика здоровья потребления. Стратегии борьбы с растущим lag:
- добавить консьюмеров в группу (до числа партиций);
- увеличить число партиций, если упёрлись в потолок параллелизма;
- ускорить обработку (батч-вставки в БД вместо построчных, асинхронные вызовы);
- настроить
max.poll.recordsтак, чтобы консьюмер успевал обработать пачку заmax.poll.interval.ms— иначе он будет считаться зависшим и группа начнёт ребалансировку.
Отдельно стоит держать в голове ограничение по размеру сообщения. Kafka плохо переваривает записи больше мегабайта — на крупных payload'ах деградирует производительность. Большие объекты (файлы, изображения) кладут в объектное хранилище, а в Kafka шлют только ссылку и метаданные.
Producer и consumer на Spring Boot
В экосистеме Spring работу с Kafka закрывает spring-kafka. Базовая конфигурация продюсера с упором на надёжность:
spring:
kafka:
bootstrap-servers: kafka-1:9092,kafka-2:9092,kafka-3:9092
producer:
acks: all # ждать подтверждения от всех in-sync реплик
retries: 10
properties:
enable.idempotence: true # защита от дублей при ретраях
max.in.flight.requests.per.connection: 5
linger.ms: 10
batch.size: 32768
consumer:
group-id: billing-service
enable-auto-commit: false # коммитим вручную, после обработки
auto-offset-reset: earliest
max-poll-records: 200
listener:
ack-mode: manual # ручное подтверждение
concurrency: 6 # число потоков = числу партиций
Ключевая настройка надёжности — acks=all в связке с enable.idempotence=true. acks=all гарантирует, что запись подтверждена всеми синхронными репликами (не потеряется при падении лидера), а идемпотентность продюсера убирает дубли, которые иначе появились бы из-за ретраев.
Продюсер событий на Kotlin:
@Service
class OrderEventPublisher(
private val kafkaTemplate: KafkaTemplate<String, OrderEvent>,
) {
fun publish(event: OrderEvent) {
// ключ = orderId: все события одного заказа попадают в одну партицию,
// а значит обрабатываются строго по порядку
kafkaTemplate.send("orders.events", event.orderId, event)
.whenComplete { result, ex ->
if (ex != null) {
log.error("Не удалось отправить событие ${event.orderId}", ex)
} else {
val md = result.recordMetadata
log.debug("Отправлено в ${md.topic()}-${md.partition()}@${md.offset()}")
}
}
}
}
Консьюмер с ручным подтверждением offset и идемпотентной обработкой:
@Component
class OrderEventConsumer(
private val processedStore: ProcessedEventStore,
private val billingService: BillingService,
) {
@KafkaListener(topics = ["orders.events"], groupId = "billing-service")
fun onMessage(record: ConsumerRecord<String, OrderEvent>, ack: Acknowledgment) {
val event = record.value()
// Идемпотентность: at-least-once гарантирует, что событие
// может прийти повторно — гасим дубль по уникальному eventId
if (processedStore.alreadyHandled(event.eventId)) {
ack.acknowledge()
return
}
try {
billingService.handle(event) // бизнес-логика
processedStore.markHandled(event.eventId)
ack.acknowledge() // коммитим ТОЛЬКО после успеха
} catch (e: TransientException) {
// не коммитим — сообщение придёт снова после повторного poll
log.warn("Временный сбой по ${event.eventId}, повтор позже", e)
throw e
}
}
}
Принципиальный момент здесь — порядок: сначала обработка, потом ack.acknowledge(). Если поменять местами (коммит до обработки), вы получаете at-most-once и риск потери сообщения при падении. Для «отравленных» сообщений (poison messages), которые не обрабатываются никогда, в spring-kafka настраивают DefaultErrorHandler с ограниченным числом ретраев и отправкой в dead-letter-топик — иначе один битый record заблокирует партицию навсегда.
Типичные грабли: потеря, дубли, ребалансировка
За годы внедрений набор болей повторяется. Вот основные.
Потеря сообщений. Главные причины — acks=1 (или 0) на продюсере и коммит offset до обработки на консьюмере. При acks=1 подтверждение приходит от лидера до того, как реплики синхронизировались: лидер падает — данные теряются. Лечение: acks=all, replication.factor>=3, min.insync.replicas=2, ручной коммит после обработки.
Дубли. Неизбежны при at-least-once: продюсер ретраит, консьюмер мог упасть между обработкой и коммитом. Бороться с дублями на стороне брокера бессмысленно — нужна идемпотентность обработки: уникальный eventId в каждом событии и проверка «уже обрабатывали?» перед применением. Это дешевле и надёжнее, чем гнаться за полным exactly-once.
Иллюзия exactly-once. Команды нередко считают, что включили exactly-once, хотя реально настроили at-least-once. Транзакции Kafka работают только внутри Kafka (read-process-write). Любая запись во внешнюю систему — БД, платёжный шлюз, почта — выпадает из транзакции. Для согласованности с базой используйте Outbox, а не пытайтесь имитировать распределённую транзакцию.
Ребалансировка (rebalance). Когда консьюмер входит в группу, выходит или признаётся зависшим, Kafka перераспределяет партиции. Во время ребалансировки потребление встаёт (stop-the-world у классического протокола). Частые ребалансировки — типичная причина «дёрганого» потребления и роста lag. Причины обычно две: обработка одной пачки дольше max.poll.interval.ms (консьюмер не успел сделать следующий poll и его выкинули) и нестабильные heartbeat'ы. Лечение: уменьшить max.poll.records, ускорить обработку, при необходимости вынести тяжёлую работу в отдельный пул. В новых версиях помогает кооперативный ребаланс (CooperativeStickyAssignor), который не останавливает всю группу разом.
Перекос партиций. Если ключ сообщения распределён неравномерно (например, 80% событий с одним и тем же ключом), одна партиция перегружена, остальные простаивают. Под нагрузкой это выглядит как «колбасит по нодам». Решение — продуманный выбор ключа партиционирования.
Мониторинг кластера и потребителей
Главное правило эксплуатации: Kafka может ошибаться в распределении нагрузки — вы должны ей в этом помогать, а для этого за ней надо непрерывно наблюдать. Минимальный набор того, что обязательно держать на дашбордах:
- Consumer lag по группам и партициям — метрика номер один. Растущий lag означает, что потребление отстаёт; если он растёт устойчиво — система не справляется с потоком.
- Распределение нагрузки по брокерам/нодам — bytes in/out, чтобы ловить перекос, когда трафик сходится на один брокер.
- Сеть и диски — именно они упираются раньше CPU. Дисковый I/O и сетевой throughput на каждом брокере.
- Under-replicated partitions — партиции, у которых реплики отстали или недоступны. Ненулевое значение — прямой риск потери данных при падении брокера.
- Частота ребалансировок в группах — всплески указывают на нестабильных консьюмеров.
Стандартный стек — JMX-метрики брокеров и клиентов, собираемые в Prometheus, дашборды в Grafana, плюс инструмент для визуального контроля топиков и lag (Kafka UI, Conduktor, Burrow для алертинга по lag). На дашборде хорошо видно характерный паттерн ночной big-data-нагрузки: поток вырастает кратно, lag временно растёт и затем разбирается, ноды дают пиковую нагрузку по сети и дискам. Если этот паттерн знаком и предсказуем — кластер спланирован верно; если lag не разбирается до следующего пика — пора добавлять партиции или ускорять потребителей.
Как внедрять: этапы и риски
На практике мы рекомендуем такую последовательность.
- Определить событийную модель. Не «как прокинуть данные из A в B», а какие факты существуют в домене: «заказ создан», «платёж проведён». Топики проектируются вокруг событий предметной области, а не вокруг конкретных интеграций.
- Развернуть кластер с запасом по надёжности. Минимум 3 брокера,
replication.factor=3,min.insync.replicas=2. Число партиций на топик — с запасом под будущий параллелизм, потому что увеличивать партиции позже больно (ломается раскладка по ключу). - Подключать сервисы по одному. Начать с некритичного потока, отладить продюсера, консьюмера, lag и мониторинг, и только потом двигаться к ключевым системам.
- Сразу заложить идемпотентность и Outbox. Это не «оптимизация на потом»: at-least-once и дубли — режим по умолчанию, обработка должна быть готова к повторам с первого дня. Запись в Kafka из сервисов с базой — через Outbox.
- Настроить мониторинг до продакшена. Lag, under-replicated partitions, нагрузка по нодам должны быть на дашбордах до того, как пойдёт реальный трафик.
Риски, которые стоит закрыть заранее:
- Шина как единая точка отказа. Самый серьёзный риск: если центральный узел встанет, встанет всё. Закрывается репликацией, многонодовым кластером, мониторингом и продуманной деградацией сервисов.
- Большие сообщения. Не пихать в Kafka payload больше мегабайта — ссылка на объектное хранилище вместо тела.
- Нет схемы данных. Без контракта на формат событий (Schema Registry, Avro/Protobuf) потребители ломаются при каждом изменении продюсера. Эволюцию схемы нужно продумать заранее.
- Кадры. Эксплуатация Kafka под нагрузкой требует людей, которые понимают партиционирование, ребалансировку и тюнинг. Это не «поставил и забыл».
В многотенантных продуктах добавляется отдельный слой вопросов — изоляция потоков событий между арендаторами; мы разбирали это в материале про мультитенантную архитектуру на Spring Boot. А в биллинговых сценариях событийная шина становится несущей конструкцией для тарификации и сверки — она же SaaS-биллинг на Spring Boot.
Событийная шина — это не разовый проект, а инфраструктурный слой, на который потом ложатся аналитика, event sourcing, интеграции и отказоустойчивость всего ландшафта. Поэтому строить его стоит руками команды, которая понимает и Kafka, и эксплуатацию highload в продакшене.
FAQ
Когда брать Kafka, а когда RabbitMQ? Kafka — для высокого потока, хранения и повторного проигрывания событий, нескольких независимых потребителей одного потока, стриминга и аналитики, то есть для корпоративной шины. RabbitMQ — для сложной адресной маршрутизации, request/reply и распределения задач между воркерами при умеренном потоке. На потоках в миллионы сообщений в секунду и больших объёмах Kafka выигрывает; на гибкой маршрутизации — RabbitMQ.
Гарантирует ли Kafka, что сообщение не потеряется?
Только при правильной настройке. Нужны acks=all на продюсере, replication.factor не ниже 3, min.insync.replicas=2 и ручной коммит offset на консьюмере после обработки. С acks=1 и автокоммитом сообщения теряются при падении брокера или консьюмера.
Как бороться с дублями сообщений? Дубли неизбежны в режиме at-least-once (по умолчанию для надёжных систем). Правильный путь — идемпотентная обработка: уникальный идентификатор события и проверка «уже обрабатывали?» перед применением. Полный exactly-once в Kafka работает только внутри Kafka; для согласованности с базой используйте паттерн Outbox.
Сколько партиций делать в топике? Число партиций задаёт потолок параллелизма потребления: больше консьюмеров в группе, чем партиций, работать не будут. Закладывайте с запасом под будущую нагрузку, потому что увеличивать число партиций позже болезненно — меняется раскладка сообщений по ключу. Ориентир — целевой throughput, делённый на пропускную способность одного консьюмера.
Что такое consumer lag и почему за ним следят? Lag — это отставание потребителя: разница между последним offset в партиции и закоммиченным offset группы. Это главная метрика здоровья потребления. Устойчиво растущий lag означает, что сервис не успевает за потоком, и нужно добавлять консьюмеров, увеличивать партиции или ускорять обработку.
Если вы проектируете корпоративную шину данных или переводите ландшафт на событийную архитектуру, мы в Новакоме строим highload-системы на Java/Kotlin — от модели событий и тюнинга Kafka до интеграции в микросервисы на Spring Boot и мониторинга в продакшене. Посмотрите наши услуги по разработке на Spring и аутстаффингу Java-команд или напишите нам с описанием вашего контура.