В нашем проекте на Quarkus мы столкнулись с необходимостью более эффективного использования ресурсов под высокой нагрузкой. Решили мигрировать с классического Hibernate ORM на Hibernate Reactive (HR). В этой статье я поделюсь опытом этого перехода: разберу ключевые архитектурные различия, расскажу о неочевидных «граблях», на которые мы наступили, и покажу на production-коде, какую цену пришлось заплатить за реактивность.

Версии используемого ПО: 

  • Quarkus: 3.31.3

  • Quarkus Hibernate Reactive: 3.31.3

  • Vertx-pg-client (реактивный клиент PostgreSQL): 4.5.24

Все описанные ниже проблемы и особенности актуальны именно для этих версий.

Ключевые отличия

Hibernate ORM построен на синхронном JDBC, в то время как Hibernate Reactive базируется на асинхронных драйверах и интеграции с библиотекой реактивных потоков Mutiny. Это влечёт за собой кардинальные изменения в коде:

  • Реактивные типы. Привычные List<T> и Optional<T> в репозиториях заменяются на Uni<T> (один результат) и Multi<T> (поток результатов).

  • Смена парадигмы. Императивный стиль (result = service.getData(); process(result)) уступает место функциональному с цепочками вызовов (service.getData().map(this::process)).

  • Явное управление контекстом. Работа с сессиями и транзакциями становится более осознанной и требует явного контроля.

Практические проблемы миграции

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

Переход на Uni/Multi: волна изменений

Самое масштабное изменение — замена типов возвращаемых значений. Оно прокатилось волной от репозиториев через сервисы до REST-контроллеров. Привычные коллекции превратились в Uni<List<T>>, и это навсегда изменило то, как мы пишем бизнес-логику. Хоть этот пункт и самый маленький по описанию, он стал самым большим по объёму отличающегося кода. Это не баг, а новая реальность, к которой нужно быть готовым.

Data Access Repositories: косметические правки

Здесь всё достаточно гладко. Вместо привычного Panache.getEntityManager() мы используем Panache.getSession(). В остальном код построения критериев остаётся узнаваемым:

public Uni<List<ColumnInfo>> findAll(Specification<ColumnInfo> specification) {
    return Panache.getSession()
                  .chain(session -> {
                      CriteriaBuilder cb = session.getCriteriaBuilder();
                      CriteriaQuery<ColumnInfo> cq = cb.createQuery(ColumnInfo.class);
                      Root<ColumnInfo> root = cq.from(ColumnInfo.class);
                      // ... построение предиката из specification ...
                      return session.createQuery(cq).getResultList();
                  });
}

Кеширование: разбираем по частям

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

  • @CacheResult (Quarkus Cache). Аннотация из экосистемы Quarkus работает с реактивными типами, но важно помнить: кешируется сам Uni, а не результат его выполнения. Если Uni завершится ошибкой, то в кеш попадет именно ошибка. Это особенность реализации, которую нужно учитывать.

    @CacheResult(cacheName = "columns-cache")
    public Uni<List<ColumnInfo>> findByTableIdIn(Collection<UUID> ids) {
        return find("From ColumnInfo c WHERE c.tableId in ?1", ids).list();
    }
  • Second-Level Cache (2LC). А вот с кешем второго уровня Hibernate всё оказалось печальнее. На момент нашей миграции (и, судя по обсуждениям в issue-трекерах, отчасти до сих пор) его работа в связке с Hibernate Reactive была нестабильна. Мы столкнулись с проблемой, когда результат запроса есть в кеше, но сами сущности не находятся в 2LC. В такой ситуации Hibernate Reactive не проверяет кеш запросов, а просто игнорирует его и выполняет запрос заново. Разработчики самого Hibernate в обсуждениях признавали, что «ситуация сложная».

  • Внешние кеши (Redis). Более того, Hibernate Reactive поддерживает не все внешние серверы кеширования. В нашем окружении доступен Redis, но мы не нашли стабильного, рабочего и поддерживаемого способа «подружить» его с HR в качестве хранилища для 2LC. В итоге нам пришлось вручную управлять кешированием на уровне бизнес-логики, полностью отказавшись от декларативного подхода Hibernate для этих сценариев.

Транзакции

Можно использовать аннотацию @Transactional из пакета Jakarta. В целом, работает, но с важными оговорками, которые стоит знать. Quarkus и Hibernate Reactive имеют ограничения на интеграцию с блокирующим API Jakarta Transactions.

Для большинства стандартных сценариев записи и чтения в рамках одной сессии всё работает как ожидается. Рекомендуемый способ — использовать sf.withTransaction() или @WithTransaction.

Проблемы начались в сценариях, выходящих за рамки «happy path». Например, при использовании StatelessSession для высокопроизводительных операций поведение транзакций стало отличаться от ожидаемого, причём фреймворк не всегда явно об этом предупреждал. Также стоит быть аккуратнее с границами транзакций при работе с несколькими ресурсами (БД + внешний реактивный клиент). В некоторых случаях транзакция может быть закоммичена раньше, чем завершится асинхронная операция, если не управлять этим явно.

Lazy Loading: прощай, магия

Это, пожалуй, самое болезненное различие. В классическом Hibernate мы привыкли к прозрачной подгрузке: обратился к дочерней коллекции, и фреймворк сам сходил в БД. В Hibernate Reactive такой прозрачной ленивой загрузки нет — связи нужно грузить явно.

Почему? Любое обращение к базе данных должно возвращать Uni<T> или Multi<T>. Если бы разработчики захотели сохранить прозрачность, то им пришлось бы изменить сигнатуру методов в сущностях, превратив их из простых POJO в источники реактивных цепочек, что сломало бы всю совместимость. Поэтому они пошли другим путём, сделав ленивую загрузку явной операцией через Mutiny.fetch().

Эмуляция ленивой загрузки (и как не надо делать)

Наивный подход «давайте просто вызовем fetch() везде» приводит к N+1 проблеме в реактивном обличье.

// Находим всех авторов
Uni<List<Author>> authorsUni = Author.findAll().list();

// Пытаемся подгрузить книги для каждого автора
authorsUni = authorsUni.chain(authors -> {
    List<Uni<Author>> authorsWithBooks = authors.stream()
        .map(author -> Mutiny.fetch(author.getBooks())  // N дополнительных запросов!
                             .map(books -> author))
        .collect(Collectors.toList());

    return Uni.combine().all().unis(authorsWithBooks).combinedWith(list -> list);
});

Поздравляю, мы только что воспроизвели классическую проблему: один запрос на получение списка авторов, затем N запросов на получение книг. Запросы выполняются асинхронно, но проблема избыточных обращений к базе никуда не делась.

Правильные подходы к загрузке связанных данных

Реактивный мир не прощает наивных решений и заставляет проектировать запросы осознанно.

  1. JOIN FETCH в HQL (рекомендуемый подход). Вы сразу говорите Hibernate, какие данные понадобятся. Это полностью решает проблему N+1, так как все данные загружаются одним запросом.

    public Uni<List<Author>> findAllAuthorsWithBooks() {
    	return find("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books").list();
    }
  2. Явная загрузка в контролируемых сценариях. Если вам действительно нужно загрузить данные после получения родительской сущности (например, в разных сервисах), то используйте fetch(), но будьте готовы к тому, что это отдельный запрос. Иногда это оправдано, например, при условной загрузке.

Сложный случай: Criteria API + JOIN FETCH

Самая интересная задача возникает, когда нам нужно сохранить гибкость динамических запросов через Specification, но при этом гарантировать загрузку всех необходимых связей. В Hibernate Reactive это решается через root.fetch():

public Uni<List<ColumnInfo>> findAllWithTable(Specification<ColumnInfo> specification) {
    return Panache.getSession()
                  .chain(session -> {
                      CriteriaBuilder cb = session.getCriteriaBuilder();
                      CriteriaQuery<ColumnInfo> cq = cb.createQuery(ColumnInfo.class);
                      Root<ColumnInfo> root = cq.from(ColumnInfo.class);

                      // Явно указываем FETCH для связи с таблицей
                      root.fetch("table", JoinType.LEFT);

                      if (specification != null) {
                          Predicate predicate = specification.toPredicate(root, cq, cb);
                          if (predicate != null) {
                              cq.where(predicate);
                          }
                      }

                      return session.createQuery(cq).getResultList();
                  });
}

Важный нюанс: в предикатах Specification нужно ссылаться на поля связанных сущностей через корень (root.get("table").get("name")), а не через объект fetch. Это гарантирует корректную работу запроса. Подход компромиссный, позволяющий сохранить динамику, избежать N+1 и получить на выходе полностью инициализированные сущности.

«Острые углы» и открытые баги

Проблемы, с которыми мы столкнулись на нашем стеке:

  • Аннотация @CurrentTimestamp не работает с FORCE_INCREMENT. Как следствие, при использовании @Version вместе с @CurrentTimestamp для оптимистичных блокировок мы получаем UnsupportedOperationException. Решение: использовать @GenerationTime или управлять версией вручную.

  • UnexpectedAccessToTheDatabase при запросе сущности с IdClass. Если первичный ключ составной и содержит ссылку на другую сущность (@ManyToOne), то при попытке загрузить сущность по такому ключу может возникнуть это исключение. Решение: загружать сначала связанную сущность, а затем основную по её полям.

  • Приложение зависает при установке max-size=1. Если установить максимальный размер реактивного пула соединений в quarkus.datasource.reactive.max-size=1 и одновременно использовать генерацию схемы (hbm2ddl), то приложение зависает при старте. Решение: увеличить размер пула >1 или отключить автогенерацию схемы на dev-стендах с таким пулом. Это известная проблема, связанная с особенностями работы генерации идентификаторов в старых версиях .

Числа

Мы начали эту историю с тезиса об эффективности использования ресурсов. Было бы нечестно не подкрепить его числами. К счастью, команда Hibernate Reactive провела серьёзное исследование на эту тему.

Они использовали тесты из популярного набора Techempower, сравнивая два идентичных приложения на Quarkus: одно на классическом Hibernate ORM, другое — на Hibernate Reactive. Нас интересовала не максимальная пропускная способность (хотя и она важна), а поведение под нагрузкой: как растёт задержка (latency) при увеличении количества запросов.

Сценарий «Множественные запросы» 

В этом тесте каждый HTTP-запрос требовал последовательной загрузки 20 случайных сущностей из БД (имитация неоптимального, но реалистичного сценария с цепочками зависимостей). Результаты получились показательными:

  • Классический Hibernate ORM уверенно держался до нагрузки ~20 000 запросов/сек, укладываясь в задержку 10 мс.

  • Hibernate Reactive на том же железе продолжал укладываться в 10 мс даже при нагрузке ~35 000 запросов/сек. Разница в 75% прироста эффективной мощности!

Стек технологий

Нагрузка (запросов/сек)

Задержка (latency)

Hibernate ORM (синхронный)

≤ 20 000

≤ 10 мс

Hibernate ORM (синхронный)

> 20 000

> 10 мс (резкий рост)

Hibernate Reactive

≤ 35 000

≤ 10 мс

Сценарий «Одиночный запрос»

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

Сценарий «Пропускная способность»

Графики пропускной способности (количество обработанных запросов в секунду) тоже впечатляют. Hibernate Reactive показывает не просто линейный рост, а гораздо более эффективное использование ресурсов по мере увеличения количества параллельных клиентов.

Эти числа хорошо коррелируют с нашими собственными наблюдениями. Мы не проводили таких чистых A/Б-тестов на проде, но заметили, что после миграции наша система стала заметно «плотнее» загружать ядра процессора и перестала «проседать» под внезапными пиками трафика.

Реальный проект под микроскопом: до и после

Давайте разберём метод из нашего сервиса, чтобы увидеть, какие архитектурные решения и «костыли» пришлось внедрять. Самое интересное здесь — это сравнение «до» и «после».

Глядя на production-код, видна реальная цена «реактивности»:

  • Рост сложности и многословности. 10 строк императивного кода превратились в 15 строк реактивных цепочек (а в реальных примерах с обработкой ошибок и группировкой — и вовсе в 50+).

  • Потеря читаемости. Вложенные chain и flatMap делают поток данных менее очевидным.

  • Сложность отладки. Stack trace уходит в дебри Mutiny, и найти истинную причину ошибки — тот ещё квест.

  • Компромиссы. Приходится выбирать между N+1 (если делать всё в лоб) и сложными, трудночитаемыми, но оптимальными запросами.

Итоги

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

Когда стоит использовать Hibernate Reactive:

  • Вы начинаете новый проект и изначально строите его как полностью реактивный (от контроллера до БД). Обратите внимание: в Quarkus 3.31.3 Hibernate Reactive всё ещё имеет статус preview, но уже вполне работоспособен.

  • У вас есть требования к экстремально высокой нагрузке (тысячи RPS) и вы готовы пожертвовать простотой кода ради эффективности использования ресурсов. Результаты показывают, что игра действительно стоит свеч именно в этом сценарии.

  • Ваша команда уже имеет опыт работы с реактивным программированием и готова к сдвигу парадигмы.

Когда не стоит использовать Hibernate Reactive (или нужно быть очень осторожным):

  • У вас сейчас монолитное приложение со сложной логикой работы с данными. Миграция будет долгой, болезненной и полной компромиссов.

  • Производительность вашего текущего приложения вас устраивает. Как мы видели на тесте с одним запросом, реактивность не даст магического ускорения.

  • У вашей команды недостаточно опыта в реактивном программировании. Кривая обучения здесь очень высока.

Для какого типа проектов миграция реально имеет смысл? Для проектов с ярко выраженными операциями ввода-вывода, где много параллельных запросов, но каждый из них не требует огромных вычислительных мощностей. Например, API-шлюзы, middleware, высоконагруженные CRUD-приложения. Внутренние корпоративные порталы или админки с 50 пользователями — не их целевая аудитория.

Наш вердикт: Hibernate Reactive — отличный, но очень специфичный инструмент. Он блестяще решает задачи масштабирования, которые большинство разработчиков даже не видят, и создаёт проблемы, о которых многие даже не подозревали. Подходите к его выбору осознанно.