Привет, Хабр! Мы команда Marketing Management GlowByte, занимаемся автоматизацией маркетинговых процессов в крупных компаниях. Решили написать небольшую статью, которая будет интересна неравнодушным к Java. Хотим поделиться на первый взгляд простыми особенностями поведения библиотек Spring Security, Spring Web, которые могут сбить с толку разработчиков, которые никогда не сталкивались с такими ситуациями.

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

Проблематика

Представим себе классические будни энтерпрайзной разработки. Новый спринт – новая задача – необходимо интегрироваться с системой Заказчика. ТЗ на столе в почте в конфлюенсе, вроде ничего сложного – обычная интеграция по HTTP-протоколу. Спринт позади, и вот уже в продакшене вы вдруг обнаруживаете, что все работает медленно и утилизирует много ресурсов CPU, причём для другой системы, которая использует эту же интеграцию, производительность гораздо выше, чем для нашей при одинаковом профиле нагрузки.

Детальнее, как это происходит со стороны сервиса (пункт 1) и клиента (пункт 2), рассмотрим ниже:

  1. Требования со стороны сервиса – самый простой Basic Auth на одного пользователя (интеграцию пилим между backend-сервисами), не нужны ни коробочные решения, ни добавление новых пользователей, достаточно зашифрованного логина/пароля в конфиге. Разработчик добавляет, например, такой конфиг: 

@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Value("${users.user.name}")
    private String userName;
    @Value("${users.user.pass}")
    private String userPass;
    @Value("${users.user.role:HABR}")
    private String userRole;
    @Value("${encoder.enable}")
    private boolean encoderEnable;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable().httpBasic();
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        PasswordEncoder passwordEncoder = passwordEncoder();
        UserDetails user = User.builder()
                .passwordEncoder(passwordEncoder::encode)
                .username(userName)
                .password(userPass)
                .roles(userRole)
                .build();
        return new InMemoryUserDetailsManager(user);
    }
}

Криминального ничего нет (согласны?), такое может пройти и ревью, и юнит-тесты, спокойно уехать на тестовый контур, там пройти интеграционные тесты и в конце концов оказаться в PROD. 

И что мы получаем? Приходит боевая нагрузка и выясняется, что сервис очень медленно работает. Разработчик уходит тестироваться локально (на 10 минут, на 1 день или целую неделю) и ничего не находит, тем более что в других проектах такой же код успешно работает.

  1. Ситуация со стороны клиента – нужна интеграция с простым web-сервисом, на котором настроен Basic Auth. Сервис для простоты принимает POST-запрос и разворачивает строку. Что получается у разработчика:

HttpHeaders header = new HttpHeaders();
header.setBasicAuth(userName, userPass);
for (int j = 0; j < amountThreadMessage; j++) {
    String data = UUID.randomUUID().toString();
    HttpEntity<String> requestEntity = new HttpEntity<>(data, header);
    ResponseEntity<String> entity = restTemplate.postForEntity(url, requestEntity, String.class);
    if (entity.getStatusCode() == HttpStatus.OK && new StringBuilder(data).reverse().toString().equals(entity.getBody())) {
        amountSuccessExecute.incrementAndGet();
    }
}

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

Вот таким нехитрым образом получаем проблему, что две стороны уверены в своём коде, но по факту интеграция работает плохо.

Анализ

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

Посмотрим детальнее, почему это происходит, где же скрыта проблема. А базируется она на двух особенностях, первая из них – BcryptPasswordEncoder::matches (выполняется на стороне сервера и заключается в сопоставлении паролей). Достаточно дорогая операция, но разве она должна выполняться на каждый запрос? Нет, конечно! И действительно, Spring заботится, чтобы она выполнялась один раз в рамках установленного соединения. 

Значит надо проверить, работает ли keep-alive, и тут мы узнаём, что нет, не работает. А почему? Идём смотреть клиентский код и видим там только new RestTemplate() и больше ничего. Посмотрим, как он работает “под капотом”.

А “под капотом” он дёргает подозрительный HttpURLConnection::close.

Как же можно исправить?

  • Использовать клиент, который стабильно держит соединение, например:

    HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(HttpClients.createDefault());
    restTemplate = new RestTemplate(factory);
    
  • Как временное решение, можно изменить PasswordEnсoder на менее требовательный к ресурсам.

  • Ваши предложения пишите в комментариях.

Тестирование

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

Будем включать и выключать keep-alive (постоянное соединение), менять PasswordEncoder (тяжёлая операция по работе с паролем) и проверять, влияет ли на результат использование одного клиента, или концепция 1 thread – 1 HttpClient надёжнее.  

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

Используем виртуальную машину в любимом облаке с таким сайзингом: 8 vCPU, 8 GB vRAM.

Запустим java -Xmx6g -Xms6g -jar Habr-1.0.0.jar

Детальные выкладки результатов под катом

Отправим по 10 тысяч запросов в 50 потоков для исправленного варианта.

Execute 500000 from 500000. Time 89366.0 ms, rps 5594.906397215975
Execute 500000 from 500000. Time 80759.0 ms, rps 6191.183754333829
Execute 500000 from 500000. Time 80542.0 ms, rps 6207.864122270092

Отправим по 1 тысяче запросов в 50 потоков вариант без исправлений с утилизацией CPU 95%+.

Execute 50000 from 50000. Time 739863.0 ms, rps 67.57998767341024
Execute 50000 from 50000. Time 735627.0 ms, rps 67.96913657446427
Execute 50000 from 50000. Time 735488.0 ms, rps 67.98198205547601

Отправим по 10 тысяч запросов в 50 потоков вариант с заменой PasswordEncoder и без keep-alive.

Execute 500000 from 500000. Time 45950.0 ms, rps 10881.156014014929
Execute 500000 from 500000. Time 37982.0 ms, rps 13163.783798014902
Execute 500000 from 500000. Time 37585.0 ms, rps 13302.825520140477

Отправим по 10 тысяч запросов в 50 потоков вариант с заменой PasswordEncoder и c keep-alive.

Execute 500000 from 500000. Time 86919.0 ms, rps 5752.416014726185
Execute 500000 from 500000. Time 82718.0 ms, rps 6044.560500006045
Execute 500000 from 500000. Time 83872.0 ms, rps 5961.394012375854

Стоп, а почему в потенциально самом быстром варианте результаты хуже?

Попробуем каждому потоку выдать свой персональный http-клиент.

Отправим по 10 тысяч запросов в 50 потоков вариант с заменой PasswordEncoder и c keep-alive и prototype-клиентом.

Execute 500000 from 500000. Time 52092.0 ms, rps 9598.218570633291
Execute 500000 from 500000. Time 29059.0 ms, rps 17205.781142463868
Execute 500000 from 500000. Time 28339.0 ms, rps 17642.907551164433

Отправим по 10 тысяч запросов в 50 потоков вариант c BCryptPasswordEncoder и c keep-alive и prototype-клиентом.

Execute 500000 from 500000. Time 50892.0 ms, rps 9824.533825869963
Execute 500000 from 500000. Time 29269.0 ms, rps 17082.33686368295
Execute 500000 from 500000. Time 28368.0 ms, rps 17624.872219676407

Ура, теперь результаты с разными энкодерами совпадают!

И контрольный: а может, на prototype-клиенте не нужен keep-alive?

Execute 50000 from 50000. Time 738208.0 ms, rps 67.73149609392462

Не-а. Без keep-alive никак.

Результаты тестов показывают, что ошибка в одной-двух строчках кода ухудшает производительность с 17000 rps до 67 rps.

Заключение

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

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

P. S. Знаем, что можно изменить подходы, библиотеки и применить другие оптимизации, чтобы работало в рамках этой задачи гораздо быстрее, не бросайтесь за выкладки таких результатов. :)

Ссылка на репозиторий: https://bitbucket.org/Griphon/security/src/master/

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


  1. murkin-kot
    25.10.2022 20:09
    -3

    Уж и не знаю, заявит ли мне автор снова, что "Знаем, что можно изменить подходы, библиотеки и...", но похоже он не в курсе, что есть целый набор механизмов, позволяющий работать с сессией, а не с отдельными соединениями. Да, при этом придётся выбросить прокладку в виде спринга, но ведь именно она приводит к проблемам, не умея использовать сессию (авторы спринга тоже про неё не слышали?).

    Вот так выбор модной библиотеки влияет на результат.


    1. BugM
      25.10.2022 20:27
      +1

      Спринг писали умные люди и все он позволяет.

      Ошибка не в одной строчке кода, а в архитектуре. Любой сеньор должен просто знать что так проектировать сервис нельзя. Почему нельзя общими словами тоже должен знать.


      1. murkin-kot
        26.10.2022 00:01

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

        Хотя возможно вы против использования сессии? Или ещё как-то оригинально смотрите на мир?


        1. Sigest
          26.10.2022 07:20
          +4

          Если бы задача стояла только во внедрении в проект basic auth, то да, следовать моде и подключать тяжеловесный спринг крайне лишне. Но у автора скорее всего уже весь проект на спринге, просто нужно реализовать дополнительную фичу. Насчет моды - спринг реально упрощает разработку, это не дань моде, а необходимость. Я тоже не сильно люблю спринг за его magic under the hood, но ничего не поделаешь, альтернатив мало. Альтернатив таких, чтобы в фреймворке было реализовано прям вот все что мне нужно


          1. murkin-kot
            26.10.2022 11:45
            -4

            Но у автора скорее всего уже весь проект на спринге

            Ну что вы, автор пишет про чтение данных пользователя из какого-то файлика. И пользователь там один. Для такого "гигантского" проекта действительно нужен спринг :)

            спринг реально упрощает разработку

            Можете привести пример? С моей колокольни спринг лишь вносит ненужное усложнение. Один из примеров скрытого некорректного поведения рассказал нам автор данной статьи. А ещё больше есть примеров неинтуитивной логики за сценой, которая требует либо погружения в исходники, либо писать на авось, с кучей ошибок, разумеется.

            альтернатив мало

            Спринг - это тонкая обёртка. Сначала вас садят на так называемые бины, которые неявным образом создаются где-то в глубинах спринга, а потом, когда вас отучили писать что-то вроде new MyObject(), спринг предлагает точно так же прятать и всё остальное. Вот в примере в статье спрятал за тончайшей (и ненужной) обёрткой вызов для проверки пароля, в результате возник косяк с производительностью. И альтернатива таким "прям вот все что мне нужно" штуковинам полно. Главная - просто используйте new MyObject() вместо кучи ненужных аннотаций и конфигурационных файлов.


        1. BugM
          26.10.2022 13:48
          +1

          Спринг это не мода. Это отраслевой стандарт. Довольно старый и устоявшийся стандарт.

          Про строчку это к основной статье. Тут было просто чтобы комментарии не плодить.

          Сессия это такое растяжимое понятие. Каждый под ней может понимать что-то свое. Я за любое стандартное работающее решение. В случае авторизации типовой токен или типовая кука. Которые можно проверить стандартным образом и быстро.


    1. eleoleeye
      27.10.2022 11:13
      +2

      Проходят десятилетия, а spring все равно остается "модной библиотекой" ;)

      По моему скромному мнения, spring как раз таки помог java сохранить свои позиции в современной web разработке. И не знать spring работая в этой сфере - это уже моветон.


      1. murkin-kot
        27.10.2022 11:27
        +1

        Спринг всегда был где-то на обочине. Крупные конторы использовали и продвигали JEE, а молодые стартаперы да, пробовали всякую всячину. И да, пробовали они её десятилетиями (а в более широком смысле - тысячелетиями). Но сторонние подходы всегда были и будут очень пёстрыми, самыми разными. И на этом дико разнообразном фоне мы видим в том числе и спринг. Он далеко не победитель среди этой мелочи. Хотя если в конторе все вокруг обязаны использовать спринг, то может показаться, что это "промышленный стандарт".