Полтора года назад я присоединился к проекту — Kotlin-микросервисы на Ktor, в проде около 20 сервисов. Messaging-слой был целиком на RabbitMQ. Всё работало, деплоились, жизнь была хороша. Пока нагрузка не выросла в 5 раз.
Начались проблемы: consumer lag, дублирование сообщений, потеря порядка. Я честно пытался починить RabbitMQ — пересматривал prefetch count, игрался с ack mode, ставил quorum queues. Но на определённом моменте стало ясно: мы упираемся в архитектурные ограничения брокера, а не в конфигурацию.
В этой статье я расскажу, чем Kafka и RabbitMQ отличаются на практике (не из документации, а из боли), покажу реальный код миграции на kafka-clients и дам честное сравнение — без холивара.
Как у нас был устроен RabbitMQ
Архитектура классическая: service A шлёт сообщения в exchange, routing key определяет очередь, service B потребляет. Для RPC использовали reply-to. Всё это обёрнуто в удобную обёртку на Kotlin:
class RabbitPublisher(
private val channel: Channel,
private val objectMapper: ObjectMapper
) {
fun publish(event: OrderCreatedEvent, routingKey: String) {
val message = objectMapper.writeValueAsBytes(event)
channel.basicPublish(
"orders-exchange",
routingKey,
AMQP.BasicProperties.Builder()
.contentType("application/json")
.deliveryMode(2) // persistent
.build(),
message
)
}
}Выглядело нормально. Но когда количество сообщений перевалило за 50k/мин, начали сыпаться тревоги: consumer lag рос, prefetch был либо слишком маленький (тормозило), либо слишком большой (OOM на consumers). А ещё мы столкнулись с тем, что порядок сообщений внутри одной очереди сохранялся, но при fanout на несколько очередей — уже нет.
Где RabbitMQ начал нас подводить
Три конкретные проблемы, которые стали триггером для миграции.
Consumer lag при пиковых нагрузках. У нас есть биллинг-сервис, который обрабатывает платежные события. В конце месяца — всплеск в 10x. RabbitMQ consumer не успевал, очередь росла. Мы добавляли инстансов, но ramp-up времени consumer'а составлял 30-40 секунд, а за это время в очереди накапливались миллионы сообщений.
Нет rebalancing'а как такового. В Kafka, если вы добавляете consumer в group, партиции перераспределяются. В RabbitMQ если у вас одна очередь и несколько consumer'ов — они делят сообщения round-robin, но нет концепции «эта партиция за этим consumer'ом навсегда». Это значит, что при добавлении consumer'а нет локальности кэша.
Replay сообщений. Для investigative purposes нам нужно было переигрывать события за определённый период. В RabbitMQ это боль — нужно либо дублировать в отдельную «archive» очередь, либо использовать shovel plugin. В Kafka это просто: consumer.seek(offset).
Миграция на Kafka: код
Мы решили мигрировать поэтапно: сначала продюсеры, потом консьюмеры. Для Kafka используем официальную библиотеку kafka-clients — она стабильно работает с Kotlin без обёрток.
Producer
class KafkaEventPublisher(
private val producer: KafkaProducer
) {
fun publish(event: OrderCreatedEvent) {
val key = event.orderId.toString()
val value = objectMapper.writeValueAsBytes(event)
val record = ProducerRecord(
"order-events",
key,
value
)
producer.send(record) { meta, ex ->
ex?.let {
logger.error("Failed to publish event", it)
producer.send(ProducerRecord("order-events-dlq", key, value))
}
}
}
} Consumer
class OrderEventConsumer(
private val consumer: KafkaConsumer,
private val orderService: OrderService
) {
fun start() {
consumer.subscribe(listOf("order-events"))
while (true) {
val records = consumer.poll(Duration.ofMillis(100))
for (record in records) {
val event = objectMapper.readValue(
record.value(), OrderCreatedEvent::class.java
)
try {
orderService.handleEvent(event)
consumer.commitSync()
} catch (e: Exception) {
logger.error("Processing failed for ${record.key()}", e)
sendToDlq(record)
}
}
}
}
} Обратите внимание: мы делаем commitSync() после каждого сообщения. В проде это можно оптимизировать через commitAsync() с batch'ем, но для начала sync-вариант надёжнее — вы не потеряете сообщение при краше, максимум обработаете дважды (idempotency — ваш друг).
Что изменилось после миграции
Consumer lag при пиковых нагрузках. Раньше: 2-3 часа отставания. Сейчас: максимум 2-3 минуты. Kafka позволяет потреблять параллельно по партициям — мы разбили топик order-events на 12 партиций, и 6 consumer'ов в группе спокойно справляются с пиком.
Replay стал тривиальным. Нужно переиграть события за 1 мая? Поднимаем consumer с нужным offset. Раньше это был отдельный механизм с архивной очередью и custom shovel-скриптами.
Порядок сообщений. В Kafka порядок гарантируется внутри одной партиции. Мы используем orderId как ключ партиционирования — значит все события одного заказа обрабатываются в порядке. В RabbitMQ мы имели порядок только в рамках одной очереди, но при fanout на несколько сервисов порядок терялся.
Честное сравнение
Вот таблица, которую я составил по итогам миграции. Не из документации — из реального опыта.
RabbitMQ лучше для: task queues, RPC-паттернов, когда нужен сложный routing (headers exchange, topic с wildcard), когда у вас до 10k сообщений/мин и нет жёстких требований к replay. Если вы строите event-driven архитектуру с сотнями тысяч событий — RabbitMQ будет сопротивляться.
Kafka лучше для: event streaming, когда важен порядок внутри группы, когда нужен replay и long-term storage событий, когда у вас высокие объёмы и нужно горизонтальное масштабирование consumers.
Мы не отказались от RabbitMQ полностью — он остался для RPC и task queues (например, отправка email-уведомлений). Kafka забрал event streaming: доменные события, аудит-лог, интеграционные события между сервисами.
Итог
Если бы я выбирал брокер сегодня — я бы задал себе три вопроса. Нужен ли мне replay? Нужен ли мне порядок? Какой объём сообщений ожидается? Если хотя бы на два ответа «да» — Kafka. Если на все три «нет» — RabbitMQ будет проще и дешевле в эксплуатации. Идеальный вариант, который мы в итоге реализовали — использовать оба: Kafka для event streaming, RabbitMQ для task distribution и RPC. Каждый инструмент решает свою задачу.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье