В 1967 году Мелвин Конвей сформулировал известный тезис, без упоминания о котором не обходится практически ни одно руководство по созданию микросервисной архитектуры. И не напрасно, ведь не одно поколение разработчиков сталкивалось с его подтверждениями.

Но если структура коммуникаций компании меняется из-за развития и выхода на новые международные рынки, то приходится менять сам продукт для соответствия потребностям пользователей этих рынков.

Постановка задачи

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

 В новых реалиях нужно было решить несколько моментов:

  • упростить текущую архитектуру избавившись от избыточного перекладывания данных между сервисами

  • улучшить время прохождения данных до конечного пользователя

  • переформатировать команды для разработки и поддержки нового решения

Какое решение нашли 

Исторически так сложилось, что основным стеком бэка в нашей компании стал .NET: за годы существования компании была накоплена значительная кодовая база, библиотеки для реализации вспомогательного функционала и экспертиза разработки на C#. Поэтому при разработке мы хотели максимально применить предыдущий опыт и не переходить на новый стек без необходимости.

Мы начинали писать сервис с использованием .NET Core 3.1, а позже довольно быстро и безболезненно мигрировали на .NET 5. Хостятся все наши сервисы с помощью Kubernetes и правильная настройка CI/CD ложится на плечи команды разработки лишь частично — основные подходы и шаблоны для этого разрабатывает и поддерживает отдельная команда DevOps.

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

Исходя из этого одним из основных требований к новому сервису стала необходимость поддержки высоких нагрузок при отдаче данных клиентам. Изначально он проектировался таким образом, чтобы избежать накладных расходов на использование внешних кешей или баз данных. Поэтому при старте сервис зачитывает данные из топиков кафки в оперативную память, осуществляя агрегацию и распространяя обновления (по сути апдейты — разницу между уже отданными и полученными данными) реактивно с помощью функционала Rx.NET через сокеты на фронты, предварительно сериализуя данные в двоичный формат посредством Messagepack. В качестве фреймворка для работы с сокетами используется SignalR.

Проблемы в процессе и их решения

Проблема №1: Память

Для того чтобы каждый раз при старте сервиса мы зачитывали из кафки только необходимые данные (актуальные таксономию и трейдинговые данные), необходимо обеспечить очистку устаревших данных в кафке. Иначе мы вынуждены будем бежать по топику от самых ранних оффсетов, пытаясь понять актуальные ли данные в данном офсете и нужно ли загружать их в память, что представляет собой ненужные накладные расходы. Однако, кафка по своей сути — commit log, а не база данных с произвольным доступом, поэтому просто написать “Delete * From” не получится (по крайней мере без дополнительных сервисов от Confluent). Поэтому сервис обеспечивающий поставку данных в кафку в случае потери данными актуальности должен отправить зануление по ключу value=null — это tombstone, говорящий кафке о том, что данные не актуальны и их можно “похоронить” при выполнении retention policies. Актуальные данные также должны уметь компактиться, ведь оперируя снепшотами нас интересует только последнее состояния сущности. Для этого необходимо указать в качестве retention policy параметр “compact” в настройках топика.

Еще одна проблема связанная с памятью — неоптимальное использование агрегаций данных. Например, для джойна в Rx.NET данные слева и справа должны быть предварительно отфильтрованы, дабы избежать ненужных расходов ресурсов.

Также стоит обратить внимание на то, что в памяти выгоднее держать агрегаты, а не сырые данные. Это связано с более эффективным использованием LOH — чем меньше туда попадет объектов и чем меньшего размера они будут, тем меньше вероятность получить излишнюю фрагментацию кучи и отхватить OutOfMemory exception при выделении большого массива. Такое может произойти при выполнении метода ToList() у IEnumerable — когда казалось бы памяти должно еще хватить, но из-за фрагментации LOH система не может выделить достаточное ее количество. Проверить фрагментацию кучи после последней сборки мусора можно вызвав метод GC.GetMemoryInfo и взглянув на поле FragmentedBytes, которое покажет количество фрагментированных байт в куче — например, "fragmentedBytes": 1669556304.   

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

Проблема №2: производительность

Как уже отмечалось, для сервиса важна производительность и возможность раздавать клиентам данные со средним latency<200 msec. Для достижения этого требования необходимо отправлять клиентам только апдейты — разницу между предыдущим и текущим состоянием данных. Для этого мы используем структуру вида Diff<T>, где T — это ViewModel, в котором каждое поле заполнено только изменившимися данными. Такой подход позволяет сэкономить на сериализации и скорости канала пересылки, но требует больше CPU для вычисления разницы каждого отправляемого объекта. Кроме того, апдейты объединены в батчи, чтобы отправлять клиенту не каждый мельчайший апдейт, а буферизировать их либо по количеству, либо по промежутку времени.             

Настройки Garbage Collector весьма ощутимо сказываются на производительности. Разумеется для оптимизации работы с памятью необходимо стремиться к минимальному использованию LOH, а в случае когда необходимо это сделать, то стремиться минимизировать размер объектов туда попадающих.

По умолчанию размер LOH = 85000 байт, но в принципе можно поэкспериментировать с увеличением этого значения. Например, если вы точно уверены, что ваши объекты не превышают 120000 байт и меньше их сделать не получается, то можно попробовать увеличить до соответствующего значения параметр System.GC.LOHThreshold.

 Серверная сборка мусора с concurrent mode также дает существенное преимущество при оптимизации latency данных. Например, включение соответствующих параметров <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection> и  <ServerGarbageCollection>true</ServerGarbageCollection> дало возможность улучшить latency примерно на 20% по сравнению с ServerGarbageCollection = false, правда за счет некоторого увеличения расхода CPU.

 Переход с .NET Core 3.1 на .NET 5.0, который прошел довольно легко с апдейтом сборки и правками сериализации, позволил без изменения кода улучшить производительность сервиса примерно на 15%. Соответственно периодический апдейт используемых фреймворков и библиотек приносит свою выгоду требуя минимальных затрат разработчиков, хотя могут быть и исключения.

 Необходимость Load тестов неоднократно оправдывала себя — любые изменения кода или бизнес логики обязательно прогоняются сначала через load тесты, имитирующие нагрузки близкие к реальным. Да, написание и поддержка тестов в актуальном состоянии требует ресурсов команды, но они позволяют хотя бы приблизительно оценить влияние новых фич или свежих оптимизаций на то, как поведет себя сервис в продакшене и на сколько изменятся его требования к ресурсам памяти и CPU.

 Проблема №3: совместимость 

Фронты (Android/iOS/Web) подписываются на изменения операционных данных по набору идентификаторов при помощи веб-сокетов. В качестве “обертки” над web сокетами мы используем SignalR на стороне бэка. Однако, качественно оптимизированных и эффективных в работе библиотек для поддержки SignalR на стороне Android и iOS под наши требования мы на тот момент не нашли. Поэтому фронтам пришлось в полной мере поддержать особенности бинарного протокола SignalR: учесть наличие хардбитов, кодирование нескольких сущностей в одном сообщении посредством добавления в сообщение информации о длине подмножеств данных (payloads) в формате VarInt и т.д. Это добавило работы, но с другой стороны, благодаря хорошо написанной документации фреймворка SignalR, позволило реализовать свою кастомную библиотеку транспорта на стороне фронтов, которая полностью поддерживается нашими командами.

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

Проблема №4: мониторинг

Для эффективного мониторинга работы сервиса конечно же нужны логи и метрики. Тут все более-менее стандартно: ELK для логов и Prometheus/Grafana для снятия метрик и их мониторинга в почти реальном времени. Здесь действует правило — чем больше метрик вы снимете, тем лучше. Даже такая метрика, как отсутствие новых данных, в течение некоторого времени может говорить о том, что что-то идет не так. Например, есть проблемы с миррорингом данных между кластерами Кафки или сервис банально завис из-за необработанной ошибки в подписке посредством Rx.NET.

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

Проблема №5: масштабирование и хостинг

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

 Причем метрика по памяти — негибкий показатель в силу недетерминистичности чистки памяти GC. Можно поэкспериментировать с параметром System.GC.HighMemoryPercent и добиться более агрессивной работы GC не при 90% занятой памяти (по умолчанию), а при 80% — это позволит автоскейлеру более эффективно масштабироваться с точки зрения потребления ресурсов.

Какие выводы мы сделали

Мы продолжаем сталкиваться с некоторыми трудностями, но уже можем точно выделить некоторые выводы:

  1. Оптимизация производительности и потребления памяти  высоконагруженного сервиса — это перманентная необходимость. По сути мы вступаем в гонку между добавлением новых фич и минимизацией их последствий для производительности системы. Это требует значительных усилий со стороны команды: как со стороны разработки, так и со стороны тестирования. Причем наличие Load тестов — это необходимое условие.

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

  3. Измерять все что только возможно — чем больше метрик, тем более система предсказуема и тем больше данных для ее улучшения

  4. Дефолтные настройки используемых фреймворков бывает полезно пересмотреть — будь то настройки кафки или настройки .NET 

Надеемся, что наш опыт поможет вам быть готовыми к трудностям при построении высоконагруженных сервисов. Удачи!

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


  1. x2v0
    21.09.2021 14:54

    В соседней ветке https://habr.com/ru/company/otus/blog/579108/ пишут

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

    Ваше мнение?


    1. serhio_ribeira
      22.09.2021 14:12

      в данном случае у Кафки другой сценарий использования - она используется только для доставки данных сервису через статический набор топиков, затем данные сохраненяютя в in-memory observable кеше, а дальше уже с помощью Rx и SignalR данные доставляются клиентам через сокеты


  1. syusifov
    22.09.2021 00:13
    +1

    блаблабла


  1. SanSYS
    22.09.2021 11:40

    Вообще интересно, но как-то без мяса получилось )

    К примеру изменённый график CPU со сменой фреймворка было бы классно увидеть и ваши выводы почему на вас это так применилось

    По SignalR любопытны код и ваша схема сообщений

    Параметры LOHThreshold, HighMemoryPercent и пр. - да полезно поиграться, но вы же уже поигрались? И промежуточные выводы применимо к себе сделали, графики нарисовали, в разных ситуациях погоняли, вот этими наблюдениями если поделиться - будет огонь. + какие подводные камни встречаются, а они везде есть, меняя в GC одно - наверняка ломаешь иное где-то


    1. serhio_ribeira
      22.09.2021 21:49

      тонкие настройки GC или использование структур данных, оптимизированных для того чтобы избежать попадания в LOH - это всегда поиск компромиссов, так что не факт что оптимизации под наши сценарии использования также хорошо подойдут кому-то другому под их сценарии, поэтому в статье мы попытались лишь контурно обозначить направление "куда копать", так например уменьшение LOHThreshold помогло сделать нам более гибким автоскейлинг, но за счёт некоторого уменьшения утилизации памяти в ноде kubernetes. Что же до миграции на NET 5.0 то тут мы полагались на основные данные по замерам производительности от Microsoft в плане работы с коллекциями, сериализацией и SignalR, как оказалось мы получили выигрыш от перехода и есть определенная уверенность что этот выигрыш не является погрешностью измерений, а связан именно с оптимизацией фреймворка


  1. korsetlr473
    22.09.2021 16:09

    Таков и простой и сложный вопрос, но очень важный:

    Есть кафка в которую забрасываем очень много сообщений, например просмотры страниц.

    Есть сервис который просыпается в N минут, вычитывает очередь , суммирует результаты и 1 раз делает update в основную базу данных.

    Вопрос для знатаков как обеспечить атомарность данной операции с учетом то что электричество может оборваться на любой строке кода

    {

    var msgs = kafka.readAllMessagesFromLastCheckpoint();

    maindb.IncrementField(ID, msgs.Count);

    kafka.Commit();

    }

    Если мы упадем на 3й строке , то данные уже записались в db , и в след раз мы по новой возьмем теже сообщения и еще раз плюплюсуем.

    Если сначала долать комит а потом записывать в БД, то если упадем на строке работы с ДБ , то мы просто потеряем эти сообщения.

    Заранее спасибо!


    1. Alew
      22.09.2021 21:07

      Пишите в бд офсет последнего прочитанного сообщения из Кафки. А когда будете стартовать, то передавайте этот офсет+1 в кафку как метку откуда читать


      1. korsetlr473
        22.09.2021 22:41

        как будет выглядеть код? как это поможет?

        {

        var msgs = kafka.readMessagesFrom(maindb.GetLastKafkaOffset);

        maindb.SaveLastKafkaOffset(msgs);

        // <<<<<<<< и тут выключается электричество

        maindb.IncrementField(ID, msgs.Count);

        kafka.Commit();

        }

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

        @serhio_ribeira


        1. Alew
          22.09.2021 23:58

          транзакции в БД с легкостью решают эту проблему


          1. korsetlr473
            23.09.2021 00:42

            я думал над этим , и опять это не работает смотри:

            var msgs = kafka.readAllMessagesFromLastCheckpoint();

            transaction start{

            maindb.IncrementField(ID, msgs.Count);

            // выполняем коммит в кафке

            kafka.Commit();

            // и тут -электричество

            } // <= транзакция выполняется вот тут

            Да! это поможет откатить-незаписать в mainDB, но мы уже сделали kafka.Commit()! тоесть в след раз мы начнем с последнего сообщения , а предыдущие потеряем !

            Забавно что автор статьи ничего не может ответить @parimatch_tech


            1. Alew
              23.09.2021 10:53
              +1

              все проще, комиты в кафке вообще не нужны

              var msgs = kafka.readMessagesFrom(maindb.GetLastKafkaOffset);

              maindb.StartTransaction()

              maindb.Save(msgs);

              maindb.SaveLastKafkaOffset(msgs.Last().Offset);

              maindb.CommitTransaction()


        1. serhio_ribeira
          24.09.2021 17:08

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


    1. serhio_ribeira
      22.09.2021 21:34

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