Как стать автором
Обновить
101.87
Maxilect
Карьера в IT: работай удаленно с экспертами

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

Время на прочтение7 мин
Количество просмотров8.6K

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.

Теги:
Хабы:
+16
Комментарии12

Публикации

Информация

Сайт
maxilect.com
Дата регистрации
Дата основания
2015
Численность
31–50 человек
Местоположение
Россия

Истории