Привет, меня зовут Валентин, я NodeJS-разработчик в Сравни. Моя команда делает Profile Service — внутренний продукт, который отвечает за быстрое получение и запись личных данных пользователей для экосистемы Сравни. Мы взаимодействуем с 20+ продуктовыми командами, которые дают нагрузку на сервис порядка 200-300 RPS; порядок обрабатываемых записей в БД – десятки миллионов.

В какой-то момент мы решили внедрить Kafka – де-факто стандарт транспорта, работающий в миллионах проектов. Что может пойти не так? Оказалось – вообще всё что угодно. 

В этой статье я расскажу, с какими неочевидными проблемами мы столкнулись при переходе на Kafka у нас в продукте, как мы чинили баги в NestJS Microservices и какие выводы сделали (спойлер: Kafka – не всегда хорошее решение). 

Приступим!

Зачем нам Kafka

Profile Service у нас в Сравни — это цепочка из микросервисов, реализованная в монорепозиторие с помощью NestJS Microservices (подробнее – в статье на Хабре). Принцип работы такой: один запрос запускает несколько последовательных команд, обрабатываемых 6-7 разными микросервисами. Поэтому тема транспорта для нас важна по умолчанию.  

Ещё в нашем сервисе мы используем Broadcast-рассылки, передаём информацию о событиях и изменениях в разные внешние системы. Это +1 повод обратить пристальное внимание на транспорт. 

Наверное, самые популярные решения, которые приходит на ум для решения подобных задач — Rabbit или Kafka. Ещё есть gRPC, но в его случае мы столкнулись со сложностями описания Protobuf-схем (они у нас динамические), а ещё были вопросы к масштабированию. Я встречал точку зрения, что gRPC отлично скейтися — если вы тоже так считаете, напишите в комментариях об этом; у нас скейлинг gRPC не завёлся. 

“Так сложилось исторически”, что для нас основным транспортом был Rabbit. Он работал, но периодически подвисал, терял сообщения и в целом был не очень стабилен — мысли улучшить работу транспорта возникали регулярно.

Кроме того, у нас постоянно происходит интеграция новых продуктов с Profile Service. В какой-то момент мы начали планировать значительное увеличение нагрузки на сервис. 

Всё это подтолкнуло нас задуматься о смене транспорта. Rabbit у нас уже был и не очень устраивал, gRPC как будто не подходил из-за вопросов со скейлингом, напрашивалась Kafka — её мы и решили попробовать. 

Дополнительный аргумент в пользу выбора Kafka — это Enterprise-инструмент, который позволяет решать довольно широкий спектр задач. Так что помимо решения основной задачи с обновлением транспорта в Profile Service, дополнительно мы хотели ещё оценить Kafka как потенциальный основной транспорт для других проектов. 

Основное преимущество Kafka для нас (как мы думали на старте) — всё готово из коробки к высокой нагрузке: можно обрабатывать десятки, сотни тысяч, а то и миллионы сообщений. 

Также Kafka поддерживает откаты, то есть всегда можно посмотреть историю сообщений и откатиться к середине топика. В Kafka используется принцип First In, First Out; из коробки поддерживается сжатие информации (в том числе сжатие по схемам) – это упрощает задачу парсинга сообщений. 

На практике, конечно, всё оказалось не так просто. 

Что там у вас, NestJS? 

Первые проблемы нам любезно подкинул NestJS. В актуальной на тот момент версии 9.0.9 он содержал критические ошибки при работе с Kafka. После любых ошибок приложение зависало и переставало реагировать на запросы. Также мы столкнулись с проблемой партиций — консьюмеры теряли партиции в топиках при перевыкатке.

Чтобы разобраться с проблемами, мы стали смотреть, как вообще взаимодействуют NestJS и Kafka.

Допустим, у нас есть 6 микросервисов. У каждого микросервиса есть потребность получать сообщения и рассылать на них ответ. 

У нас есть сервис-менеджер, у которого есть функция form.create. Чтобы обратиться к этой функции и получить от неё ответ, необходимо в топик form.create отправить запрос, а сервер прочитает этот запрос и выдаст ответ в Reply-топик.  

Получаем:

  • Kafka-сервер исполняет функцию formCreate: слушает топик profile.form-create с запросами на выполнение этой функции и отдаёт ответ в profile.form-create.reply;

  • Kafka-клиент обращается к функции formCreate: делает запрос в profile.form-create и читает ответ в profile.form-create.reply.

Соответственно, для 6 микросервисов имеем: один микросервис отправляет запрос в один топик, тот – в другой, этот – в третий и так далее. Потом они перемещают ответ в обратную сторону, в Reply-топики. 

Сложность тут в том, чтобы все эти ответы не перепутались. При запросе клиент сразу же пытается подключиться к Reply-топику. Это необходимо, чтобы клиент получил партицию и знал, где именно он находится. А при отправке запроса к серверу в заголовках этого запроса клиент указывает, в какую партицию ему надо ответить. 

Собственно, это породило первую проблему — нужно очень точно рассчитывать количество партиций. Иначе может случиться ситуация, когда клиент не сможет подключиться к Reply-топику.

Предположим, у нас есть сервис, который имеет 10 точек входа – 10 функций, которые он обрабатывает. Любое взаимодействие с ним происходит через Remote Service. Критически важная особенность здесь — при запросе одного сервиса к одной из функций, сам сервис подписывается на ответы по всем существующим у него функциям и занимает минимум по одной партиции у Reply-топиков.

Это необходимо учитывать при расчёте партиции Reply-топика. В какой-то момент партиций может не хватить, и клиент не сможет подключиться к Reply-топику. Его место займут другие клиенты, которые могут этот топик даже не использовать – просто потому что они подключаются ко всем функциям. Дисклеймер: эта проблема актуальна именно для NestJS microservices, просто такая особенность. 

Также при работе с NestJS необходимо всегда помнить, что уникальность полученных данных при Broadcast-ссылках идет внутри определённой консьюмер-группы. То есть каждый под должен подключаться с уникальной консьюмер-группой, чтобы получить все данные.

Kafka-клиент при запросе сначала подключается к Reply-топику. Особенностью наших сервисов было наличие requestTimeout — после определенного количества секунд мы считаем, что запрос уже не актуален и отдаём ошибку. При некоторых условиях получалось следующее: при обращении к gateway нашего сервиса, один из микросервисов не успевал за эти секунды подключиться к своим Reply-топикам, и на первый запрос мы получали ошибку. 

То есть когда мы успеваем отвечать за первый запрос, всё в порядке. Когда не успеваем отвечать, все сервисы виснут. Мы долго не могли понять, что происходит: неправильно настроена Kafka? Пытались переустанавливать, использовать новые версии Kafka, собирать всё с нуля, покрывать код автотестами. Всё было нормально, работало. Но как только случалась какая-то ошибка — сервис не падал, а именно зависал и не делал ничего. 

Тогда я полез в Core-код самого NestJS. И там я увидел прекрасную вещь:

Если вы внимательно посмотрите на эту функцию, увидите, что есть одно место, в котором этот Promise не резолвится. И поэтому сервис зависал. Да, через пару минорных версий это уже было исправлено, но на тот момент эта ошибка была. 

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

Просто посмотрите на эти коммиты.

И тут же следом:

Так как эти исправления пришли уже позже, нам пришлось писать кастомный транспорт, который наследует Kafka-транспорт из NestJS, и переопределять неисправные методы. А еще, как видите, метод приватный, так что пришлось много методов переносить к себе в “библиотеку-надстройку” для переопределения метода с ошибкой. Это был первый звоночек, после которого надо было бы задуматься — может быть, не стоит продолжать с Kafka? 

Проблемы с задержкой ответа

Когда мы исправили эту ошибку в NestJS, сервис заработал. Правда, когда выкатились в прод, столкнулись с долгим ответом сервиса. Ооочень дооолгим. Откатились и стали разбираться, в чём дело.

Для начала сделали отдельный сервер, который просто тестировал запрос-ответ на основе NestJS. Раскатили его на Dev-окружении и увидели страшную картину: при 100 попытках среднее время исполнения запроса было 352 миллисекунды. А у нас, напомню, связка из 6 микросервисов — то есть, 352 миллисекунды умножить на 6. Для нас это было неприемлемым временем ответа. При этом транспорт иногда вообще отвечал по 6 секунд на один запрос, что было вообще фатальным значением. 

В этот момент мы крепко задумались и ради интереса параллельно стали проводить тесты, где вопрос-ответ по транспорту заменялся обычным HTTP-запросом к сервису.

Вводные: 100 попыток, запрос-ответ, единицы измерения – миллисекунды, один поток.

Окружение

Каfka 

Rabbit

HTTP

Облако: dev

Average

352

22.26

53

Max

5958

67

301

Localhost

Average

4.2

3.44

10

Max

52

25

-

Сравнение тестов Kafka VS Rabbit VS HTTP

Rabbit в таких же условиях показывал значение 22 мс. Это было странно, потому что предполагалось, что Kafka в разы быстрее, чем Rabbit. Но даже при локальном запуске время обработки вопроса-ответа было порядка 3-4 мс для Kafka и столько же для Rabbit при 100 запросах. 

Проблема таких огромных задержек была в неправильной настройке CPU на подах и возникающем троттлинге. Это совершенно другая история, но я хотел бы отметить её тут для понимания, что для подобных задач это имеет место

Когда мы решили проблемы с троттлингом, получились следующие значения для Kafka. Вводные: 100 попыток, запрос-ответ, единицы измерения – мс, один поток

Окружение

Каfka (было)

Каfka (стало)

Облачное dev

Average

352

35.44

Max

5958

299

Тесты Kafka без троттлинга


Здесь интересно подметить, что Rabbit никак не отреагировал на изменения (для HTTP результаты, самом собой, тоже никак не поменялись).

Но всё равно по сравнению с локальными 4 мс, значение 35 мс нам показалось странным. И тут мы вспомнили про количество партиций. Мы тестировали на тестовом топике, у которого количество партиций было выставлено по умолчанию – 10. А консьюмер у нас был один. Когда мы стучимся одним костюмером в топик, у которого 10 партиций, и когда мы стучимся тем же костюмером в этот же топик, но с 1 партицией, результат отличается в два раза. 

Это навело на мысль, что для максимальной производительности необходимо очень точно рассчитывать количество партиций в каждом топике. То есть смотреть, сколько у нас костюмеров будет читать, и столько партиций ставить — ни больше, и не меньше. 

Также мы подумали, что у нас могут быть проблемы с сетевыми задержками и начали экспериментировать с настройками. Мы пытались размещать сервис в разных зонах и подключаться к разным брокерам. Но никакого существенного прироста мы не получили.

Пробовали поиграться с гарантией доставки, но ввиду того, что мы стучались к одному брокеру, это тоже ни на что не повлияло. Вероятно, это влияет при более значительных нагрузках и при большем количестве подов. Отличие от настройки Acks (подтверждение для продюсера от брокера о доставке сообщения) составляло 1-2 мс.


Также заметили разницу <10 мс, если данные хранить не сжатыми и просто удалять ненужные.

В итоге настройки топиков были следующие:

Свойство

Значение

Комментарий

name

profile.form.create

profile – сервис, form – частный микросервис, create – функция

partition

3

по количеству инстансов микросервиса; количество топиков уменьшать нельзя 

cleanup_policy

CLEANUP_POLICY_DELETE

удаляем ненужные данные после заданного времени

compression_type

COMPRESSION_TYPE_UNCOMPRESSED

без компрессии, так быстрее

Проблемы с партициями и репликацией

Скорость 14 мс на запрос-ответ между двумя микросервисами нас устраивала. Мы выкатили приложение, и оно заработало. Полдня мы радостно наблюдали за прекрасными графиками. А в середине дня я решил добавить небольшой хотфикс, и при его выкатке сервис упал. Когда падает прод в общей суете бывает сложно понять, что конкретно стало причиной падения. Мы опять откатились на Rabbit и стали разбираться. 

Когда выкатывается сервис, он удерживает три старых пода и поднимает три новых. И только когда он проверит эти три новых пода, он начинает отключать старые. Так вот, в момент, когда три пода подключаются, все их Kafka-серверы пытались подключиться к своим топикам. И получалось, что у нас на три партиции, которые мы рассчитывали, было 6 консьюмеров. Соответственно, 3 консьюмера получали партиции, а 3 консьюмера не получали. Вроде бы здесь не должно быть проблем: когда старый консьюмер отключится, должен произойти ребаланс и новые консьюмеры должны получить новые топики. 

Но не тут-то было. Для таких ситуаций, чтобы не терять данные, в NestJS есть киллер-фича: запоминаются предыдущие партиции, на которых сидел сервис при подключении, при ребалансе происходит попытка подключиться на эти же партиции. Но дело в том, что эти новые 3 пода ещё не получали никаких партиций. NestJS простодушно запоминал null в строке с партициями и пытался в эту нулевую партицию подключиться. И получается, просто терял сообщение и не мог подключиться. Об этом даже есть issue

Чтобы решить эту проблему, мы опять переопределили кучу методов и поправили ошибку в Core-библиотеке.

В результате – сервис всё равно работал нестабильно. 

Мы долго думали, что происходит, а потом увидели следующую картину: на два Kafka-брокера у нас было три Zookeeper. Это были дефолтные параметры окружения. Один Zookeeper может поддерживать несколько брокеров, но информация об одном брокере могла храниться только на одном Zookeeper. То есть количество Zookeeper не должно быть больше, чем брокеров; если один Zookeeper может хранить два брокера, то один брокер может работать только с одним Zookeeper. 

В какой-то момент у нас упал один Zookeeper, и вместо него кластер переключился на другой Zookeeper, который ничего не знал о брокерах. Поэтому один брокер на какое-то время вышел из строя, а из-за того, что у всех топиков был replication-factor = 1, упавший брокер повлек за собой потерю соединений со всеми хранящимися в нём топиками. 

А из-за этого replication-factor = 1 другие брокеры не знали о топиках, которые были потеряны на том брокере. И поэтому в Kafka имел место огромный ребаланс, чтобы новых консьюмеров подключать к новым топикам. Мы подумали, что если бы у нас replication-фактор был правильный (равен количеству брокеров), у нас такие падения происходили бы бесшовно и мы бы их даже не замечали. 

Так зачем нам всё-таки Kafka

Полгода подобных мытарств позволили нам определить реальный список задач, где применима Kafka в нашем контексте. 

Во-первых, в случаях, когда нам нужен большой Broadcast – все Broadcast-рассылки мы будем оставлять на Kafka. 

Также необходимо учитывать, что Kafka позволяет обрабатывать сообщение и с середины, и с начала — откуда угодно. Если вам нужна история сообщений и возможность откатов, стоит выбрать Kafka. 

Не стоит забывать, что Kafka при больших объёмах данных умеет обрабатывать сообщения батчами. Если вам нужно обработать миллион сообщений, с батчами у вас это получится быстрее, чем без них. 

Kafka – прекрасный балансировщик. Если у вас есть у вас пять подов и разнородные задачи, Kafka будет не просто в пять партиций вкидывать сообщения — будет смотреть, как консьюмеры их считывают, и будет аккуратно нагружать консьюмеров, чтобы у вас был баланс между пятью подами, которые обрабатывают топик. На всякий случай предупрежу тут, что эта балансировка лучше работает, когда у вас совсем разнородные задачи или больше пяти подов. 

Попытка сделать 100 concurrency-запросов

Transactions

990 hits

Availability

62.94 %

Elapsed time

100.49 secs

Data transferred

6.99 MB

Response time

5.66 secs

Transaction rate

9.85 trans/sec

Throughput

0.07 MB/sec

Concurrency

55.74

Successful transactions

990

Failed transactions

583

Longest transaction

8.86

Shortest transaction

2.19

StatusCodeStats

- 201: { count: 958 }

  - 408: { count: 594 }

Результаты итогового тестирования Kafka

Сейчас Kafka у нас успешно используют коллеги из DevOps для – для транспорт логов из Kubernetes в Elastic; там много сообщений, всё работает стабильно и классно. 

Однако, в контексте нашего продукта мы не нашли использование Kafka оправданным. Большая часть функциональности – нам просто не нужна. У нас пока нет необходимости в откатах, а все задачи достаточно однородные, подов не так много, балансировка Kafka получается избыточной. 

Для себя мы решили использовать HTTP в качестве транспорта между микросервисами. Это более сбалансированное решение с точки зрения обслуживания и простоты реализации, хотя оно и менее отказоустойчиво. Для Broadcast-ссылок будем использовать Kafka. Мы всё-таки продолжим тесты по поводу репликации и троттлинга, но уже на тестовом окружении. Также будем продолжать дорабатывать Сore-библиотеку для работы с Kafka. 

Опыт – да, неудачный – нет

Даже в нашем случае, когда всё пошло не по плану, Kafka-инструментарий показал себя гибким – кажется, с его помощью можно собрать примерно всё. Любые семантики, доставки, Broadcast, не-Broadcast. 

Если сравнивать с нашим старым знакомым Rabbit, то у того есть красивая формализация всей настройки, которая идет через Exchange, а ещё Dead Lettering и много разных фишек, из которых вообще-то тоже можно собрать то, что тебе нужно, в разумных пределах.

Есть отличия в производительности. Если от Rabbit производительность можно получить в достаточно узких пределах, то в Kafka потенциально с этим гораздо лучше. 

А вообще, вся эта история с Kafka для нас стало репетицией перед реально большими нагрузками. Это точно был полезный опыт, все его наработки будем использовать в будущем. Так что спасибо Kafka, продолжаем (настороженно) дружить.  

Комментарии (10)


  1. lkoida
    10.10.2023 05:57
    +2

    По итогу, складывается ощущение что проблем больше доставил NestJS, а не Kafka.


    1. YegorP
      10.10.2023 05:57

      Там хватает таких мест. В седьмой версии столкнулись в пакете @nestjs/cqrs с тем, что любое исключение в @EventsHandler'е валит процесс.


      1. ptr128
        10.10.2023 05:57
        +2

        Не подскажете, в чем преимущество JS на бэке перед давно обосновавшимися там C# и Java?


        1. hardtop
          10.10.2023 05:57

          Скорее всего на JS изначально писали.


        1. spuR Автор
          10.10.2023 05:57

          По моему мнению, история JS на беке иногда начинается с историй про то, что удобно когда фронт и бек написан на одном языке, и можно на начальных стадиях развития проекта одним разработчиком закрыть сразу много "дыр", а потом уже разделяться на более глубокий бек и фронт.

          Могут еще так же затесаться истории про пороги вхождения в язык и т.п.


  1. ptr128
    10.10.2023 05:57
    +1

    Kafka для работы в режиме запрос-ответ - не лучший выбор. Хотя бы потому, что в ней нет для подписчиков push. Только poll. Отсюда и вытекает то, что чем больше сообщений за один poll будет обрабатывать подписчик - тем лучше.

    gRPC, но в его случае мы столкнулись со сложностями описания Protobuf-схем (они у нас динамические), а ещё были вопросы к масштабированию.

    Не понял, что значит динамическая схема. Как можно организовать коммуникацию сервисов без контрактов?

    А с масштабированием у gRPC проблем нет. Хотите - через балансировщик, хотите - через тот же Zookeeper.

    В общем случае. Kafka - массивы информации между сервисами. RabbitMQ - роутинг сообщений. gRPC - взаимодействие точка-точка.

    А в Вашем случае, когда задержки столько важны, я бы посмотрел в сторону Redis


    1. spuR Автор
      10.10.2023 05:57

      Динамическая схема - возможные поля описаны в БД в отдельной коллекции. В процессе мы дошли до того, что совершенно не страшно и быстро триггерить генерацию типов и схем при изменении бд разными хуками, но на тот момент - было как было.

      По поводу масштабируемости gprc - это вопрос момента. Видимо в тот момент у меня было недостаточно экспертизы в этом, были какие то проблемы в TS с ним, да и быстро окинув взглядом статьи разные - увидел много мнений, кто то говорил "сложно масштабировать", кто то говорил "не сложно".

      По поводу общего случая - для чего и что подходит - абсолютно согласен
      - для "точка точка" - пошли по более простому пути и по хттп стали стучаться от сервиса к сервису.
      - по поводу что Кафка изначально и не для этого - это понятно, просто необходимо было для исследования поведений Кафки в таких странных для нее случаях


      1. ptr128
        10.10.2023 05:57

        Динамическая схема - возможные поля описаны в БД в отдельной коллекции.

        И что с того? Ну будут все возможные поля в схеме. Protobuf эффективно эту проблему решает.

        триггерить генерацию типов и схем при изменении бд

        Точно так же можно генерировать схемы при деплое. У нас необходимые схемы автоматически создаются по метаданным БД и обновляются в schema registry при деплое.

        для "точка точка" - пошли по более простому пути и по хттп стали стучаться от сервиса к сервису

        gRPC из коробки делает это. Причем по HTTP/2, в бинарном сжатом виде, да еще и с поддержкой потоков как от клиента, так и от сервера.

        А чем все же Redis не устроил, раз уж так важны низкие задержки? Он существенно обгоняет и Kafka, и RabbitMQ.


        1. spuR Автор
          10.10.2023 05:57
          +1

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

          По поводу Redis - изначально цель была исследовать как из коробки работает NestJs Microservices, где было обещано бесшовное переключение транспорта между сервисами. Так как Кафка была в этой "коробке" - и так как необходимо было чуть поднять экспертизу по ней - выбрана была она, чтоб убить несколько зайцев сразу.
          Ну как оказалось - из коробки оно не работает, да и как и предполагалось, к сожалению, не подошло к вопросу ответу

          По gRPC - да спасибо, я внимательнее это все изучу - и так как он "в коробке неста" - попробую его затащить в будушем!


  1. azamatk503
    10.10.2023 05:57

    Так, если какое-либо событие в одном сервисе триггерит бизнес логику в другом, это уже не микросервисная архитектура, а распределенная. К слову, распределенная система создавала больше проблем, чем монолиты, что и привело к появлению микросервисной архитектуры.