← назад к разделу

Сервис на Spring MVC ходит в PostgreSQL через JDBC и держит триста запросов в секунду. На ретро кто-то предлагает переписать его на WebFlux, «потому что реактивный стек масштабируется», а кто-то другой: «просто включите виртуальные потоки, это одна строка». Оба совета звучат убедительно, и оба могут стоить команде полгода. Эта статья про то, как устроены три модели обработки запроса, что на самом деле меняет каждая и по каким числам принимать решение.

Обязательно

Откуда развилка: поток на запрос

В классическом Spring MVC на каждый HTTP-запрос уходит один поток из пула Tomcat, и он занят от приёма запроса до отправки ответа. В этом промежутке поток почти всё время ждёт: базу, внешний сервис, диск. Ответ базы за сорок миллисекунд для процессора вечность, а поток всё это время держит своё место.

Место стоит дорого. Платформенный поток резервирует под стек около мегабайта памяти и живёт как объект операционной системы, который ядро переключает вместе с остальными. Поэтому пул небольшой: у Tomcat под Spring Boot по умолчанию двести потоков (server.tomcat.threads.max). Когда все двести ждут базу, новые соединения встают в очередь на приём (accept-count, по умолчанию сто), а дальше клиент получает отказ. Двести официантов стоят у стойки бариста, каждый ждёт свой кофе, а очередь у входа растёт, хотя бариста нужна секунда на чашку.

Именно эту проблему решали двумя разными способами, и из них выросла развилка.

MVC, пул на 200 запрос поток из пула ждёт базу 40 мс ответ MVC + виртуальные запрос виртуальный поток ждёт, носитель свободен ответ WebFlux запрос поток цикла (4) колбэк по готовности ответ

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

Три модели одного запроса

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

Виртуальные потоки. Java 21 добавила потоки, которые живут не в ядре, а в куче JVM: стек хранится как обычный объект и растёт по мере вызовов. Когда виртуальный поток доходит до блокирующего вызова, его стек уезжает в кучу, а поток-носитель, настоящий поток операционной системы, тут же берёт следующий виртуальный. Официанты стали бумажными: принял заказ, отдал бариста талончик и пошёл к следующему столу, а когда чашка готова, любой свободный официант её отнесёт. Носителей столько же, сколько ядер, а официантов хоть миллион.

Событийный цикл WebFlux. Потоков четыре на весь сервис (Reactor Netty берёт больше из числа ядер и четырёх), и ни один никогда не ждёт. Вместо «поток ждёт ответ базы» код описывает, что сделать, когда ответ придёт, и цикл вызывает этот кусок по готовности данных. Один официант на весь зал с блокнотом: обходит столы, у кого что готово. Быстро, пока он ни у одного стола не застрял.

Виртуальные потоки: тот же код, другая цена ожидания

Самое важное свойство виртуальных потоков в том, что код не меняется. Тот же контроллер, тот же orderRepository.findById(id), тот же JDBC. Меняется только цена ожидания внутри вызова. Spring Boot 3.2 и новее переключается одной настройкой:

spring:
  threads:
    virtual:
      enabled: true

После неё Tomcat обрабатывает каждый запрос в новом виртуальном потоке, @Async и @Scheduled тоже уходят на виртуальные. Стек-трейсы, отладка, логи с MDC работают как раньше: у каждого запроса по-прежнему свой поток, просто дешёвый.

Насколько дешёвый, видно по числам. Платформенный поток резервирует мегабайт под стек, и даже пустой стоит ядру объекта планировщика; тысяч десять таких потоков в JVM уже проблема. Виртуальный поток в состоянии ожидания занимает в куче от нескольких сотен байт до пары килобайт, ровно столько, сколько весит его реальный стек. Пятьдесят тысяч ждущих запросов умещаются в сотню мегабайт кучи.

При этом виртуальные потоки ничего не ускоряют. Запрос к базе идёт те же сорок миллисекунд, сериализация JSON занимает те же микросекунды процессора. Дешевле стало только ожидание. Если сервис считает, а не ждёт, флаг ничего не даст: ядер как было четыре, так и осталось.

WebFlux: событийный цикл и обещания

WebFlux меняет не цену ожидания, а сам способ писать код. Метод контроллера возвращает не результат, а обещание результата, который придёт потом: Mono<T> для одного значения, Flux<T> для последовательности. Вся обработка описывается цепочкой операторов над этими обещаниями, и цикл выполняет каждый шаг, когда готовы его данные.

Сравните два обработчика одного и того же запроса. Обычный MVC:

@GetMapping("/orders/{id}")
Order order(@PathVariable Long id) {
    return orders.findById(id).orElseThrow(OrderNotFound::new);
}

Тот же обработчик на WebFlux:

@GetMapping("/orders/{id}")
Mono<Order> order(@PathVariable Long id) {
    return orders.findById(id).switchIfEmpty(Mono.error(OrderNotFound::new));
}

Разница не в одной строке. Во втором варианте orders обязан быть реактивным репозиторием поверх R2DBC, а не JDBC: блокирующий драйвер под событийным циклом остановит цикл. Внешние вызовы идут через WebClient, а не через RestTemplate. Логирование с контекстом запроса, транзакции, обработка ошибок устроены иначе, потому что нет «потока запроса», к которому всё это раньше привязывалось. Стек-трейс ошибки показывает внутренности Reactor, а не ваш вызов. Всё это выучиваемо, но это отдельная дисциплина, и на неё уходят месяцы.

Главное правило событийного цикла: поток цикла не должен ждать никогда. Один .block() или вызов JDBC внутри цепочки останавливает не запрос, а поток цикла, и все соединения, закреплённые за этим потоком, стоят вместе с ним.

поток цикла №1 соединение A block() на 2 с B ждёт C ждёт D ждёт поток цикла №2 соединение E ответ соединение F ответ соединение G

Один блокирующий вызов в WebFlux: поток цикла №1 занят две секунды, и все соединения, закреплённые за ним, стоят вместе с ним. Второй поток ничего не заметил. Так деградирует четверть сервиса от одного вызова.

Если без блокирующего вызова не обойтись, его выносят на отдельный планировщик (Schedulers.boundedElastic()), но каждый такой вынос сигнал: стек не реактивный насквозь, и WebFlux здесь работает против себя.

Управление скоростью: где WebFlux действительно выигрывает

Есть один класс задач, где WebFlux даёт то, чего у MVC нет, и это не «больше запросов в секунду». Это управление скоростью потока данных, оно же backpressure.

Простейшее управление скоростью есть и у MVC. Клиент читает медленно, окно TCP закрывается, write() в ответ блокируется, виртуальный поток дёшево ждёт: отдача сама подстраивается под клиента. Отдавать ответ по частям MVC тоже умеет: StreamingResponseBody для больших тел, SseEmitter для событий, и на виртуальных потоках тысячи таких открытых ответов ничего не стоят.

Разница в том, что в блокирующей модели сигнал «помедленнее» доходит только до того, кто пишет в сокет. Стадия, которая читает из базы и складывает в буфер перед отправкой, о медленном клиенте не знает и наполняет буфер, пока хватает памяти. У WebFlux сигнал сквозной: потребитель запрашивает request(n), и это число уходит вверх по всей цепочке до драйвера базы, который читает ровно столько строк. Для каждого буфера при этом явно объявлено, что делать при переполнении: ждать, выбросить старое, оставить последнее или упасть.

Потребитель Источник request(32): дай ещё 32 32 элемента, не больше

Сквозное управление скоростью: потребитель сам говорит, сколько готов принять, и эта цифра доходит до драйвера базы. Медленный клиент замедляет чтение из источника, а не наполняет буфер посередине.

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

Порог выбора в числах

Решение перестаёт быть спором, если посчитать.

MVC на виртуальных потоках на типовом поде в два процессора и два гигабайта памяти держит десятки тысяч одновременных ожидающих запросов: потолок ставят max-connections (8 192 по умолчанию, поднимается), лимит файловых дескрипторов и память на буферы ответов, а не потоки. Пропускная способность при этом упирается в базу задолго до этих чисел: триста запросов в секунду с сорока миллисекундами на запрос это двенадцать одновременно занятых соединений, и никакая модель потоков не сделает базу быстрее.

WebFlux оправдан, когда выполняется хотя бы одно из трёх. Сервис держит больше сотни тысяч долгоживущих соединений на экземпляр, и память на них важнее простоты кода. Сервис гонит потоки данных, где медленный потребитель должен притормаживать источник, а не буфер. Или стек уже реактивный насквозь, R2DBC и WebClient везде, и команда пишет так не первый год.

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

Дополнительно: при первом чтении можно пропустить

Глубже: что ломается после флагарасширенное

Флаг одной строкой, но у него есть последствия, о которых редко пишут. Три из них стоит знать до включения.

Исчезает ограничитель нагрузки. Пул на двести потоков был не только узким местом, но и предохранителем: лишние запросы честно стояли в очереди на входе. После флага очереди нет, Tomcat принимает всё до server.tomcat.max-connections (по умолчанию 8 192), и тысячи запросов проходят внутрь одновременно. Внутри их ждёт пул соединений HikariCP на десять соединений. Раньше за десять соединений конкурировали двести потоков, теперь восемь тысяч: каждый ждёт connection-timeout (тридцать секунд) и получает исключение. Вместо честной очереди сервис отвечает лавиной таймаутов, а база при этом не перегружена вовсе.

8 000 запросов в пике ограничителя на 200 больше нет 8 000 виртуальных потоков каждый хочет соединение пул на 10 соединений connection-timeout 7 990 ждут до 30 с вместо очереди на входе лавина таймаутов

Что происходит после флага без ограничителя: раньше лишние запросы стояли в очереди Tomcat, теперь все проходят внутрь и встают у пула соединений, а через тридцать секунд получают ошибку.

Лечится ограничением конкурентности там, где раньше её ограничивал пул: семафор на число одновременно обрабатываемых запросов в фильтре, переборка (bulkhead) из Resilience4j вокруг обращений к базе или просто пул соединений, размер которого рассчитан вместе с connection-timeout. Виртуальные потоки убирают потолок по потокам, но потолок по соединениям, по памяти на ответ и по способности базы никуда не делся, и его теперь надо задавать явно.

Кэш на поток превращается в утечку. ThreadLocal на виртуальных потоках работает, и в этом ловушка. Классический приём «дорогой объект на поток»: форматтер дат, буфер на мегабайт, клиент со своим состоянием. На пуле из двухсот потоков это двести объектов, которые живут вечно и переиспользуются. На виртуальных потоков не двести, а столько, сколько запросов: объект создаётся на каждый и не переиспользуется никем. Буфер в мегабайт на поток при десяти тысячах одновременных запросов это десять гигабайт. Такие кэши переводят на явные пулы объектов, а контекст запроса, который просто нужно пронести вглубь, с Java 25 удобнее держать в ScopedValue.

Прибитый носитель. До Java 24 виртуальный поток, вошедший в synchronized и заблокировавшийся внутри, не отпускал носитель: носитель стоял вместе с ним, а следующие виртуальные потоки ждали свободного. Старые драйверы и библиотеки с synchronized вокруг ввода-вывода превращали флаг в замедление. С Java 24 (JEP 491) JVM отпускает носитель и внутри synchronized, на актуальной LTS Java 25 прибивают только нативные вызовы и инициализаторы классов. Флаг -Djdk.tracePinnedThreads из старых статей в Java 24 удалён; смотреть надо событие Flight Recorder jdk.VirtualThreadPinned. Как устроен носитель и его подмена, разобрано со схемой в статье про виртуальные потоки.

Глубже: если WebFlux уже естьрасширенное

Развилка не всегда стоит перед новым сервисом. Чаще WebFlux уже написан три года назад, и в нём постепенно завелись .block(), а то и JDBC на boundedElastic(). Это тот самый признак, по которому решают: если в реактивном сервисе есть блокирующие вызовы, которые никто не собирается убирать, стек не реактивный, и его дешевле привести к MVC на виртуальных потоках, чем чинить.

Переезд проще, чем кажется, потому что на виртуальных потоках блокирование дёшево. Обработчик на Mono можно временно завершить .block() внутри обычного контроллера MVC, и это уже не преступление, а нормальный вызов, который ждёт в дешёвом потоке. Дальше цепочки распрямляют по одной: WebClient заменяют на RestClient, R2DBC на JDBC, операторы на обычный код. Сервис при этом всю дорогу остаётся рабочим.

Чего делать не стоит, так это оставлять смешение: часть контроллеров на Mono, часть обычных, в одном приложении. Две модели ошибок, две модели транзакций, два способа пронести контекст запроса, и команда держит обе в голове. Одна модель на сервис, а если моделей объективно две, это два сервиса.

Коротко

  • Пул на 200 платформенных потоков ограничивает число одновременных ожиданий; виртуальные потоки убирают этот потолок, не меняя код, но ничего не ускоряют.
  • Платформенный поток резервирует мегабайт стека, виртуальный в ожидании весит сотни байт: десятки тысяч ждущих запросов умещаются в сотню мегабайт кучи.
  • После флага пропадает предохранитель: конкурентность надо ограничивать явно, иначе тысячи запросов ждут десять соединений и падают по таймауту.
  • Кэши на ThreadLocal на виртуальных потоках множатся на число запросов; с Java 24 synchronized носитель не прибивает.
  • WebFlux требует неблокирующего стека насквозь; один .block() останавливает поток цикла и все его соединения.
  • Настоящее преимущество WebFlux одно: сквозное управление скоростью для очень многих долгих соединений.
  • Порог: сотни тысяч соединений на экземпляр или потоки данных с медленными потребителями; иначе MVC на виртуальных потоках.
  • Реактивный сервис с .block() внутри дешевле перевести на MVC, чем чинить; смешивать две модели в одном сервисе нельзя.

Что почитать дальше