Ускорение Spring REST API на 200%

от автора

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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *