Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Всем привет. Как вы все знаете, после определенных событий у нас случился ресурсный кризис. И появился запрос на оптимизацию потребляемых ресурсов.
Темой и станет оптимизация потребления ресурсов микросервисов и уменьшение времени выполнения наших запросов.
Перед тем как начать работу, нужно подвести цели:
Уменьшение потребления ЦПУ и ГПУ
Уменьшение времени на обработку запросов в секунду
Уменьшение время инициализации (запуска)
Унификация кодовой базы для наших микросервисов
Цели выявили и перед тем, как понять, что мы их достигли, нужно сделать замеры. Прикладываем линейку к нашим сервисам.
Возьмем 5 микросервисов, которые мы оптимизировали, и посмотрим на результаты до и после:
Слева сверху видим среднее потребление ресурсов в бою (До/После). Остальные графики реализованы с помощью синтетических тестов для большей наглядности (Нагрузку давали по одному из самых популярных методов на микросервис). От 1 до 5 - это условное обозначение наших микросервисов.
Как видим, выделено ресурсов было намного больше среднего потребления ЦПУ. Было это из-за DDoS-атак, которым мы переодически подвергаемся.
По графикам до и после можно увидеть, что время обработки запросов уменьшилось во много раз.
Снизилось потребление Heap - спасибо JDK17.
Уменьшилось количество потоков, так как запросы стали выполнятся быстрее.
Немного понизилось потребление ГПУ.
Значительно уменьшилось время инициализации микросервисов.
Ну и самая главная преследуемая цель - снизилось потребление ЦПУ.
Также уменьшился вес собираемого пакета примерно в 2 раза.
В итоге на на данный момент мы сэкономили около 80% потребляемого ЦПУ и и около 7% ресурсов ГПУ. Раз мы тут разговариваем об оптимизации перед тем как перейти к сладкому, о том как мы решали данную проблему. Как мы выявляли утечки в наших микросервисах?
Нам понадобится Intellij Idea Ultimate (а именно, его Profiler), нагрузочное тестирование (в моем случае - wrc/wrc2), и для более чистого эксперимента лучше помещать наше приложение в контейнер докера c необходимым ограничением ресурсов.
Запускаем наш микросервис и подключаемся через профайлер Intellij Idea:
После подключения к микросервису начнется запись JFR, по которой в будущем можно будет построить различные графики для анализа.
Также можно посмотреть текущее потребление ЦПУ, ГПУ и прочее через "CPU and Memory Live Charts".
Делаем нагрузочное тестирование с включенным JFR и анализируем, что у нас дольше всего выполняется.
Пример нагрузочного тестирования через wrk2:
wrk2 -c 20 -d 10m -R 400 -H "Authorization: Basic ***" "http://localhost:8082/service/test?test1=test"
Подробнее как настраивать и анализировать JFR через profiler Intellij Idea.
Можно много рассказывать о том, какие данные подозрительны или нет, но, если вкратце, то стоит смотреть, что потребляет больше всего и что с этим можно сделать. Справа FlameGraph построенный по JFR, слева google по которому ищешь, как срезать косты потребления по логике, которая долго по твоему мнению отрабатывает.
Давайте уже перейдем к решениям, которые помогли нам сэкономить данные ресурсы. Касается они, конечно, Java и Spring.
Используем правильный модуль авторизации. В нашем случае это был BCryptPasswordEncoder, который задавался для SpringSecurity. То есть каждый раз использовалась очень надёжная, но довольно прожорливая авторизация, так как декрипт происходит с помощью процессора, который делает множество итераций для того, чтобы надежно декодировать присланный пароль. Данный тип реализации авторизации было бы нормально использовать для gui авторизации, но на каждую обработку запроса использовать данный способ плохо. На данный момент решил использовать кеширование, которое решает данную проблему. Это сняло часть нагрузки с наших микросервисов. В идеале стоит использовать отдельный микросервис, который отвечал бы за авторизацию, например, с использованием OAuth 2.0. Но это только в планах.
Пример ошибки которая была допущена:
//Бин в SpringSecurity
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); //Наш инкодер
}
Все авторизации мы пропускали через данный Encoder, включая все REST запросы.
Для маленьких микросервисов – маленькие фреймворки. Не забываем, что есть большое количество фреймворков, которые могут делать излишние абстракции, маппинг и т.д. Например Hibernate, lucene. В нашем случае переход на простой Spring JDBC помог ускорить выполнение методов.
Стараемся не использовать RegExp(split, replaceAll), тем более, в больших текстовых наборах, если время выполнения нам важно. Java не очень любит быстро работать с регулярками. Переделываем на нативные методы или высокопроизводительные встроенные методы jdk, такие как indexOf.
Не используем Java StreamAPI на маленькие коллекции. Это увеличивает время обработки запросов, так как тратит время на преобразования.
Пример TestEnum:
public enum TestEnum {
A("1"),
B("2"),
C("3"),
D("4"),
E("5"),
F("6"),
G("7");
private String number;
private TestEnum(String number) {
this.number = number;
}
public String getNumber() {
return number;
}
}
Пример вызова с помощью StreamApi:
staticVarTestEnum = Arrays.stream(TestEnum.values()) // Время на преоброзование в StreamAPI - опционально
.filter(value -> value.number.equals("4"))
.findFirst()
.get();
Если даже не учитывать время на преобразования из Array в Stream, то внутренние операции в StreamApi на небольших массивах все равно используют слишком много времени выполнение по сранению со StreamApi.
Пример без StreamApi:
for (TestEnum value : TestEnum.values()) {
if (value.getNumber().equals("4")) {
staticVarTestEnum = value;
}
}
Переезд на JDK 17. Принёс хорошую оптимизацию и новый GC. Не буду пересказывать уже написанные статьи, но, что важно отметить, так это выросшую в 1.5 раза скорость запуска при простом переходе с JDK 11 до JDK 17.
Долгое выполнение запросов на стороне БД? Не забываем проиндексировать нужные поля. Но правильно. Убрав неиспользуемые индексы с базы данных и добавив действительно необходимые, увеличили скорость выборки из БД.
Если мы используем Spring, индексируем его компоненты с помощью spring-indexes, что даст прирост скорости запуска. Прирост зависит от количества компонентов в проекте.
Для увеличения скорости запуска проекта и уменьшения потребление heap-memory используем Lazy инициализацию для неиспользуемых или редко используемых модулей.
Используем строго прописанный property файл для приложения. Так как Spring ищет сотни вариантов написания нашего property. Применимо, если нам важен каждый процент CPU.
@EnableConfigurationProperties(ApplicationProperties::class) //kotlin example
Создаем профайл для конфигураций, тем самым убирая из автоконфигураций ненужные классы. Это ускорит выполнение тестов и ускорит запуск нашего приложения.
@SpringBootApplication(exclude = [DataSourceAutoConfiguration::class])
//Выпиливаем все не нужные.
//Проверить какие пытаются сконфигурироваться можно в debug режиме.
Не используем тяжелые layout для logger (например, LogStash). Они создают много абстракций, и в результате тратится много времени на запуск и лишнее время на логирование ваших сообщений.
Пример для logback:
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
//Указываем свой Encoder
<encoder class="ru.tinkoff.logger.perfomance.TinkoffLogbackEncoder"/>
</appender>
//В данном примере используем ассинхронный лог
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="STDOUT"/>
</appender>
//Указываем уровень логирования
<root level="INFO">
<appender-ref ref="ASYNC"/>
</root>
Пример реализованного простого encoder
public class LogbackEncoder extends EncoderBase<ILoggingEvent> {
private static final Logger LOGGER = LoggerFactory.getLogger(LogbackEncoder.class);
private final ObjectMapper mapper;
public byte[] encode(ILoggingEvent event) {
this.start();
try {
ObjectNode eventNode = this.mapper.createObjectNode();
this.getContext().getCopyOfPropertyMap().forEach((key, value) -> {
eventNode.put(StringUtils.uncapitalize(key), value);
}); //создаем наш конектест
event.getMDCPropertyMap().forEach((key, value) -> {
eventNode.put(StringUtils.uncapitalize(key), value);
}); //копируем данные в наш контекст из МДС
return (this.mapper.writeValueAsString(eventNode) + System.lineSeparator()).getBytes(StandardCharsets.UTF_8);
//возврашаем наш контектс в виде байтов
} catch (Exception ex) {
LOGGER.error(ex.getMessage(), ex);
} finally {
this.stop();
}
return new byte[0];
}
}
Не логируем тела запросов (JSON/XML и т.д.). Encoder конвертирует из текста в объект и обратно для валидации и вывода вашего сообщения. Это увеличивает потребление ресурсов и время обработки запросов.
В данном случае выход один – отказаться от прямого логирования request и response и логировать данные в своем формате без валидаций и конвертаций или/и логировать только ошибочные ответы.
С помощью данных подходов получилось решить поставленные задачи.
Спасибо за внимание!