Введение

Основной тренд в мире 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 также бывает быстро и легко, если её правильно готовить.

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