Spring Framework уже многие годы является базой, на которой разрабатывается подавляющее большинство серверных приложений на Java. Он предоставляет абстракции над множеством различных технологий, в том числе и абстракции для разработки REST API. Все эти абстракции имеют свою цену в плане производительности, и иногда эта цена является очень большой, если речь идёт о высоконагруженном приложении. В этой небольшой статье я покажу, как можно избавиться от ненужных накладных расходов и значительно увеличить производительность вашего API.

Что дано из коробки?
Давайте зайдем на сайт https://start.spring.io/ и сгенерируем самое простое стандартное Spring MVC приложение, как показано на скриншоте:

Выберем последнюю версию Spring Boot, последнюю LTS версию Java и всего одну зависимость: Spring Web.
Добавим в проект простой контроллер:
package com.example.demo; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class VanillaHelloController { @ResponseBody @GetMapping("/hello-vanilla") public String hello(@RequestParam String name) { return "Hello, " + name + "!"; } }
Как видно по коду, функция /hello-vanilla принимает на вход параметр name и возвращает в ответ приветствие по заданному имени.
Приложение можно запустить командой mvn spring-boot:run.
Теперь попробуем измерить QPS этого контроллера, используя JMeter. Настроим сам запрос, проверку ответа и вывод статистики так, как показано на скриншотах ниже:



Запустим тест и видим, что на моей рабочей машине с процессором AMD Ryzen 9 5950X скорость работы примерно 61 тысяча запросов в секунду:

Сам по себе этот результат в абсолютном выражении нам не особо интересен. Интерес будет представлять только сравнение этого показателя с тем, что будет получено в следующих тестах в аналогичных условиях. Отмечу только то, что 61 тысяч запросов в секунду — это довольно много. Во время теста CPU был нагружен примерно на 50-60%. То есть в целом можно сказать, что утилизация CPU неплохая и как минимум мы не упираемся в какие-то неявные ограничения тестовой системы.
Spring поддерживает три веб сервера: tomcat, jetty и undertow. По умолчанию работает tomcat. Попробуем сделать тот же самый тест с использованием jetty и undertow. Для этого необходимо внести изменение в pom.xml:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jetty</artifactId> </dependency>
Как видно по коду, мы убираем из зависимостей tomcat и добавляем jetty. Запустим тест повторно и видим, что jetty показывает куда более скромный результат в 49 тысяч запросов в секунду:

Пробуем undertow:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-undertow</artifactId> </dependency>
и видим уже гораздо более высокую скорость в 67.5 тысяч запросов в секунду:

То есть просто поменяв веб сервер с tomcat на undertow, мы получили прирост производительности на примерно 11%.
Думаю, многие читатели могут вполне законно подвергнуть критике такой тест. Например, что jmeter следует запускать на отдельной машине и из консоли, что у jetty/tomcat/undertow/java есть конфиги X/Y/Z, которые следует изменить/увеличить/уменьшить, и так далее. Я безусловно соглашаюсь с такой критикой, однако отмечу то, что целью статьи является не проведение нагрузочного тестирования и не тюнинг веб серверов. Вместо этого далее я покажу методику, которую можно использовать для значительного ускорения работы ваших сервисов.
Если подключить к тесту async-profiler и посмотреть на распределение нагрузки на CPU, то становится вполне очевидно, что большую часть нагрузки создает Spring:

Проделываем дырку в Spring
В тех высоконагруженных проектах, с которыми мне приходилось сталкиваться, как правило есть одна или две функции, которые создают основную нагрузку на сервис. Остальные 99% функций API не требовательны к скорости работы Spring. Суть того, что будет показано далее, проста: запросы к тем немногим функциям, которые создают основную нагрузку, мы будем пропускать мимо Spring и направлять напрямую в undertow. Spring сам по себе не предоставляет такой возможности, поэтому придётся её добавлять самостоятельно.
Для обработки входящих запросов undertow предлагает использовать паттерн Chain of Responsibility и предоставляет для этого интерфейс HttpHandler. Создадим свою реализацию HttpHandler, которая будет выполнять ту же логику, которую мы использовали выше в тестах:
package com.example.demo; import io.undertow.server.HttpHandler; import io.undertow.server.HttpServerExchange; import org.springframework.stereotype.Component; @Component public class HelloHttpHandler implements HttpHandler { @Override public void handleRequest(HttpServerExchange httpServerExchange) { String name = httpServerExchange.getQueryParameters().get("name").peekFirst(); httpServerExchange.getResponseSender().send("Hello, " + name + "!"); } }
Spring тоже подключает к undertow свои реализации HttpHandler, для того чтобы через них уже включать всю свою функциональность. Наша задача состоит в том, чтобы подключить к undertow наш новый HelloHttpHandler так, чтобы он отрабатывал прямо перед реализациями от Spring.
Spring имеет свой интерфейс HttpHandlerFactory, который он использует для создания HttpHandler в undertow. Мы тоже реализуем этот интерфейс:
package com.example.demo.undertow; import com.example.demo.HelloHttpHandler; import io.undertow.server.HttpHandler; import io.undertow.server.handlers.PathHandler; import org.springframework.boot.web.embedded.undertow.HttpHandlerFactory; public class PathHttpHandlerFactory implements HttpHandlerFactory { private final HelloHttpHandler helloHttpHandler; public PathHttpHandlerFactory(HelloHttpHandler helloHttpHandler) { this.helloHttpHandler = helloHttpHandler; } @Override public HttpHandler getHandler(HttpHandler next) { PathHandler pathHandler = new PathHandler(next); pathHandler.addExactPath("/hello-optimized", helloHttpHandler); return pathHandler; } }
В этой реализации мы говорим undertow, что функцию по пути /hello-optimized следует обрабатывать нашим собственным HelloHttpHandler, а всё остальное — передавать следующим обработчикам (то есть в Spring).
Последним шагом нам нужно включить наш новый PathHttpHandlerFactory в работу. Для этого мы подсмотрим, как это делает сам Spring в своём ServletWebServerFactoryConfiguration. В нём мы видим, что создается класс UndertowServletWebServerFactory, внутри которого находится цепочка HttpHandlerFactory, которая нас интересует. Включаем в проект собственную реализацию UndertowServletWebServerFactory:
package com.example.demo.undertow; import com.example.demo.HelloHttpHandler; import io.undertow.Undertow; import io.undertow.servlet.api.DeploymentManager; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.web.embedded.undertow.*; import org.springframework.stereotype.Component; import org.springframework.util.ReflectionUtils; import java.lang.reflect.Field; import java.util.List; @Component public class CustomUndertowServletWebServerFactory extends UndertowServletWebServerFactory { private final HelloHttpHandler helloHttpHandler; public CustomUndertowServletWebServerFactory( ObjectProvider<UndertowDeploymentInfoCustomizer> deploymentInfoCustomizers, ObjectProvider<UndertowBuilderCustomizer> builderCustomizers, HelloHttpHandler helloHttpHandler) { this.getDeploymentInfoCustomizers().addAll(deploymentInfoCustomizers.orderedStream().toList()); this.getBuilderCustomizers().addAll(builderCustomizers.orderedStream().toList()); this.helloHttpHandler = helloHttpHandler; } @Override protected UndertowServletWebServer getUndertowWebServer( Undertow.Builder builder, DeploymentManager manager, int port) { UndertowServletWebServer undertowServletWebServer = super.getUndertowWebServer(builder, manager, port); Field httpHandlerFactoriesField = ReflectionUtils.findField(UndertowWebServer.class, "httpHandlerFactories"); if (httpHandlerFactoriesField == null) { throw new IllegalStateException("Unable to create undertow web server: no httpHandlerFactories field in UndertowWebServer class"); } ReflectionUtils.makeAccessible(httpHandlerFactoriesField); List<HttpHandlerFactory> httpHandlerFactories = (List<HttpHandlerFactory>) ReflectionUtils.getField(httpHandlerFactoriesField, undertowServletWebServer); httpHandlerFactories.add(new PathHttpHandlerFactory(helloHttpHandler)); return new UndertowServletWebServer(builder, httpHandlerFactories, getContextPath(), port >= 0); } }
Поскольку правильных способов зарегистрировать свой HttpHandlerFactory не существует, мы используем для этого reflection. Мы берём список обработчиков, который создал сам Spring, и добавляем к нему в начало свой PathHttpHandlerFactory.
Запустим проект и проведем последний нагрузочный тест, уже вызывая функцию /hello-optimized:

Мы получили скорость около 175 тысяч запросов в секунду, то есть ускорение работы примерно в три раза. Обратите внимание, что старый VanillaHelloController продолжает работать без каких-либо изменений. Наша оптимизация никак не влияет на работу Spring, и мы можем продолжать создавать новые контроллеры в обычном стиле, и в целом пользоваться фреймворком как обычно.
Заключение
Я ни в коем случае не призываю использовать описанный выше подход во всех ваших Spring API. Более того, не делайте так без особой необходимости. Используйте обычные способы создания контроллеров, которые описаны в документации Spring. Их производительности будет хватать в подавляющем большинстве случаев.
Однако, если вы, как и я, используя профайлер, обнаружили, что имеете узкое место в виде самого Spring, то теперь вы знаете, что можно с этим сделать.
Тестовый проект можно посмотреть здесь: https://github.com/burov4j/spring-undertow-example
Автор статьи: Андрей Буров, Максилект.
P.S. Мы публикуем наши статьи на нескольких площадках Рунета. Подписывайтесь на нашу страницу в VK или на Telegram-канал, чтобы узнавать обо всех публикациях и других новостях компании Maxilect.
ссылка на оригинал статьи https://habr.com/ru/articles/896240/
Добавить комментарий