В 1967 году Мелвин Конвей сформулировал известный тезис, без упоминания о котором не обходится практически ни одно руководство по созданию микросервисной архитектуры. И не напрасно, ведь не одно поколение разработчиков сталкивалось с его подтверждениями.
Но если структура коммуникаций компании меняется из-за развития и выхода на новые международные рынки, то приходится менять сам продукт для соответствия потребностям пользователей этих рынков.
В новых реалиях нужно было решить несколько моментов:
Мы начинали писать сервис с использованием .NET Core 3.1, а позже довольно быстро и безболезненно мигрировали на .NET 5. Хостятся все наши сервисы с помощью Kubernetes и правильная настройка CI/CD ложится на плечи команды разработки лишь частично — основные подходы и шаблоны для этого разрабатывает и поддерживает отдельная команда DevOps.
Данные между нашими сервисами пересылаются с помощью кафки в виде сообщений содержащих полный снепшот-состояние той или иной бизнес-сущности. При этом снепшоты меняются с очень разной скоростью. Например, таксономия (данные о чемпионатах, спортивных событиях, игроках) меняется раз в несколько секунд, а операционные данные (коэффициенты рынков) несколько раз в секунду.
Исходя из этого одним из основных требований к новому сервису стала необходимость поддержки высоких нагрузок при отдаче данных клиентам. Изначально он проектировался таким образом, чтобы избежать накладных расходов на использование внешних кешей или баз данных. Поэтому при старте сервис зачитывает данные из топиков кафки в оперативную память, осуществляя агрегацию и распространяя обновления (по сути апдейты — разницу между уже отданными и полученными данными) реактивно с помощью функционала Rx.NET через сокеты на фронты, предварительно сериализуя данные в двоичный формат посредством Messagepack. В качестве фреймворка для работы с сокетами используется SignalR.
Для того чтобы каждый раз при старте сервиса мы зачитывали из кафки только необходимые данные (актуальные таксономию и трейдинговые данные), необходимо обеспечить очистку устаревших данных в кафке. Иначе мы вынуждены будем бежать по топику от самых ранних оффсетов, пытаясь понять актуальные ли данные в данном офсете и нужно ли загружать их в память, что представляет собой ненужные накладные расходы. Однако, кафка по своей сути — 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% — это позволит автоскейлеру более эффективно масштабироваться с точки зрения потребления ресурсов.
Но если структура коммуникаций компании меняется из-за развития и выхода на новые международные рынки, то приходится менять сам продукт для соответствия потребностям пользователей этих рынков.
Постановка задачи
Именно с задачей по подготовке продукта к выходу на международные рынки и столкнулась наша команда летом 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% — это позволит автоскейлеру более эффективно масштабироваться с точки зрения потребления ресурсов.
Какие выводы мы сделали
Мы продолжаем сталкиваться с некоторыми трудностями, но уже можем точно выделить некоторые выводы:- Оптимизация производительности и потребления памяти высоконагруженного сервиса — это перманентная необходимость. По сути мы вступаем в гонку между добавлением новых фич и минимизацией их последствий для производительности системы. Это требует значительных усилий со стороны команды: как со стороны разработки, так и со стороны тестирования. Причем наличие Load тестов — это необходимое условие.
- Стоит регулярно обновлять основные используемые библиотеки и фреймворки — часто они привносят улучшения с точки зрения производительности и помогают сделать работу сервиса более стабильной
- Измерять все что только возможно — чем больше метрик, тем более система предсказуема и тем больше данных для ее улучшения
- Дефолтные настройки используемых фреймворков бывает полезно пересмотреть — будь то настройки кафки или настройки .NET
5 проблем и их решения при создании высоконагруженного сервиса с использованием .NET и Kafka
В 1967 году Мелвин Конвей сформулировал известный тезис, без упоминания о котором не обходится практически ни одно руководство по созданию микросервисной архитектуры. И не напрасно, ведь не одно...
habr.com