Введение

Основной тренд в мире IT сейчас - экономия ресурсов и легковесные решения. В облаке мы платим только за те ресурсы, которые действительно используем. И чем эффективнее мы их используем, тем меньше денег тратится впустую: уменьшая издержки, увеличиваем маржинальность.

Java долгое время была и остается фаворитом в enterprise решениях, но все чаще в высоконагруженных проектах предпочтение отдается более "производительным" языкам, таким как Go, а порой даже C++. Но вдруг Java тоже может быть быстрой?

Вечная весна

Стандартным и наиболее часто используемым фреймворком в Java-community является Spring . Когда-то Spring был глотком свежего воздуха по сравнению с монструозными enterprise-серверами. Spring предоставляет огромное количество библиотек и интеграций, объединенных в единую экосистему.

Однако, несмотря на удобство, у Spring есть ряд недостатков:

  1. Скорость погружения. Разработчику недостаточно просто знать Java, чтобы понимать и писать код на Spring. Необходимо учить особенности фреймворка, его абстракции и правила работы с ними. Это увеличивает время вхождения для новых разработчиков, не знакомых с фреймворком.

  2. Изоляция разработчиков от низкоуровнего API. При работе с БД, очередями сообщений или другими системами Spring предоставляет свои готовые абстракции. Это позволяет разработчикам не углубляться в нюансы работы конкретных API. Часто разработчики Spring не умеют работать с БД или очередями в отрыве от Spring.

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

  4. Производительность. За удобство мы платим производительностью. Приложения на Spring довольно тяжеловесные - они долго запускаются и потребляют много ресурсов. Изоляция разработчиков от API также плохо влияет на производительность, т.к. не позволяет использовать API на полную мощность.

В остальном Spring был, есть и долгое время будет оставаться стандартным решением для большинства проектов. Благодаря мощной экосистеме и огромному community выбрать Spring как основу для своего проекта вряд ли будет ошибкой. Но если хочется чего-то модного, молодёжного и быстрого...

Какие альтернативы?

Хоть Spring и лидирует по популярности в Java-community, существует ряд альтернативных решений. На мой взгляд, наиболее известные и перспективные:

  • Microprofile и различные его имплементации - легковеснее, чем Spring, но в основе Java EE. По сути та же "магия", но поменьше.

  • Ktor - микрофрейморк для Kotlin. Ничего лишнего, хорошая производительность, правда подходит только командам, которые любят и исповедуют Kotlin.

Но в этой статье я бы хотел рассказать о молодом и не столь известном решении - Helidon SE и Helidon Níma.

Полет ласточки

Helidon (Ласточка) - это молодой Java-фреймворк с прицелом на максимальную легковесность и работу в облаке. У него есть 2 версии:

  • MP - реализация того самого Microprofile. Надстройка поверх SE версии;

  • SE - cloud-native микрофреймворк.

Именно SE интересует нас в рамках данной статьи.

Философия Helidon SE в корне противоположна Spring и Java EE:

  1. Прозрачность и полный контроль разработчика над фреймворком;

  2. "No magic" подход без Inversion of Control и автоконфигураций.

Вот так выглядит создание простого приложения с веб-сервером:

public final class Main {

    public static void main(final String[] args) {
        LogConfig.configureRuntime();

        //Конфигурация автоматически подтягивается из ENV и папки resources.
        //Поддерживаются YML, properties, json и других форматы.
        Config config = Config.create();

        //Веб-сервер - просто Java-объект. Мы имеем над ним полный контроль.
        WebServer server = WebServer.builder()
                //Конфигурация сервера - хост, порт и т.д. 
                .config(config.get("server"))
                //Правила маршрутизации запросов
                .routing(routing -> {
                    routing.get("/greeting", (req, res) -> res.send("Hello World!"));
                    routing.post("/greeting/{name}", (req, res) -> {
                        String name = req.path().pathParameters().get("name");
                        res.send("Hello %s!".formatted(name));
                    });
                })
                .build()
                .start();
    }
}

Helidon предоставляет разработчику набор библиотек, которые покрывают большую часть потребностей современных микросервисных приложений. "Из коробки" доступны health-checks, метрики, трассировка. Также есть поддержка gRPC, WebSocket, OpenAPI, MQ.

Helidon Níma - виртуальные потоки вполне реальны

Helidon Níma - первый веб-фреймворк, полностью построенный вокруг Project Loom и виртуальных потоков. Но чтобы разобраться, какие преимущества это даёт, придется немного погрузиться в теорию.

Существуют 2 модели веб-серверов:

  1. Блокирующие - на каждый HTTP запрос создается поток, который блокируется при любом блокирующем вводе-выводе (любое сетевое взаимодействие). Код пишется в стандартной, императивной парадигме. Среди блокирующих серверов наиболее часто используются embedded-сервера Tomcat, Jetty.

  2. Неблокирующие - имеется небольшой общий пул потоков, который обрабатывает входящие запросы. Потоки обрабатывают множество запросов одновременно, получая и отправляя события. При этом любое сетевое взаимодействие должно использовать неблокирующий ввод-вывод. Для этого приходится использовать специальные библиотеки (Netty Client вместо HttpClient, R2DBC вместо JDBC). Код пишется в реактивной парадигме с использованием ProjectReactor. Самый популярный неблокирующий Java веб-сервер - Netty.

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

Helidon SE версии 3 строится на базе неблокирующего веб-сервера Netty и использует реактивную модель.

Но 19 сентября 2023 вышла Java 21 и принесла Project Loom - виртуальные потоки для Java. Если кратко описать, чем так хороши виртуальные потоки - они позволяют писать неблокирующий код в императивной парадигме. Мы можем взять лучшее от двух миров: эффективность реактивного подхода и простоту императивного.

Команда Helidon решила быть "на острие технологий" и еще до выхода Project Loom в релиз начала делать веб-сервер, который бы базировался полностью на виртуальных потоках. Имя этой технологии - Helidon Níma и на момент написания статьи актуальная версия 4.0.0-M2 - release candidate, который пока не готов для production, но отлично подходит для экспериментов. Níma, работающий на виртуальных потоках, позволяет добиться производительности реактивного веб-сервера (и даже больше), используя простую и понятную императивную модель.

Helidon 4 будет использовать Níma веб-сервер как в SE, так и в MP версии.

Болтовня про производительность - это здорово, но давайте посмотрим как это будет работать в действии. Я хочу показать результаты тестирования Helidon Níma 4.0.0-M2 в сравнении с Spring WebFlux и Spring WebMVC.

Результаты тестирования

Подготовка к тестированию

Для проведения тестирования я взял простой пример: сервис должен забрать 100 строк из таблицы в БД Postgres, перевести их в Json и вернуть клиенту. Запрос к БД - самый частый тип блокирующего вызова, а нагрузку на веб-приложения обычно создают множественные запросы на чтение.

Для бенчмарка я создал 3 проекта:

  • Helidon Nima, JDBI

  • Spring WebFlux, Spring Data R2DBC

  • Spring WebMVC, Spring Data JDBC / JDBI (Spring 3.1.4)

Код доступен в репозитории.

Версии библиотек:

  • Spring 3.1.4

  • JDBI 3.41.1

  • Helidon 4.0.0-M2

Приложения запускались через docker compose на моей локальной машине с ограничением на ресурсы.

Использовалось 2 конфигурации:

  • 1 CPU + 1Gb memory

  • 2 CPU + 2Gb memory

В качестве базы был выбран образ container-registry.oracle.com/java/openjdk:21, JVM запускалась с
настройками -XX:InitialRAMPercentage=90.0 -XX:MaxRAMPercentage=90.0. GC по умолчанию - G1GC.

В каждом приложении использовался пул потоков (Hikari для Helidon/Spring WebMVC и R2DBC POOL для WebFlux) с фиксированным размером пула в 100 подключений.

Перед тестированием на сервисы подавалось 100к запросов для разогрева JVM. Нагрузка подавалась через AutoCannon с 3 сценариями:

  • 100 потоков-пользователей - по размеру пула подключений к БД;

  • 200 потоков-пользователей - в 2 раза превышающий пул;

  • 1000 потоков-пользователей - пиковая аномальная нагрузка.

Число одновременных запросов равно числу потоков, а число запросов в секунду ограничено только производительностью сервиса.

Полученные данные я занес в таблицу и визуализировал ее в виде диаграмм:

По результатам тестирования Níma вышла абсолютным победителем как по RPS, так и по времени ответа.
Чтобы сделать тестирование более объективным, в одном из тестов я заменил persistence слой в MVC версии с Data Jdbc на более легковесную библиотеку JDBI (ту же, что использовал в Níma). Это дало прирост производительности, но не спасло Web MVC от разгрома.

Вместо заключения

Поговорим о деньгах.

Níma смог обрабатывать в 4.5 раз больше запросов в секунду, чем Spring WebFlux, используя те же ресурсы. Понятно, что эти результаты приблизительные и могут сильно отклоняться в реальных задачах в обе стороны, но я позволю себе достать калькулятор и пофантазировать.

Представим высоконагруженную систему из 10 микросервисов, расположенную в трёх зонах доступности. В каждой зоне находится минимум один МС. Каждый МС работает на машине с 1CPU и 1GB памяти. Тогда, чтобы решение на Spring могло обрабатывать количество запросов, сравнимое с решением на Helidon, нам понадобится в 4-5 раз больше экземпляров приложений.

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

Цена

Час

Год

Год 10 МС

Год 10 МС x 3 экземпляра

Год 10 МС x 15 экземпляров

Разница

1 CPU

1,12 RUB

9 811,20 RUB

98 112,00 RUB

294 336,00 RUB

1 471 680,00 RUB

1 177 344,00 RUB

1 Гб ОЗУ

0,39 RUB

3 416,40 RUB

34 164,00 RUB

102 492,00 RUB

512 460,00 RUB

409 968,00 RUB

Суммарно

1,51 RUB

13 227,60 RUB

132 276,00 RUB

396 828,00 RUB

1 984 140,00 RUB

1 587 312,00 RUB

Я надеюсь, что мне удалось немного развеять миф о том, что Java - это тяжело и медленно. Java также бывает быстро и легко, если её правильно готовить.

Дополнительные материалы

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


  1. Algrinn
    03.10.2023 11:01
    +7

    Java может быть быстрой, чтобы в этом убедиться, нужно смотреть Web Framework Benchmarks, там каждый год тестируют фреймворки на разных языках, несколько сотен. Если кратко, могут C/C++, может Rust. Go пока не может. Java тоже может, хотя и не так хорошо, как C/C++ Rust. Spring не может и никогда не мог.


    1. taishi-sama
      03.10.2023 11:01

      Результаты Web Framework Benchmarks, кстати, лучше перепроверять. А то как минимум год назад был казус, когда разработчики ASP.NET core нарушили правила бенчмарка в своём предложенном решении(разработки фреймворков имеют право сабмитить свои реализации бенчмарка, чтобы они, зная как всё работает внутри, продемонстрировали максимальную производительность), и, например, генерировали ответ на запрос не шаблонизатором, а сложением строк, и также вручную дёргали лежащий в основе фреймворка веб-сервер, вместо того, чтобы дёргать его через фреймворк, как предписывали правила.


  1. Antharas
    03.10.2023 11:01
    +3

    Интересно, что же все таки случится в «правильном тесте» webflux, если добавить к вызову репозитория switch context скажем bounded elastic пул. В MVC - где тесты с тем же CompletableFuture, а как же loom вместо стандартной фабрики потока на запрос?

    Описание пайплайна не верное, тест можно считать провальным.


    1. AnatoliyYakimov Автор
      03.10.2023 11:01
      -5

      Не соглашусь. Идея теста была в том, чтобы сравнить фреймворки "из коробки" без глубокого тюнинга и оптимизаций. Понятно, что если в каждом решении поиграть с настройками, то можно получить небольшой прирост производительности. Частично я это показал, заменив Data Jdbc на более легковесную библиотеку в одном из тестов.

      По тезисам:

       если добавить к вызову репозитория switch context скажем bounded elastic пул

      Тестирование различных пулов Reactor стоит оставить для статей по реактору.

       а как же loom вместо стандартной фабрики потока на запрос

      Аналогично, такие сравнение следует оставить специализированным статьям. Например, этой: Project Loom и Spring Boot: тесты производительности. Спойлер - не поможет, т.к. Tomcat не был заточен под Project Loom.

      В MVC - где тесты с тем же CompletableFuture

      Чем в данном случае поможет CompletableFuture? Если завернуть вызов еще в один поток выполнения, производительность от этого не вырастет.


      1. Antharas
        03.10.2023 11:01
        +4

        Тест с reactor в корне неверный. Обратитесь к документации, где четко сказано, что любое IO необходимо публиковать на выделенный Schedulers. Иначе, что вы хотите сказать графиками - reactor хуже servlet stack?

        Тестирование различных пулов Reactor стоит оставить для статей по реактору.

        Если тестировать - то правильно. Как минимум по графикам уже понятно, чем event loop занят 99% времени.


      1. ivankudryavtsev
        03.10.2023 11:01
        +1

        Так это херня, а не тесты - сравнение круглого с квадратным. Вы что тестируете Java или кучу кода с непонятными функциями, которые под капотом фреймворков? Вроде в банкинге и финтехе никто не жалуется на медленность Java.


  1. aleksandy
    03.10.2023 11:01
    +8

    на каждый HTTP запрос создается поток, который блокируется при любом блокирующем вводе-выводе

    Такую хрень разве что студенты-самоучки по доисторическим учебникам могут написать. Java 1.5 зарелизилась 19 лет, в ней появились пулы потоков "из коробки". И создавать/пускать потоки вручную с той поры следует лишь в случае, когда ты реально понимаешь зачем тебе это надо.

    Среди блокирующих серверов наиболее часто используются embedded-сервера TomcatJetty.

    Интересно с какого это перепоя tomcat и jetty вдруг опять стали блокирующими? Синхронная обработка запросов != блокирующий сервер. А jetty, емнип, вообще изначально писался на неблокирующем nio.


    1. AnatoliyYakimov Автор
      03.10.2023 11:01
      -1

      Речь шла как раз о блокируещем IO. Который по умолчанию используется в Tomcat и Jetty. В статье нет цели разобрать работу указанных веб-серверов в деталях, лишь схематически обозначить разницу в подходах.

      создавать/пускать потоки вручную с той поры следует лишь в случае, когда ты реально понимаешь зачем тебе это надо.

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

      Такую хрень разве что студенты-самоучки по доисторическим учебникам могут написать

      Если не умеете общаться уважительно и по делу, то, возможно, вообще не стоит писать комментарии.


      1. aleksandy
        03.10.2023 11:01
        +7

        Никто и не говорит, о том что при блокирующем IO не используются пулы потока.

        А как это понимать?

        на каждый HTTP запрос создается поток

        Не выделяется, не назначается, а именно создаётся.

        не умеете общаться уважительно

        Ах, прошу меня великодушно извинить за то, что ранил Вашу тонкую натуру.

        по делу

        Я, собственно, по делу и написал: создавать (прошу заметить, не получать из пула, а именно создавать) отдельный поток на запрос - это самый распространённый подход при реализации сетевого общения в примерах учебников года до 2007 уж точно. И на сегодняшний день это является антипаттерном и не рекомендуется к использованию.


        1. advbg
          03.10.2023 11:01

          Пo антипаттерну: ну вот с появлением виртуальных тредов это больше не антиаттерн. Нужен поток - создай виртуальный, сделай в нем что нужно, и выброси. Это отныне очень дешево и производительно. Времена меняются!


          1. aleksandy
            03.10.2023 11:01

            Думаю, что Вы видите разницу между "создай поток" и "создай виртуальный поток".

            И даже в этом случае, дабы было единообразие в коде, лучше использовать специальный ExecutorService.


            1. advbg
              03.10.2023 11:01

              В целом да, можно придраться. Но, как человек, который работает с виртуальными тредами каждый день, могу сказать, что фраза "создать поток" для меня уже давно значит создать именно виртуальный поток. А создать несущий тред (carrier thread) или реальный тред - говорит о том реальном треде.


              1. aleksandy
                03.10.2023 11:01

                А сколько в сообществе вас таких, для которых

                фраза "создать поток" для меня уже давно значит создать именно виртуальный поток

                ?

                Всё-таки, думаю, что пока ещё меньшеньство. А пиша статью, ориентироваться стоит на большинство, иначе "провинция-с, не поймут.".


  1. Kotsuba
    03.10.2023 11:01
    -2

    Не понимаю за что заминусили автора


    1. Hivemaster
      03.10.2023 11:01
      +11

      За то, что он рассуждает о том, в чём мало понимает. И за то, что архитектор решения написал статью достойную джуна.


    1. noavarice
      03.10.2023 11:01
      -2

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


  1. schernolyas
    03.10.2023 11:01
    +2

    Честно говоря ... бестолковая статья т.к. сравниваются .... web сервер с фреймворком высокого уровня. Судя по представленному выше коду, логичнее сравнивать Nima с Netty, Undertow и подобными вещами.


  1. m0mus
    03.10.2023 11:01
    +5

    Спасибо за статью. Главный момент тут в том, что правильное использование Virtual Threads, дает производительность, сравнимую с Netty, но без рективщины. Блокинг код более понятный, его проще отлаживакть и проще писать.

    Мы начали делать Ниму более года назад в тесной кооперации с ребятами их JDK. Было важно именно написать веб-сервер с нуля, а не переделывавть существующий. Мы пробовали, но решение было половинчатым и не давало преимуществ. А Нима дает. Ребята из Java испоьзуют Нима для тестирования и на презентациях, как пример удачного использования Virtual Threads. И новый Хелидон, в основе которого лужит Нима, тоже очень хорош по производительности. Кому интересно, RC1 уже доступен, GA планируется в этом месяце.

    По поводу бенчмарков, если интересны другие тесты, то независимые от TechEmpower можно посмореть по ссылке. Там и Quarkus есть и Spring Boot.


  1. advbg
    03.10.2023 11:01
    +5

    Во-первых хочу сказать - большое спасибо за статью! Она отличная!
    Во-вторых.. Прочитав все коментарии, мне показалась, что все описанное слишком “революционно” для многих комментаторов. И мне тоже показалось, что большинство все еще не делают разницы между “обыкновенными” тредами и виртуальными тредами.
    Я попытаюсь внести немного больше ясности и в терминологию Хелидона, хотя автор в целом все правильно написал.
    Helidon Nima это внутреннее название веб сервера написанного с нуля в блокирующей парадигме с использованием виртуальных тредов. Ныне официально этот веб сервер называется Helidon Web Server (вот так просто). Старое имя Nima больше не используется.
    Helidon SE и Helidon MP это уже полноценные фреймворки для написания микросервисов. SE использует максимально “джавовый” стиль программирования, а MP сертифицирована по MicroProfile спецификации, и в ней много всякой магии в стиле спрингов (типа поставил пару аннотаций и все само заработало).
    В версиях 1-3 низкоуровневым веб сервером служил Netty. В Helidon SE был свой отдельный независимый реактивный движок Helidon Reactive Engine (его, кстати, помогал писать сам Дейвид Карнок). Все API фреймворка Helidon SE были реактивными на базе этого движка и Netty в основе. То есть, если хочешь написать микросервис, то надо написать его в реактивной парадигме используя Single, Multi (хелидоновские аналоги Mono и Flux) и кучей реактивных операторов.
    С появлением Helidon Web Server (в прошлом Helidon Nima) было принято решение переписать весь Helidon SE под “блокирующую” парадигму и полностью убрать Netty. Тем самым фреймворк Helidon SE 4 обзавелся новым “нереактивным блокирующим” API и новым Helidon Web Server. То есть с версии 4 нет всех этих Single и Multi и реактивных операторов. Просто пишется блокирующий код, как нас учили в 5м классе.
    И что получилось - команде Helidon мы сравнивали Netty с Helidon Web Server, и этот Helidon Web Server оказался на пару процентов быстрее. Здророво то, что код самого веб сервера стал меньше по объему, понятнее и легким в отладке.
    Насколько я вижу, автор сравнивает webflux именно с Helidon SE 4 (который работает на основе Helidon Web Server). То есть сравнивает фреймворки. И насколько я вижу из примера, там не только сам Helidon Web Server, но и метрики и всякие observability фичи, и т.д.
    Поэтому сравнение вполне корректно.
    Я погонял пример у себя (M1 Max, 64 Gb RAM, Aarch64 JDK21), и результаты схожие.
    Так что, все вышеописанное верно! Еще раз спасибо за статью!
    Ознакомиться с результатами бенчмарков можно из ответа m0mus.


    1. advbg
      03.10.2023 11:01
      +4

      Ну и стоит добавить, что пользователи Helidon MP получат троекратный (по нашим бенчмаркам) performance boost, просто поменяв версию на 4. Ведь MicroProfile это стандарт, сам код микросервиса менять не надо.


  1. Artemik
    03.10.2023 11:01
    +1

    1. Продвижение Project Loom, это здорово. Будут интересны результаты Spring MVC, когда появится "коробочная" версия, без танцев с бубном.

    2. В тестах Spring MVC, надеюсь, количество tomcat потоков было увеличено с дефолтных 100?

    3. Советую сделать тесты с jetty - изменение одной строки, по прежнему коробочный MVC, а производительность намного выше (по моим наблюдениям).

    4. Хочется отметить ещё раз важность понимания что именно тестируется. Тесты WebMvc Jdbi это наглядно демонстрируют. Может получиться так, что не так далеко нужно уйти от коробочной версии, чтобы получит схожий буст производительности. Ещё пример - Spring Data и нативный R2DBC имеют разницу в примерно 1.5 раза.

    5. Все таки важно тестировать на разных машинах. В частности, сетевые запросы к бд, вроде бы, и не идут через local loopback, из-за использования докера, но подозреваю, что все таки, это намного быстрее реального сетевого запроса. В реальности вся видимая разница может исчезнуть.

    6. Важно тестировать на нескольких CPU. Например, R2DBC прям до недавнего времени имел огромную деградацию, проявляющуюся на многоядерности.

    7. Про деньги - как бы да, дешевле. Но нужно помнить и про затраты на танцы с новым фреймворком. Его дружбе со всей другой экосистемой итп. Посчитайте сколько стоит один разраб в год, занимающийся этим.

    8. Как по мне, WebServer.builder() такая же "магия" как и @RestController. Как ни крути, любой фреймворк, его поведения итд нужно учить и понимать. Не избежать этого, хоть с аннотациями, что без.

    9. Согласен, Java быстрая. Нужны просто прямые руки. А если прям 200% уверены, что ботлнек не в бд, то есть Vertx с Vertx Sql Client.

    10. Помните, переписав свой проект с нуля, зачастую получится быстрее даже на старом фреймворке! :)