Я думаю многие согласятся, что Spring Data JDBC — это ORM, который занимает конкретную нишу: он предоставляет более легковесный репозиторный слой доступа к данным поверх реляционной БД без persistence context, без lazy loading, без dirty checking и так далее

Иными словами, Spring Data JDBC старается реализовать принцип «what you see is what you get» — каждое обращение к репозиторию означает конкретный SQL‑запрос в БД, который просто достаёт дерево Aggregate. Это и преимущество, и, тем не менее, иногда это источник потенциальных проблем с производительностью.

В этой статье я разберу ключевые подходы к оптимизации запросов в Spring Data JDBC: от дизайна агрегатов и Single Query Loading, до Stream в качестве возвращаемого значения и @Modifying запросов. Разберём всё с кодом и на примерах.

Только один момент — в этой статье я не затрагиваю Spring Data открытые/закрытые Projection‑ы и т.п, так как я предполагаю, что пользователи Spring Data знают, что это и в каких ситуациях их стоит использовать. Эти вещи не специфичны для Spring Data JDBC, я же буду говорить про вещи более специфичные именно для Spring Data JDBC.

Aggregate. Теория в Небе. Практика на Земле.

Давайте буквально на всякий случай пару слов скажем про базу — про Aggregate. Spring Data JDBC построен вокруг концепции Aggregate из Domain‑Driven Design. Это не просто теоретическая абстракция, это то, что напрямую влияет на то, какие SQL‑запросы будут сгенерированы.

Агрегат в Spring Data JDBC — это дерево, это DAG сущностей с одним корнем (Aggregate Root). Репозиторий работает с агрегатом как с единым целым: загружает целиком, сохраняет целиком. Собственно говоря, поэтому в blue sky сценарии в DDD и в Spring Data JDBC в том числе подразумевается, что Вы создаёте репозиторий только под Aggregate Root.

На практике можно так не делать, и я в своей работе видел, когда команды отходят от этого правила. Так вот, если командам приходится нарушать это правило, то по сути в кодовой базе начинает иметь место обновления поддерева aggregate без участия root. И само это действие неявно показывает, что дизайн aggregates просто неверный.

И вот тут вопрос не с теоретической точки зрения, а с практической. Плох ли сам по себе тот факт, что мы имеем не совсем «правильно» задизайненные аггрегаты? Я отвечу так — в этом ничего хорошего нет, но воспринимайте это как техдолг.

Один некорректно задизайненный аггрегат ничего смертельного с Вашей системой не сделает. Вопрос в том, что тут в дело часто на практике вступает теория разбитого окна (Настоятельно рекомендую с ней ознакомиться. Во многом узнаете свои проекты). Так что подходите с умом.

Итак, продолжим.

Внутри агрегата допускаются one‑to‑one и one‑to‑many связи. Ссылки между разными агрегатами осуществляются как правило через AggregateReference — по сути, просто номинальный тип для хранения foreign key без загрузки связанной сущности. Хотя в примере ниже mostPopularProduct может легко быть Long.

Вот пример:

class Order {
    @Id
    Long id;
    String customerName;
    LocalDateTime createdAt;

    @MappedCollection(idColumn = "order_id")
    List<OrderItem> items;

    AggregateReference<Product, Long> mostPopularProduct;
}

class OrderItem {
    String productName;
    int quantity;
    BigDecimal price;
}

Здесь Order это Aggregate Root, а OrderItem это вложенная сущность внутри агрегата, и mostPopularProduct - это уже ссылка на другой агрегат, а именно на Product по его ID. Соответственно, Spring Data JDBC не загрузит Product при чтении Order и это правильно. То есть, если вам нужен Product, вы идёте в ProductRepository и загружаете его отдельно.

Визуальное представление Aggregate

Иногда сложный Aggregate бывает очень полезно визуализировать. Я сам, если мне нужно понять структуру Aggregate, пользуюсь Amplicode Explorer. По-моему Intelij Idea Enterprise тоже умеет, но я уже как-то привык к Open IDE во многом (это не реклама, я ни от Amplicode ни от Jet Brains денег не получаю):

Штука удобная, спору нет. Единственное, что было бы круто (ну мне по крайней мере), так это увидеть прямо DAG. Не знаю, насколько это возможно, но надеюсь, парни из Amplicode услышат. Но это off topic.

И вот тут, друзья, ключевой момент right out of the gates: очевидно, что размер агрегата напрямую определяет стоимость его загрузки. Если ваш агрегат это Order с 500 OrderItem, то каждый раз при findById Spring Data JDBC будет загружать все 500 элементов. Persistence context нет, lazy loading нет — всё грузится сразу и честно. Это не баг, а фича.

Поэтому, первый и, пожалуй, самый важный совет по оптимизации: старайтесь проектировать маленькие агрегаты. Это причем даже не только ради оптимизации, а в том числе, чтобы сократить потенциальный размер техдолга, о котором мы говорили Выше (я об этом правиле буду говорить в подробностях говорить на Spring I/O в Барселоне. Есть золотое правило дизайна API от Джошуа Блоха:

“When in doubt, leave it out. You can always add, but you can never remove”

Этому правилу уже десятки лет, и оно в разных ипостасях применяется в разных местах в Software Engineering. И тут оно, в том числе, на мой взгляд, как нельзя кстати. В общем, Если у вас BlogPost с тысячей Comment, вероятно, имеет смысл вынести Comment как отдельный агрегат со ссылкой на BlogPost через AggregateReference.

Я Вам даже больше того скажу, в «Implementing Domain Driven Design», которую в простонародье часто называют «The Red Book» или как Оливер Дротбом любил в своё время говорить в «Новом Завете» Вон Вернон сфорумлировал правило маленьких аггрегатов. Оно вот как раз говорит о проблемах больших Aggregates и причинах их такими не делать.

Значит ли это, что будет достаточно aggregates, состоящих только из одной сущности — да, такое вполне себе может быть. И это не так плохо.

N+1. Неизбежное Зло, или Нет?

Обратите внимание на то, как Spring Data JDBC загружает агрегаты. Допустим, у нас Minion с коллекцией Toy:

class Minion {
    @Id
    Long id;
    String name;

    @MappedCollection(idColumn = "minion_id")
    List<Toy> toys;
}

class Toy {
    String name;
}

Когда мы вызываем minionRepository.findAllById(...), Spring Data JDBC выполнит:

  1. SELECT * FROM minion WHERE id IN (...) — загружаем корни

  2. Для каждого миньона — SELECT * FROM toy WHERE minion_id = ?

Это и есть классическая N+1 проблема. И она присуща не только Spring Data JDBC, конечно, но в контексте Spring Data JDBC она особенно заметна, потому что нет никакого батчинга "из коробки", как в Hibernate.

В Hibernate считается, что в тех местах, где N + 1 неизбежен, уж лучше пойти либо через dual-SELECT, либо можно пойти через @BatchSize. Spring Data JDBC не поддерживает подобных вещей, в том числе осознанно, т.к например для реализации Batch Size нужен PersistenceContext, а его у нас нет и т.д.

Опять же, можно разнести на разные Aggregates, но допустим, что мы так делать не хотим, по каким-либо причинам. В общем, давайте разберёмся, что с этим в итоге делать.

Single Query Loading. Круче, чем Вы Думаете

Начиная с Spring Data JDBC 3.2 (это Spring Boot 3.2+), появилась фича под названием Single Query Loading. Она на самом деле гораздо круче, чем Вы думаете. Ниже мы в этом убедимся.

Задумка за этой фичей была такая: давайте попробуем загрузить весь агрегат одним SQL-запросом. Один SELECT — и у вас в руках полный агрегат с коллекциями.

Для включения Single Query Loading нужно настроить JdbcMappingContext:

@Configuration
class DataJdbcConfig extends AbstractJdbcConfiguration {

    @Override
    public JdbcMappingContext jdbcMappingContext(
            Optional<NamingStrategy> namingStrategy,
            JdbcCustomConversions customConversions,
            RelationalManagedTypes jdbcManagedTypes
    ) {
        JdbcMappingContext context = super.jdbcMappingContext(
            namingStrategy, customConversions, jdbcManagedTypes
        );
        context.setSingleQueryLoadingEnabled(true);
        return context;
    }
}

Заглядываем под капот.

Тут, друзья, начинается самое интересное. Казалось бы, все понятно, JOIN и поехали. Но не так просто. Если у агрегата несколько коллекций, скажем, List<Toy> и List<Hobby>, то обычный JOIN даёт декартово произведение. Например, 10 toys и 10 hobbies у одного миньона — и мы получаем 100 строк вместо 10. Вам может и ничего, но это довольно быстро взорвёт ResultSet и соответственно ваш Java Heap. В Hibernate это вызовет MultipleBagFetchException, и там уже есть свои способы с этим работать.

Соответственно, Разработчики Spring Data (и в частности, Jens Schauder, лид Spring Data JDBC) решили эту проблему с помощью window functions. Идея в том, чтобы:

  1. Для каждой дочерней таблицы создать подзапрос с row_number() — c уникальным номером строки в рамках группы по foreign key.

  2. Соединить всё это через LEFT JOIN.

  3. Добавить WHERE clause, который устраняет дубликаты, используя row numbers в рамках partition-а как своего рода "виртуальный ключ" для pseudo full outer join.

Результат выглядит ResultSet будет примерно так:

minion_id

name

toy_name

toy_rn

hobby_name

hobby_rn

1

Bob

Teddy

1

Hold Teddy

1

1

Blue Light

2

Look Cute

2

1

Follow Kevin

3

2

Kevin

...

...

...

...

P.S: За полным примером и пояснением, как так вообще получается, рекомендую прочитать статью Jens-а, он там всё довольно подробно описывает. Доменная модель с Toy и Hobby как раз взята оттуда.

Обращаю Ваше внимание - самое важное в ResultSet-е выше это то, что для каждой корневой сущности, если мы имеем условно 2 коллекции внутри агрегата, размерами M и N, то в традиционном топорном подходе размер ResultSet для чтения одного aggregate это M * N, но в случае Spring Data JDBC это Max(M, N). Это очень и очень существенная разница. Невероятно элегантное решение, которому, как говориться, аналогов нет :)

Ограничения Single Query Loading

Однако, и тут есть нюанс. На данный момент Single Query Loading имеет ряд ограничений:

  • Глубина вложенности ограничена — Это певрая и самая важная проблема. На данный момент не поддерживаются агрегаты с глубоко вложенными сущностями (вложенности больше одного уровня). На самом деле она логично вытекает из текущей реализации.

    P.S: В теории (!), с помощью Recursive CTE и это можно решить, но там есть ряд нюансов. Поэтому, когда появится Single Query Loading для глубокого уровня nesting-а - не ясно.

  • Поддерживается ограниченный набор методов — изначально это были только findAll(), findById()findAllById(). Со временем покрытие расширяется, но пользовательские @Query методы не используют Single Query Loading. Это исключительно инженерная проблема, тут только ждать новых релизов.

  • Требуются window functions — они поддерживаются во всех основных RDBMS (PostgreSQL, MySQL, Oracle, SQL Server). Формально H2 и HSQLDB тоже поддерживают window functions (H2 — начиная с версии 1.4.198), однако Single Query Loading на данный момент не работает с H2 и HSQLDB — специфический SQL, генерируемый Spring Data JDBC для этой фичи, не полностью совместим с этими БД. Имейте это в виду, если вы используете, например, H2 для тестов (а таких много на самом деле, т.к. инфобеза имеет свойство хвататься за сердце когда слышит про тестконтейнеры).

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

Streaming. Для больших объёмов данных

Если вам нужно обработать большой объём данных, а загружать всё в память нет желания, Spring Data JDBC поддерживает Stream как возвращаемый тип:

interface OrderRepository extends CrudRepository<Order, Long> {

    @Query("SELECT * FROM orders WHERE created_at > :since")
    Stream<Order> streamOrdersSince(@Param("since") LocalDateTime since);
}

Stream позволяет обрабатывать результаты по мере их поступления из БД, не загружая весь ResultSet в память. Будет прямо самый настоящий Stream-processing. Сам SQL в логах при этом не поменяется, по этому поводу не пугайтесь, потому что запрос дейстивтелньо будет один, а всю магию сделает прокси над ResultSet и ResultSetSpliterator (то есть никаких открытых курсоров и т.д).

Но есть же Pageable? Да, есть, но вот он не работает с Native Queries, т.е. с любыми зарпосами, над которыми стоит @Query. Это долгая история, сейчас я в неё уходить не буду.

Также, обратите внимание: Stream держит открытое соединение с БД. Поэтому вы обязаны закрыть Stream:

@Transactional(readOnly = true)
public void processOldOrders(LocalDateTime since) {
    try (Stream<Order> orders = orderRepository.streamOrdersSince(since)) {
        orders
            .filter(o -> o.items.size() > 10)
            .forEach(this::processLargeOrder);
    }
}

Используйте try-with-resources и не забывайте про @Transactional — без активной транзакции соединение может быть закрыто до того, как вы обработаете все элементы.

Modifying Queries. UPDATE и DELETE

Отдельная история — модифицирующие запросы. Когда вам нужно обновить одно поле у сотни записей, сохранять каждый агрегат через save() — не вариант. Напомню: save() в Spring Data JDBC удаляет все дочерние сущности и вставляет их заново. Для массовых обновлений это катастрофа.

Есть планы это поменять, хотя некоторые core-maintainer-ы считают, что это дизайн, вытекающий из DDD-модели агрегатов, где агрегат сохраняется атомарно, целиком, и править это не совсем корректно. В целом в этом есть разумное зерно, но оно будет очень быстро разбито о реальность.

В общем, вместо save() используйте @Modifying запросы вместе с @Query. Да, это не по-христьянски по DDD, но мы же хотим, чтобы работало быстро:

interface OrderRepository extends CrudRepository<Order, Long> {

    @Modifying
    @Query("UPDATE orders SET customer_name = :name WHERE id = :id")
    boolean updateCustomerName(@Param("id") Long id, @Param("name") String name);

    @Modifying
    @Query("DELETE FROM orders WHERE created_at < :before")
    int deleteOldOrders(@Param("before") LocalDateTime before);
}

Единственный момент - @Modifying запросы выполняются напрямую в БД, минуя event-ы, callback-и и аудит. Например, Если у вас поля с @CreatedDate / @LastModifiedDate — они не обновятся автоматически. Обновляйте их явно в SQL:

@Modifying
@Query("UPDATE orders SET customer_name = :name, updated_at = NOW() WHERE id = :id")
boolean updateCustomerName(@Param("id") Long id, @Param("name") String name);

Практические рекомендации. Итого

Давайте подведём итог. Вот конкретные рекомендации по оптимизации запросов в Spring Data JDBC, ранжированные по приоритету:

  1. Правильно проектируйте агрегаты

    Это самое важное. Маленькие агрегаты = быстрые запросы. Если у вас коллекция может расти неограниченно — просто вынесите её за агрегат. Ну и используйте AggregateReference для связей между агрегатами.

  2. Включите Single Query Loading

    Если ваша БД поддерживает window functions (PostgreSQL, MySQL 8+, Oracle, SQL Server — все поддерживают), включайте setSingleQueryLoadingEnabled(true). Это устраняет N+1 для стандартных операций findById / findAll без каких-либо изменений в вашем коде.

  3. @Modifying для массовых операций

    Да, так не очень правильно, но часто лучше воздержаться от save() для массовых обновлений. Это довольно редкий кейс, когда save() оправдан, т.к. save() приведёт к удалению и повторной вставке всех дочерних сущностей для каждого агрегата. Чаще всего @Modifying + @Query — правильный путь.

  4. Streaming для больших объёмов

    Если обрабатываете тысячи записей — подумайте над использованием Stream-а как над типом возвращаемого значения, чтобы не загружать всё в память единомоментно. Иногда и старая добрая Pageable поможет, но не в случае @Query.

    Не забудьте закрыть Stream и обеспечить открытую транзакцию, так как всё время, пока вы процессите Stream, Вы держите соединение открытым.

Заключительное слово

Вообще, оптимизация запросов в Spring Data JDBC начинается не с SQL-запросов, а с дизайна агрегатов. Дизайн агрегатов решает 80% проблем с производительностью. Оставшиеся 20% закрываются проекциями, @QueryResultSetExtractor, Single Query Loading и так далее. На практике, "правильный" дизайн это что-то в вакууме, и иногда можно им жертвовать в угоду производительности. Это не преступление, иногда так делать нужно.

Ну и послдеднее. Недавно мы в рамках Spring АйО решили начать свою Академию, подобной той, что делает Sergi Almar и команда, помогая VMWare Tanzu обучать программистов работе со Spring Framework и всему, что около него. Вообще о том, что вдохновило нас на подобное я хотел сделать отдельный пост, но там посмотрим.

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

Всем успехов!