Love, Spring and HTTP Bugs

Love, Spring and HTTP Bugs

Привет, VC! Мы команда 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 день или целую неделю) и ничего не находит, тем более что в других проектах такой же код успешно работает.

2. Ситуация со стороны клиента – нужна интеграция с простым 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.

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

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

HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(HttpClients.createDefault()); restTemplate = new RestTemplate(factory);

2. Как временное решение, можно изменить PasswordEnсoder на менее требовательный к ресурсам.

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

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

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

Будем включать и выключать 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.183754333829Execute 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.96913657446427Execute 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.156014014929Execute 500000 from 500000. Time 37982.0 ms, rps 13163.783798014902Execute 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.416014726185Execute 500000 from 500000. Time 82718.0 ms, rps 6044.560500006045Execute 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.218570633291Execute 500000 from 500000. Time 29059.0 ms, rps 17205.781142463868Execute 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.533825869963Execute 500000 from 500000. Time 29269.0 ms, rps 17082.33686368295Execute 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/

Love, Spring and HTTP Bugs
Начать дискуссию