Spring MVC vs Spring WebFlux. Что лучше? Объясняем на пингвинах

Существует множество способов реализации REST-API. Большой популярностью пользуется Spring MVC на основе блокирующих вызовов, но все чаще попадаются проекты, использующие WebFlux на неблокирующих вызовах. Меня зовут Альберт Фатхудинов. Я Java-разработчик Технократии. В этой статье буду разбираться, какой из этих двух фреймворков работает лучше. 

Технологии, которые использовали

Дисклеймер, в котором перечислим технологии, которые применялись в эксперименте:

  • Apache JMeter — для нагрузочного тестирования

  • VisualVm — для профилирования

  • MongoDb — NoSql База данных

  • PostgreSql — реляционная база данных

  • Netty — серверная среда неблокирующего ввода/вывода для разработки сетевых приложений

  • Apache Tomcat — контейнер сервлетов

  • Spring MVC — Фреймворк, обеспечивает архитектуру паттерна MVC при помощи слабо связанных готовых компонентов

  • Spring WebFlux — фреймворк, реализующий парадигму реактивного программирования, добавлен в Spring 5+

На этом дисклеймер закончился. Поехали!

Архитектура тестового приложения

Схема сервиса для уведомления пользователя о штрафах, налогах и коммунальных платежах (Notification Service)Схема сервиса для уведомления пользователя о штрафах, налогах и коммунальных платежах (Notification Service)

Представим абстрактного пингвина, которого зовут Шкипер. Он хочет узнать о своих последних штрафах, налогах и коммунальных платежах. Для этого он использует NotificationService. Но есть несколько проблем:

  1. Время ответа БД PostgreSql и MongoDb — от 30 мс до 200 мс (специально занижал производительность БД неправильными индексами и большой вложенностью Json. Также добавил по 1 млн записей в каждую БД)

  2. Время ответа удаленного сервиса — 1 — 3 сек

  3. Шкипер будет ждать ответа от сервиса уведомления по самому медленному источнику т.е минимальное время ответа составить 1 — 3 сек

  4. Внутреннюю реализацию сервиса (MVC Tomcat) можно посмотреть здесь.

Тестовые запросы

Время ответа от сервиса входит в диапазон от 1 до 3 секунд.

d1aed4670ad6dd3e3ca964d214896e0c.png65b822313e1d41714e851b29af903898.png

Такое время ответа не устраивает как пингвинов, так и нас. Нужно разобраться в чем же проблема.

Блокирующие вызовы

Без нагрузки Tomcat, который по умолчанию используется в starter-web Спринга, создаёт 10 потоков http exec. При нагрузке же он может масштабироваться до 200 потоков. В моем примере томкат масштабировался примерно до 170 потоков при единовременной нагрузке в 1000 пользователей.

Без нагрузкиБез нагрузкиПод нагрузкой. Огромное количество потоков крадут друг у друга процессорное время. Нецелесообразное использование мощностей процессораПод нагрузкой. Огромное количество потоков крадут друг у друга процессорное время. Нецелесообразное использование мощностей процессора

Нагрузочное тестирование показало, что 1000 одновременных запросов сервис обработал за 2 минуты 38 сек, количество ошибок составило 23.1% от общего количества запросов при пропускной способности в 6.3 запроса в секунду.

Сводная таблица по нагрузочному тестированиюСводная таблица по нагрузочному тестированию

Модель Tomcat основана на блокирующих вызовах. Когда поток обращается к БД или удаленному сервису, он блокируется и, пока не будет получен ответ, так и будет находится в заблокированном состоянии. 

Как ведет себя поток в стандартном MVC приложении на TomcatКак ведет себя поток в стандартном MVC приложении на Tomcat

WEBFLUX Неблокирующие вызовы

Перейдем к другой реализации. Что за такой зверь WebFlux? Это микрофреймворк, который представляет полностью асинхронный и неблокирующий веб-стек, который позволяет обрабатывать большее количество одновременных запросов по сравнению с стандартным MVC.

dda1b774db25773a0ae6a4a3d33a3b06.png

В основе WebFlux лежит Project reactor — project reactor это библиотека java 8, которая реализует модель reactive streams и предоставляет реактивные типы. По умолчанию WebFlux использует Netty. Возникает логичный вопрос: «Это все очень интересно, тут у нас и reactive streams, и реактивные типы, и еще Netty. Но что же это такое?» как раз на этот вопрос ответим чуть ниже.

Reactive streams

81acfc34afd55110594abce194426bd5.png

Это стандартный способ асинхронной обработки в потоковом стиле. В него входят следующие интерфейсы: subscriber, publisher, subscription и processor

Принцип работы reactive streams:

Subscriber подписывается на publisher(subscribe ()), но общаться с publisher будет через subscription

Subscription получает данные от publisher и отгружает их в подписчика (onNext (data)). 

C помощью методов onError () и onComplete () Subscription принимает от Subscriber информацию о том, сколько данных он хочет получить от publisher через метод request (n). Старый добрый паттерн Наблюдатель, в лучшей реализации.

Реактивный сервер?

Да! Мы преобразуем наш сервис уведомления о штрафах в реактивное приложение. Для начала представим реактивную архитектуру:

Архитектура реактивного приложенияАрхитектура реактивного приложения

Приложение состоит из 5 составляющих:

  1. HTTP Server. В нашем случае Netty, так как WebFlux по умолчанию предоставляет данный сервер.

  2. Реактивный адаптер. Интересно и зачем же здесь адаптер? Все очень просто. Netty и WebFlux не совместимы, поэтому здесь и нужен адаптер.

  3. Spring WebFlux.

  4. Контроллер.

  5. Репозиторий для коннекта с БД.

  6. И последнее, все элементы архитектуры начинают общаться с помощью реактивного типа FLUX

Теперь подробнее про каждую часть.

NETTY

NETTY — асинхронная среда сетевых приложений, управляемая событиями. На входе у Netty в бесконечном цикле крутится поток. За счет каналов и селекторов он перенаправляет входящие запросы во входящие буферы и делегирует обработку запросов выделенному пулу асинхронных потоков.

659549ed270b3910f349fe92bea4a23b.png

Есть очередь событий и event loop, который их обрабатывает и делегирует пулу асинхронных потоков. В то же время происходит регистрация Callback-а. Он вызывается для отгрузки данных, после завершения обработки асинхронным пулом потоков.

4aba746f33dae2124ff2c4eb9b94d480.png

Как же это выглядит в приложении? А вот как:

3d8158e41fd45b7812f922e1dcdbb1b6.png

Поток подписывается на определенное событие (выгрузка данных из БД), получает callback и идет работать дальше. После того, как данные будут готовы, поток вернется чтоб их забрать.

Reactive Adapter

0287e3b5b5cde58d7c8a5d065680595d.png

Вернемся к реактивному адаптеру. Я упомянул, что Netty и WebFlux несовместимы. Вот тут и появляется Reactor IPC.

Это расширение, позволяющее интегрироваться с различными платформами и системами. Когда запрос поступает на Netty, он обрабатывается ChannelOpertaions, затем вызывается цепочка вызовов, которая достигает Dispatcher handler, а затем запрос достигает контроллера. 

Затем на основе publisher выстраивается поток, достигающий ChannelOpertaions. Следом в ChannelOpertaions вызывается метод subscribe. Только в этот момент поток начинает свою работу.

Реактивные типы

1dc4fd98ba536138ad7c3a35a8eef425.png

К реактивным типам относятся Mono и Flux. Они имплементируют интерфейс publisher, т.е являются источниками данных.

  • Если нам нужно отгружать пользователю от 0 до N объектов используем FLUX

  • Если же нужно отгружать от 0 до 1 элементов используем MONO

  • Если мы ничего не хотим отгружать используем MONO

Publisher делят на два вида HOT и COLD.

Приведу аналогию: фильм, запущенный на Netflix с самого начала — это cold publisher, а стрим на twitch, в который мы ворвались на середине, — HOT. Cold publisher начинает отгружать данные, когда на него подписываются с самого начала, а Hot publisher отгружает данные тем, кто подписался с момента остановки отгрузки данных.

Элемент Processor импелементирует интерфейс subscriber и publisher, используется для обработки данных Mono или Flux для того, чтобы не обрывать стрим.

Теперь переведем сервис уведомления о штрафах из стандартного MVC в реактивный. Шкиперу должно понравиться.

Реактивное приложение

Начнем переводить наше приложение со стандартного MVC на WebFlux.

Чтобы перейти на WebFlux, нам нужно поменять зависимость с web на webflux. Мы получим готовый к использованию реактивный сервис.

БылоБылоСталоСтало

Изменим контроллер так, чтобы он возвращал реактивный тип:

ae8932e2edf723968e97b5018eedac19.png

И проведем нагрузочное тестирование (1000 единовременных пользователей):

Без нагрузкиБез нагрузки Под нагрузкойПод нагрузкой

Без нагрузки создался один поток reactor-http-nio-1. Он работает постоянно. Под нагрузкой NETTY масштабировал количество потоков до 12. Мой ноутбук 6-ядерный, работает в 12 потоках, поэтому NETTY масштабировал на количество потоков процессора. Они также все время работают и не простаивают.

1. Таблица MVC 2. таблица WebFlux1. Таблица MVC 2. таблица WebFlux

Нагрузочное тестирование показало: обработали быстрее, но получили большее количество ошибок. Непорядок.

f4b5c098cb7a9f9cacd21f9261ad211e.png

«Что и требовалось доказать, еще не много и нас взорвут» — говорит Шкипер.

Нужно разобраться, в чем причина такого количества ошибок.

Основная проблема — блокирующие вызовы к БД и удаленному сервису.Основная проблема — блокирующие вызовы к БД и удаленному сервису.Netty делегирует обработку событий асинхронным потокам. Этих потоков критически мало по сравнению с Tomcat: 200 против 12Netty делегирует обработку событий асинхронным потокам. Этих потоков критически мало по сравнению с Tomcat: 200 против 12

Если заблокируются все потоки в AsyncThreadPool, придется откидывать запросы, пока не освободится AsyncThreadPool. 

Сперва сделаем БД реактивными. Для этого подключим реактивные драйверы на MongoDb и PostgreSql. Начнем с MongoDb.

Reactive Mongo Driver

MongoDb предоставляет свою реализацию reactive streams. Чтобы ее использовать, нам нужно поменять зависимости.

былобылосталостало

Теперь поменяем имплементацию репозитория и сменим ее на реактивную.

БылоБылоСталоСтало

Теперь наш репозиторий реактивный. Вместо List возвращаем реактивный тип Flux. Он возвращает нам от 0 до N элементов. Реактивный драйвер MongoDb использует под капотом Netty, а в прошлых версиях asynchronous socket channel. Если хотите разобраться в этом глубже, переходите по ссылке.

Reactive Driver PostgreSql

Наступило время превратить нашу реляционная базу данных в реактивную. 

Компания Pivotal релизнула spring-data-r2dbc (Декабрь 2019), что позволяет легко перейти на реактивный драйвер.

Заменим зависимости:

БылоБылоСталоСтало

Изменения незначительные, репозиторий имплементирует r2dbc репозиторий и возвращает Flux.

БылоБылоСталоСтало

Из интересных фактов: под капотом свою работу выполняет Netty. Для разбора закрепляю ссылку на r2dbc-driver.

WebClient

Перейдем к самому интересному — интеграции с удаленном сервисом. Вместо RestTemplate будем использовать более удобный и крутой WebClient.

БылоБылоСталоСтало

Client возвращает реактивный тип Mono.

Reactive Service

Теперь приступим к изменению сервиса и контроллера, так как они должны возвращать тоже реактивные типы.

БылоБылоСталоСтало

Операцией flatMapIterable преобразуем Mono> → Flux. Операцией Flux.merge соединяем несколько Flux в один общий Flux.

Reactive Controller

Также меняем контроллер.

БылоБылоСталоСтало

MediaType.APPLICATION_STREAM_JSON_VALUE дает понять контроллеру, что мы будем стримить данные пользователю пачками. То есть, как будет готов объект NotificationDTO, он сразу отгрузится пользователю, не дожидаясь остальных.

Как это выглядит в браузере:

Данные приходят не массивом, а пачками, по готовностиДанные приходят не массивом, а пачками, по готовностиtransfer-encoding: chunked → позволяет надежно доставлять данные от сервера без необходимости заранее знать точный размер всего HTTP-сообщения. Как доказательство не присутствует Content-Length.transfer-encoding: chunked → позволяет надежно доставлять данные от сервера без необходимости заранее знать точный размер всего HTTP-сообщения. Как доказательство не присутствует Content-Length.

Теперь с пользователем общаемся с помощью событий. Каждое уведомление (NotificationDTO) и есть событие, которое нужно обработать. Пользователь не нужно ждать всего ответа.

Осталось поменять application.yaml.

Было. Обратите внимание на Jdbc(блокирующий драйвер).Было. Обратите внимание на Jdbc (блокирующий драйвер).Стало. Поменял jdbc → на r2dbc. Для MongoDB настройки остались такими жеСтало. Поменял jdbc → на r2dbc. Для MongoDB настройки остались такими же

Нагрузочное тестирование реактивного приложения

Нагрузочное тестирование MVCНагрузочное тестирование MVC WebFlux с блокирующим БД и RestTemplateWebFlux с блокирующим БД и RestTemplate WebFlux c реактивными драйверами и WebClienWebFlux c реактивными драйверами и WebClien

Из нагрузочного тестирования видно, что мы смогли обработать 1000 одновременных запросов за 2:35, что быстрее MVC на 3 сек. Количество ошибок меньше на 9%. Реактивный сервис обработал большее количество запросов при меньшем количестве потоков. Заглянем в профилировщик.

Без нагрузки. Общее количество потоков составило 30(Netty). У MVC 34(Tomcat)Без нагрузки. Общее количество потоков составило 30(Netty). У MVC 34(Tomcat) Под нагрузкой. Общее количество потоков составило 77(Netty). У MVC 221(Tomcat). Потоки работают на максимуме, используя всю мощность процессора.Под нагрузкой. Общее количество потоков составило 77(Netty). У MVC 221(Tomcat). Потоки работают на максимуме, используя всю мощность процессора.

Не напрягая зрение видно, что, помимо потоков приложения (reactor-htp-nio), появились еще два вида потоков:

  • reactor-tcp-nio — потоки для обслуживания r2dbc драйвера

  • nioEventLoopGroup — потоки реактивного драйвера MongoDB

Модернизированный сервис уведомления о штрафах

b5b48390ba83e4be8653401a2ac40d76.png

Новая архитектура позволяет делать выводы, что сервис отвечает требованиям Reactive Manifesto:

98812dc955fb06a45015586c57f246b7.png
  1. Responsive (отзывчивость) — с пользователем общаемся с помощью событий. Он получает все частями и работает с ними, даже если в процессе возникнет ошибка, пользователь получит часть данных.

  2. Elastic (Эластичность) — приложение использует минимальное количество потоков, они не простаивают и работают на максимум. Также применимо вертикальное масштабирование. Мощнее процессор, больше потоков.

  3. Message Driven — сервисы должны общаться с помощью событий. Реактивный сервис с помощью реактивных драйверов и WebClient общаются между собой именно так. То же и с пользователем: за счет MediaType.APPLICATION_STREAM_JSON_VALUE стримим событиями.

  4. Resilient (Устойчивость) — сервис должен адекватно реагировать на возникновение ошибок. В модернизированном приложении не делал никаких действий по устойчивости для чистоты эксперимента.

Ну что, скажешь на это, Шкипер?

4a644ebcd6705feeca842d87aba633f5.png

Вопрос закрыт. Пингвины не справились с потоком штрафов, заказали судно и решили скрыться из страны. Миссия выполнена.

Вывод

  1. WebFlux не про скорость обработки запроса, а про одновременное обслуживание большого количество запросов.

  2. Нужно правильно продумать архитектуру приложения, чтобы не было блокирующих соединений. Система должна отвечать требованиям Reactive Manifesto.

  3. Чтобы избавиться от блокировки бд и интеграции с медленными сервисами, используем реактивные драйверы и WebClient (R2dbc и Reactive MongoDb driver)

  4. WebFlux лучше всего использовать для большого количества одновременных запросов (Высоконагруженные системы)

  5. Если есть большое количество блокирующих соединений и малое количество одновременных запросов, лучше посмотреть в сторону стандартной реализаций MVC на tomcat

© Habrahabr.ru