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

Темой и станет оптимизация потребления ресурсов микросервисов и уменьшение времени выполнения наших запросов.

Перед тем как начать работу, нужно подвести цели:

  • Уменьшение потребления ЦПУ и ОЗУ

  • Уменьшение времени на обработку запросов в секунду

  • Уменьшение время инициализации (запуска)

  • Унификация кодовой базы для наших микросервисов

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

Возьмем 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.

CPU
CPU

Используем правильный модуль авторизации. В нашем случае это был BCryptPasswordEncoder, который задавался для SpringSecurity. То есть каждый раз использовалась очень надёжная, но довольно прожорливая авторизация, так как декрипт происходит с помощью процессора, который делает множество итераций для того, чтобы надежно декодировать присланный пароль. Данный тип реализации авторизации было бы нормально использовать для gui авторизации, но на каждую обработку запроса использовать данный способ плохо. На данный момент решил использовать кеширование, которое решает данную проблему. Это сняло часть нагрузки с наших микросервисов. В идеале стоит использовать отдельный микросервис, который отвечал бы за авторизацию, например, с использованием OAuth 2.0. Но это только в планах.

Пример ошибки которая была допущена:

    //Бин в SpringSecurity
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); //Наш инкодер
    }

Все авторизации мы пропускали через данный Encoder, включая все REST запросы.

CPU/ОЗУ/StartUp и т.д.
CPU/ОЗУ/StartUp и т.д.

Для маленьких микросервисов – маленькие фреймворки. Не забываем, что есть большое количество фреймворков, которые могут делать излишние абстракции, маппинг и т.д. Например Hibernate, lucene. В нашем случае переход на простой Spring JDBC помог ускорить выполнение методов.

CPU
CPU

Стараемся не использовать RegExp(split, replaceAll), тем более, в больших текстовых наборах, если время выполнения нам важно. Java не очень любит быстро работать с регулярками. Переделываем на нативные методы или высокопроизводительные встроенные методы jdk, такие как indexOf.

SpeedUp
SpeedUp

Не используем 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();
Пример использования StreamApi - FlameGraph
Пример использования StreamApi - FlameGraph

Если даже не учитывать время на преобразования из Array в Stream, то внутренние операции в StreamApi на небольших массивах все равно используют слишком много времени выполнение по сранению со StreamApi.

Пример без StreamApi:

for (TestEnum value : TestEnum.values()) {    
  if (value.getNumber().equals("4")) {       
    staticVarTestEnum = value;    
  }
}
Тот-же код но с использованием без StreamApi
Тот-же код но с использованием без StreamApi
CPU/ОЗУ/StartUp и т.д.
CPU/ОЗУ/StartUp и т.д.

Переезд на JDK 17. Принёс хорошую оптимизацию и новый GC. Не буду пересказывать уже написанные статьи, но, что важно отметить, так это выросшую в 1.5 раза скорость запуска при простом переходе с JDK 11 до JDK 17.

SpeedUp
SpeedUp

Долгое выполнение запросов на стороне БД? Не забываем проиндексировать нужные поля. Но правильно. Убрав неиспользуемые индексы с базы данных и добавив действительно необходимые, увеличили скорость выборки из БД.

SpeedUp
SpeedUp

Если мы используем Spring, индексируем его компоненты с помощью spring-indexes, что даст прирост скорости запуска. Прирост зависит от количества компонентов в проекте.

ОЗУ and StartUp
ОЗУ and StartUp

Для увеличения скорости запуска проекта и уменьшения потребление heap-memory используем Lazy инициализацию для неиспользуемых или редко используемых модулей.

StartUp
StartUp

Используем строго прописанный property файл для приложения. Так как Spring ищет сотни вариантов написания нашего property. Применимо, если нам важен каждый процент CPU.

@EnableConfigurationProperties(ApplicationProperties::class) //kotlin example
SpeedUp and StartUp
SpeedUp and StartUp

Создаем профайл для конфигураций, тем самым убирая из автоконфигураций ненужные классы. Это ускорит выполнение тестов и ускорит запуск нашего приложения.

@SpringBootApplication(exclude = [DataSourceAutoConfiguration::class]) 
//Выпиливаем все не нужные.
//Проверить какие пытаются сконфигурироваться можно в debug режиме.
StartUp и SpeedUp
StartUp и SpeedUp

Не используем тяжелые 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];
    }
}
CPU и SpeedUp
CPU и SpeedUp

Не логируем тела запросов (JSON/XML и т.д.). Encoder конвертирует из текста в объект и обратно для валидации и вывода вашего сообщения. Это увеличивает потребление ресурсов и время обработки запросов.

В данном случае выход один – отказаться от прямого логирования request и response и логировать данные в своем формате без валидаций и конвертаций или/и логировать только ошибочные ответы.

С помощью данных подходов получилось решить поставленные задачи.

Спасибо за внимание!

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


  1. panzerfaust
    08.08.2022 20:46
    +7

    Извините за возможно тупой вопрос, но GPU это graphics processing unit? Какого рода задачи выполняет ваше приложение, если оно одновременно и использует мейнстримный спринг бут и такую экзотику как вычисления на GPU?


    1. tsypanov
      08.08.2022 22:24
      +1

      Присоединяюсь к вопросу


    1. Devsett Автор
      10.08.2022 12:44
      +1

      Допущена ошибка :D
      Почему-то писав статью за GPU -> предполагалось ОЗУ.
      Хотя GPU в моем случае не используется вообще.


  1. tsypanov
    08.08.2022 22:26

    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;
            }
    }

    В перечислениях в Java есть встроенное поле ordinal, представляющее порядковый номер члена перечисления.


    1. aleksandy
      09.08.2022 08:59
      +5

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


      1. tsypanov
        09.08.2022 11:00

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


        1. aleksandy
          09.08.2022 14:01
          +1

          Во-вторых, по-православному добавление новых постоянных всегда делается в конец существующего энума.

          Это как пиратский кодекс (свод указаний, а не жёстких законов). И, честно говоря, я ни разу не видел, чтобы этим руководствовались постоянно, без каких-либо к тому принуждений. Например, тесты, которые проверяют порядковый номер каждой константы.

          по-умолчанию в таблицу вставляется именно порядковый номер, а не имя

          Все делают ошибки, а принятые плохие решения, просочившись в стандарт, остаются там навсегда. Это такой же косяк, с которым приходится жить, как и @*ToMany(fetch = EAGER).


          1. tsypanov
            09.08.2022 14:37

            В том-то и дело, что это не ошибка. Да, разработчики JDK не прописали жёсткого запрета на перестановку членов перечисления, но уже тот факт, что Enum можно сериализовать (а вместе с ним и EnumSet/EnumMap, в которых в частности используется ordinal) говорит о том, что перестановки делать нельзя. В JPA исходя из этого и решили использовать порядковый номер, а не имя. Во-первых, чтобы повторить исходное поведение (старые данные будут неверно преобразовываться в случае смены порядка), и во-вторых чтобы использовать число вместо текста в таблице.


            1. aleksandy
              09.08.2022 19:54

              Ну, не знаю, не знаю. Как по мне, так ошибка самая что ни на есть.

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

              Использование данного метода в прикладном функционале сродни использованию java.util.ArrayList#trimToSize: метод, несомненно, полезный, но в 99,(9)% неиспользуемый вне реализации стандартной библиотеки классов. Но он не мешает, т.к. работа с коллекциями в подавляющем большинстве случаев идёт на уровне интерфейсов.


              1. tsypanov
                10.08.2022 12:06
                +2

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

                1) Несериализуемый Optional в своё время вызвал немало споров, а всё потому, что значительная часть пользователей не поняла, что Optional задумывался как OptionalReturn, иными словами он должен использоваться только как возвращаемое значение и с этой точки зрения запрет сериализации выглядит полностью логичным и оправданным.

                2) Одноразовый Stream тоже вызвал недоумение у многих, привыкших переиспользовать коллекции и вертеть их как угодно, тем более, что Stream намного тяжелее и дороже коллекций. Но оказывается, что для ленивого связывания и параллелизма очень сложно реализовать механизм поддержания и сбрасывания промежуточного состояния. И ещё сложнее реализовать все свойства стримов в случае, например, построчного чтения из файла.

                С перечислениями, на мой взгляд, та же история, просто по непонятной причине это явно не прописали в документации.


    1. Devsett Автор
      10.08.2022 12:39

      Это не пример из прода) Тут суть в том что есть некая строка которая возвращается) А не в номере.


      1. tsypanov
        10.08.2022 17:36

        И ладно, дискуссия из него получилась вполне годная )


  1. tsypanov
    08.08.2022 22:30
    +4

    @SpringBootApplication(exclude = [DataSourceAutoConfiguration::class]) 
    //Выпиливаем все не нужные.
    

    Более правильным будет выпиливание ненужных библиотек из classpath-а приложение, а то, что нужно явно исключить лучше описывать в application.{yml|properties}:

    spring.autoconfigure.exclude= \ 
      org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, \
      org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, \
      org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration

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


    1. Devsett Автор
      10.08.2022 12:38

      Согласен, что это лучше. Просто пример одного из вариантов исключения.


  1. venum
    08.08.2022 23:22

    Ещё стоит оптимизировать приложения по области их деятельности:

    • Должно выживать под большой нагрузкой => поднимаем фоновый процесс + пулинг модель, убираем сериализацию, переходим на Webflux (если есть возможность), включаем http/2 и.т.д.

    • Должно обрабатывать большие массивы данных => вспоминаем про спец. инструменты хранилищ данных, буфферные чтения, воскрешаем SAX парсеры и п.р.


    1. Devsett Автор
      10.08.2022 12:37

      Тут сама суть "По области"


  1. BugM
    08.08.2022 23:53
    +5

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


    1. Aquahawk
      09.08.2022 06:38
      +5

      О да, я называю это внезапными квадратами. Что самое интересное, в развитой микросервисной архитектуре квадрат может быть и во взаимодействии.


    1. Devsett Автор
      10.08.2022 12:36
      +1

      В нашем случае их не было.


  1. Adverte
    10.08.2022 15:14
    +1

    Добавить свободных ресурсов и скорости может ещё отказ от Spring в проекте. Не прорабатывали этот вариант?


    1. Devsett Автор
      10.08.2022 16:33
      +1

      100 % правда. Был проделан большой анализ. Рассматривали micronaut, quarqus и "свое решение". Micronaut по метрикам мало оптимизаций привносит. Quarqus 2.0 много времени на переписывание и поддержку, оставили за ним только момент с высоконагруженнымы микросервисами, но даже в нем нету решения главного бича спринга.

      Наибольшей оптимизации спринга можно добиться если делать все возможные генерации в precompile, то-есть отказаться от спринга. Тогда тут можно выиграть не плохо ресурсов и RPS. Но появится другая проблема: интеграция с другими сервисами(redis, elastic и т.д.), когда в спринге ты просто можешь прикрутить стартер и все готово.

      Готовых хороших решений нету, особенно с мно-вом интеграций с другими сервисами и большого комьюнити.


      1. panzerfaust
        11.08.2022 09:28
        +1

        Micronaut по метрикам мало оптимизаций привносит

        Наибольшей оптимизации спринга можно добиться если делать все возможные генерации в precompile

        Но погодите, ведь микронавт ровно этим и занимается. DI, декорация, проксирование и прочее выполняется в compile time, насколько я понимаю. Какие именно метрики вам не понравились?


        1. Devsett Автор
          11.08.2022 12:21

          Кажется что это тема для отдельной статьи.

          Действительно какие-то вещи он делает в compile time. Тот-же DI, он другой и делается в compile time и решает проблему с рефлексией в спринге, но quarqus представляет более оптимизированное решение. Некоторые вещи он продолжает делать в runtime, тот-же AOP - частичная компиляция. Так как micronaut пытается быть таким-же как Spring, но "лучше", из-за этого он тащит ряд похожих решений спринга.

          После проделанных тестов на производительность, у нас отпали вопросы к micronaut. Micronaut производительнее чем Spring. Но не так значительно чтобы отказаться от Spring. Quarqus выглядит намного вкуснее.

          Графики по 99% перцентилю. Сравнение quarqus/micronaut/spring.
          Графики по 99% перцентилю. Сравнение quarqus/micronaut/spring.