Реактивне програмування на Spring Boot: мій досвід, приклади та розбір загальноприйнятих підходів

💡 Усі статті, обговорення, новини про Java — в одному місці. Приєднуйтесь до Java спільноти!

Привіт усім, я Андрій Микитин, Software Engineer у Sombra, більше 5 років працюю з Java. Протягом цього часу мав справу з різними бекенд-рішеннями — від простих REST API до складних high-load сервісів. Коли з’явилась задача з оптимізації продуктивності на проєкті з великим навантаженням, я почав шукати альтернативу класичному підходу. І знайшов її в реактивному програмуванні.

Сьогодні хочу розповісти про свій досвід із реактивним програмуванням у Spring Boot: як ми до нього прийшли, що пробували, і чим це все закінчилося, а також покажу на прикладах, у чому суть реактивного підходу, як він відрізняється від класичного та асинхронного, і де він справді приносить користь.

Чому я взагалі почав дивитися в бік реактиву

Класичний підхід із блокуючими потоками добре працює, поки система не росте. Але щойно кількість запитів зростає — все починає «буксувати»: потоки блокуються, ресурси зжираються, і жодне масштабування вже не допомагає.

Один із проєктів мав саме такі вимоги:

  • багато паралельних запитів;
  • хмарна інфраструктура, де кожен додатковий ресурс — це $$$;
  • відсутність «прав на помилку» у продуктивності.

Потрібно було вирішити, як:

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

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

Підхід № 1 — блокуючий (старий добрий варіант) на мові Java з використанням Spring Framework

Ось звичний код у контролері:

@GetMapping("user/{id}")
public User getUserInfo(@PathVariable final Long id){
 User user = userService.getUserById(id);
 UserMetaData meta = userMetaDataService.getUserMetaData(id);
 user.setMetaData(meta);
 return user;
}

Якщо проаналізувати виконання даного коду та зобразити це у вигляді Request diagram то можна побачити, що все красиво, просто і зрозуміло. Але... цей код блокує потік на кожному кроці. Уявіть, що таких запитів — тисяча. Потоки простоюють, чекаючи відповідей від BE. Це як завантажити вантажівку і змусити її стояти, поки не прийде накладна.

Підхід № 2 — CompletableFuture: спроба асинхронності

Окей, вирішуємо писати паралельно:

@GetMapping("user/{id}")
public User getUserInfo(@PathVariable final Long id){
 CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> userService.getUserInfo(id));
 CompletableFuture<UserMetaData> metaFuture = CompletableFuture.supplyAsync(() -> userMetaDataService.getUserMetaData(id));
 CompletableFuture.allOf(userFuture, metaFuture).join();
 User user = userFuture.join();
 user.setMetaData(metaFuture.join());
 return user;
}

Виглядає розумно: обидві операції відбуваються паралельно. Але головний потік все одно блокується на .join(). Та й читабельність коду падає. Для простої операції ми отримали набагато складнішу реалізацію. У даному прикладі виклики виконуються паралельно, але зрештою об’єднання результату getUser() у getUserMetaData() блокується, доки не буде отримано відповіді від обох викликів.

Підхід № 3 — реактивність на Spring WebFlux

Реальна перевага проявилась, коли ми спробували WebFlux:

@GetMapping("user/{id}«)
public Mono<User> getUserInfo(@PathVariable final Long id ){
 return userService.getUser(id)
 .zipWith(userMetaDataService.getUserMetaData(id))
 .map(tuple -> {
 User user = tuple.getT1();
 user.setUserMetaData(tuple.getT2());
 return user;
 });
}

Якщо зобразити виконання цього коду на діаграмі, то ми побачимо таку картину:

Це вже reactive. Ми отримуємо результат, коли обидва Mono готові, і весь цей час потік не блокується. Потік вивільняється, щойно доходить до очікування, і продовжує працювати далі — або з іншим запитом, або просто спить.

Mono vs Flux — простими словами

Mono<T> — результатом є один елемент або none.

Flux<T> — це потік об’єктів (від 0 до N).

Вони обидва реалізують Publisher, і за філософією працюють через патерн Observer — ви не тягнете результат, ви підписуєтесь, а коли він готовий — отримуєте.

Як це виглядає у виконанні

Запит приходить → Mono або Flux створюється

Нічого не виконується, поки немає subscribe()

Коли дані приходять — ми їх обробляємо

Потік не простоює → звільняється → масштабування йде вертикально

Реальні плюси

✅ немає блокування потоку

✅ ідеально підходить для хмарних середовищ

✅ можна масштабуватись без втрати продуктивності

✅ код стає гнучким для обробки великої кількості запитів

Але не все так веселково

Реактивність — не про «давайте одразу». Є й зворотна сторона:

🔻 відлагоджування — біль (нема класичного step-through debug)

🔻 складніший код для новачків

🔻 багато сторонніх бібліотек ще не підтримують реактивність

🔻 деякі архітектурні рішення треба буде переписати

Коли я рекомендую реактивність?

Так:

  • коли система має 1000+ одночасних запитів
  • коли інфраструктура обмежена
  • коли у вас мікросервісна архітектура
  • коли у вас WebSocket/Streaming API

Ні:

  • коли це звичайний CRUD-REST сервіс
  • коли треба швидко зробити MVP
  • коли команда без досвіду у WebFlux

Висновок

Реактивне програмування — це не магія. Це підхід, який працює, коли його використовують правильно. У нашому випадку — воно дало ефект, який ми шукали: більше запитів, менше ресурсів, краща продуктивність.

Але ключове — використовувати там, де це має сенс.

Дякую, що дочитали. Якщо цікаво і маєте запитання, коментарі — пишіть

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую за статтю, але тут не вказано, що у випадку з WebFlux весь стек має бути реактивним (включаючи драйвер баз даних).
Тобто якщо у вас, наприклад, для бази Spring Data JPA + JDBC драйвер (а він синхронний), то у вас знову будуть затики з очікуваннями потоків, але вже на рівні запитів до БД.

Дякую за коментар, так, тут це не вказано, стаття була більше орієнтована на основні структури WebFlux і загалом, що таке реактивний підхід. Блокування потоків на рівні комунікації із БД це вже більше до R2DBC теми. Реактивний підхід сам по собі доволі таки складна тема і не дуже хотілось ускладнювати ще більше.

Дякую за гарне інтро.
Останні тренди суттєво змінюються з появою віртуальних потоків. Головні фанати і саппортери Project Reactor — Netflix взяли курс на віртуальні потоки та відмову від реактивщини, тому чекаю від вас статті про віртуальні потоки)

Дякую за коментар !
Стаття про віртуальні потоки already in progress !

Віртуальні потоки починаючи з Java 21 дадуть той самий результат що і reactive. Тільки код буде значно простіший.
Масштабування stateless Java мікросервісів дуже дешеве, тому гнатися за космічними цифрами у десятки тисяч одночасних запитів на один інстанс немає сенсу, завжди краще підняти додаткові сервери. Reactive модель програмування складніша і вимагає більше зусиль і концентрації від розробників.
Після появи virtual threads я би вже не використовував реактивність, для Java це вже минула епоха розвитку як JSP web сторінки чи JWT UI.

Дякую за коментар, так все сказане вище має сенс і доволі таки reasonable.
Мені здається, що кожен із цих інструментів/підходів має право на життя, враховуючи той момент, що Spring Web Flux дає так само багато додаткового функціоналу з коробки, який у випадку із virual threads доведеться писати руцями(це не описано у статті), наприклад: back-preassure, hot/cold стріми.
Більше схиляюсь до думки, що це проблема на рівні дизайну наскільки релевантно використовувати той чи інший підхід.

Після появи virtual threads

На WebFlux простіше писати, це власне фреймверк під низом якого якраз асихнронщіна, на базі ассінхронних можливостей контейнера сервлетів, а там вже низкорівневі файбери, зокрема. Ви пишете код стріма, по суті функціональщіну, а усе знизу за вас робить сам фреймверк. Зі звичайним MVC різниця мінімальна, а от по простоям СPU на очікування ввода-вивода, мьютекси та інші сихронізації потоків і т.д. дуже суттєвий плюс. Профайлери показують дуже велику перевагу для мікросервісів, особливо у випадках оркестрації. А це суттєво менше треба платити за клауди в періоди навантаження.
Єдиний недолік WebFlux — він з’явився пізніше за класичні : MSС, JAX-RS і т.д.

Дякую за коментар !
У випадку легкості використання, я б посперечався, оскільки це не традиційний підхід і є багато речей у які варто вдатися та зрозуміти.
Це як кому вийде, комусь вдасться це легше, а комусь важче, складність розуміння відносна штука, яка корелюється від самої особистості. Як показує практика, більшості важко вникнути.
Проте без mindshift, як згадував dou.ua/users/alxpacker в одному з коментарів нижче не обійтись.

Як показує практика, більшості важко вникнути.

Виключно через або нестаток досвіду, або через стеріотипне мислення. WebFlux не така вже нова штука, десь 5 років тому спробував, оскільки на ньому був тренінг по одній SAS eCommerce. От він показав свої реальні переваги, в тому числі на проді і для дуже відомого автоконцерну, якого не можу назвати через NDA. В той же самий час, на тих же принципах зроблений Play Framework, от де реальні проблеми — там дуже треба вміти, і явно там краще Scala, а не Java.

Стосовно досвіду тут не посперечаєшся, з власної практики помітив, що ті хто має хороший базис в GOF (особливо, якщо людина стикалась з Observable, Iterrator в не тривіальних випадках циклів або Producer-Consumer на практиці) і дуже добре розуміє абстракцію їм простіше зрозуміти те, що відбувається під капотом WebFlux і для чого він взагалі.

У простих випадках (як у прикладах) — так.
У складних (back-preassure, контрольований паралелізм) — вам доведеться писати власний фреймворк.

Reactive модель програмування складніша і вимагає більше зусиль і концентрації від розробників.

Тому що це більше ніж просто «дешеві потоки».

Віртуальні потоки цілком зможуть зменшити використання реактивності, але не зможуть цілком її замінити.

Так, цілком, проте не думаю, що написання власного фреймворку буде найкращим варіантом, можна буде використовувати WebFlux в одному проєкті паралельно із стандартним підходом. Утилізувати використання WebFlux до окремих атомарних одиниць логіки, які вимагають складніших механізмів.
Це все дуже відносно і це все залежить тільки від потреб проєкту, досвіду особи яка дизайнить та функціональних вимог

Открою тех. тайну — с реактивными моно, если лоалд балансер теряет соединения, т е клиента, то ваш пайплайн реактивный тоже прервется, если он конечно был поделён на какие-то стадии, а не один какой-то обработчик. Больше нет реквестов которые все ещё пытаются что-то сделать, а клиент уже мертв и не ожидает ответа.

Дякую за коментар і відкриту таємницю !
Уточнення, це все має сенс за умови відсутності обробки ситуації скасовування підписки

Обычно есть всякое, и это надо учитывать, и соответственно дизайнить вот эти все моно/флюкс. Короче говоря, нужен mindshift.

Всё зависит от контекста исполнения и конфигурации пайплайна. Да, при потере соединения с клиентом (например, если load balancer завершил соединение), реактивный пайплайн может завершиться, так как подписчик исчез — а без подписчика в реактивной модели нет смысла продолжать работу.

Однако это не значит, что все стадии внезапно и полностью прерываются. Если уже были инициированы асинхронные операции (например, вызовы к БД или другим сервисам), они могут дойти до конца — просто их результат больше никто не получит.

Кроме того, реактивный стек (например, WebFlux) предоставляет достаточно инструментов для управления таким поведением: doOnCancel, doFinally, timeout, takeUntilOther и др. С их помощью можно корректно завершать ресурсы, логировать отмену запроса или отменять долгие операции.

У вас же блокування було на рівні доступу до бази даних.
Якщо я правильно зрозумів із статті, воно нікуди не зникло. Просто перенесли на інший рівень абстракціі.
Чи все-таки база підтримує асинхронну обробку, або змінили драйвер на асинхронний(R2DBC) ?

Дуже гарно підмітили, так потрібно замінити драйвер, або використовувати базу яка підтримує асинхронну обробку.

Дякую за статтю! Чудовий intro до реактивності в Java. Треба і самому спробувати з цим поекспериментувати :)

CompletableFuture: спроба асинхронності
Але головний потік все одно блокується на .join().

Ви ж можете повернути CompletableFuture замість User і нічого не блокувати
Ящо у вас є CompletableFuture, то join взагалі не має бути, бо код пишеться у CPS.

Та й читабельність коду падає.

Ви ж так самі написали — використовуйте thenApply/thenApplyAsync буде не гірше WebFlux

public CompletableFuture<User> getUserInfo(@PathVariable final Long id) {
    var userFuture = CompletableFuture.supplyAsync(() -> userService.getUserInfo(id));
    return  CompletableFuture.supplyAsync(() -> userMetaDataService.getUserMetaData(id))
     .thenComposeAsync(meta -> userFuture.thenApplyAsync(u -> {
      u.setMetaData(meta);
      return u;
    ));
 }
якщо userService / userMetaDataService будуть зразу повертати CompletableFuture, то код буде значно простіше.

Так, ви повністю праві, із погляду CPS, це буде читабельніше та ми уникнемо блокування у наявному прикладі, проте уникнути блокування потоку на рівні комунікації із БД або дочірнім мікросервісом не вийде. Це буде можливо, якщо використовувати у даному підходу віртуальні потоки.
Дякую за гарний коментар !

Я мав на увазі, що у вас приклад #2 — не зовсім коректний — з CompletableFuture можна зробити так само, як з Mono (приклад #3) — без блокувань.
Вам краще в прикладі вертати колекцію / Flux — тоді це не вийде просто замінити на CompletableFuture.
Що до «блокування потоку на рівні комунікації» — це трохи інша тема (R2DBC, etc)

Зрозумів, дякую, що підмітили !

Ви ж так самі написали — використовуйте thenApply/thenApplyAsync буде не гірше WebFlux

Буде не гірше використовувати, важче оптимізувати ресурси.

Дякую за статтю, чи дивились в сторону virtual threads?

Так, дивився. Дуже гарне питання насправді !
Reactive з’явився швидше ніж віртуальні потоки, але це не є основною причиною їхньої відмінності. На перший погляд, вони виконують одну і ту ж роль. Проте, якщо заглибитись у використання, то віртуальні потоки є хорошим способом оптимізувати ресурси на комунікацію у міскросервісній архітектурі між компонентами (BE -> BE). А от якщо говорити про Reactive approach, то це класний варіант оптимізації внутрішніх ресурсів, мається на увазі їхнє використання по максимуму.

Також, хотів би додати, віртуальні потоки не гарантують відсутності блокування, оскільки результат їхнього виконання все одно буде зводитись до user-thread на рівні самого сервера. У випадку із Reactive approach він вимагає наявності сервера, який підтримує реактивний підхід, тобто із віртуальними потоками Ви можете використовувати Tomcat, а от для реактивного підходу повинен бути Netty або Jetty сервер. Плюс віртуальні потоки це один з інструментів, а реактивний підхід це вже, як принцип побудови системи.
Це якщо мене не підводить пам’ять

З віртуальними потоками можна добавити 1 анотацію до «Підхід № 1», і отримати той самий результат, що у «Підхід № 3».

Про яку анотацію іде мова ?

Якщо Ви маєте на увазі @EnableVirtualThreads анотацію, то Ви маєте рацію, проте тут є проблема можливостей масштабування. У реактивної моделі вони будуть ширшими.
Хоча запропонований підхід також є доволі таки оптимізований і не вимагає переписування додатка з нуля на Mono/Flux. Цей підхід класний для вже наявних систем, де варто провести оптимізацію ресурсів, які використовуються

Виправте помилки в заголовку статті.

Підписатись на коментарі