Spring Data JDBC vs Spring Data JPA: когда и зачем выбирать JDBC

Оглавление

  1. Введение
  2. Технические различия Spring Data JDBC и JPA
  3. Когда Spring Data JDBC предпочтительнее JPA
  4. Производительность и ресурсы: сравнительный обзор
  5. Примеры кода с Spring Data JDBC
  6. Рекомендации: когда выбирать Spring Data JDBC
  7. Сравнительная таблица Spring Data JPA vs Spring Data JDBC
  8. Заключение

Введение

При проектировании доступа к данным в приложениях на Spring Java разработчики часто используют Spring Data JPA, опирающуюся на ORM, для удобной работы с базой данных. Однако существует более “легкая” альтернатива – Spring Data JDBC, которая предлагает прямой доступ к данным без полноценного ORM-слоя. В этой статье мы подробно рассмотрим технические отличия Spring Data JDBC и Spring Data JPA, сравним их производительность (скорость и потребление памяти) и проанализируем сценарии, где Spring Data JDBC может оказаться предпочтительнее. Особое внимание уделим микросервисной архитектуре и пакетной (batch) обработке данных, а также приведем примеры кода и диаграммы, иллюстрирующие ключевые концепции. В конце представлены четкие рекомендации, когда именно стоит выбирать Spring Data JDBC вместо JPA.

Технические различия Spring Data JDBC и JPA

Отсутствие ORM и контекста персистентности. Главное отличие – Spring Data JDBC не является ORM-фреймворком. Spring Data JPA строится на JPA/Hibernate и использует объектно-реляционное отображение: сущности управляются EntityManager’ом, который ведет контекст персистентности (первый уровень кэша), отслеживает изменения (dirty checking) и поддерживает ленивую загрузку связей. Spring Data JDBC же работает более прямолинейно: при выполнении операций сразу обращается к базе через JDBC, без промежуточного Session/EntityManager. Нет понятия attached сущностей – каждая полученная из БД сущность не привязана к контексту и не отслеживается фреймворком. Это означает, что Spring Data JDBC не предлагает автоматического кеширования в транзакции, ленивой загрузки отношений или автоматического сохранения изменений при коммите транзакции. По сути, каждый вызов репозитория JDBC напрямую выполняет SQL и возвращает результат, никаких “магических” скрытых действий не происходит.

Агрегаты и модель данных. Отсутствие ORM влияет на способ моделирования данных. В JPA можно свободно использовать сложные связи между сущностями: например, двунаправленные @OneToMany/@ManyToMany, каскадные операции и т.д. Spring Data JDBC придерживается принципов предметно-ориентированного дизайна (DDD) и вводит понятие агрегатов. Репозиторий в Spring Data JDBC соответствует агрегату – группе объектов, которые всегда сохраняются и загружаются вместе, имея один корневой объект (Aggregate Root). Внутри агрегата можно моделировать отношения «один-ко-многим” как коллекции объектов (например, Order содержит список OrderItem), и Spring Data JDBC сохранит/загружает их целиком. Но вот между агрегатами прямые ссылки не поддерживаются: вместо явных внешних ключей на уровне объектов используются идентификаторы. Например, для связи «многие-к-одному” или «многие-ко-многим” Spring Data JDBC рекомендует хранить в объекте лишь ID другой сущности (через тип AggregateReference), без автоматической загрузки связанного объекта. Это важное ограничение: Spring Data JDBC не поддерживает прямых связей Many-to-One и Many-to-Many – такие связи реализуются явным хранением foreign key и отдельными запросами за связанными данными. Такой подход дисциплинирует разработчика четко ограничивать границы агрегатов, упрощает модель данных и устраняет целый класс проблем (например, циклические зависимости, сложно контролируемые каскады, “ infamous N+1 queries” и т.д.). В JPA же, напротив, можно работать с обширным графом связанных сущностей как с единым объектным графом – фреймворк сам позаботится о загрузке связей (лениво или жадно), синхронизации изменений и каскадных операциях.

Кеширование и ленивые загрузки. За счет контекста персистентности Hibernate предоставляет 1-й уровень кэша (все загруженные в транзакции сущности хранятся и переиспользуются) и опциональный 2-й уровень кэша для межтранзакционного кеширования. Кроме того, JPA поддерживает ленивую загрузку: связанные коллекции или объекты могут подгружаться из БД только при обращении к ним, что позволяет гибко работать с графом объектов. В Spring Data JDBC ничего этого нет: никакого контекста и кэша – объекты всегда “оторваны” (detached) от хранения, и при каждом запросе выполняется новый SQL, а ленивая загрузка отсутствует. Это упрощает предсказуемость: нет скрытых дополнительных запросов – если нужно связанные данные, их либо проектируют в тот же агрегат, либо делают отдельный метод репозитория для их выборки. Однако разработчик должен сам позаботиться о том, чтобы при выборке получить все нужное, так как при повторном доступе к уже полученному объекту Spring Data JDBC не пойдет в базу автоматически, в отличие от JPA. Отсутствие контекста означает и отсутствие автоматического dirty checking: изменения полей сущности не сохранятся в БД при закрытии транзакции автоматически. Вместо этого нужно явно вызывать save() на репозитории для обновления объекта.

Изменение данных и каскадность. При обновлении данных эти фреймворки ведут себя принципиально по-разному. В JPA, когда мы загружаем объект, изменяем его поля и коммитим транзакцию, Hibernate автоматически определит измененные поля и выполнит соответствующий UPDATE (благодаря механизму dirty checking и тому, что объект привязан к Persistence Context). В Spring Data JDBC при вызове save(...) для агрегата фреймворк не знает, что именно изменилось, поэтому по умолчанию перезаписывает весь агрегат целиком. Например, если у сущности-агрегата есть дочерние записи (список), то при сохранении агрегата Spring Data JDBC сначала удалит все старые дочерние записи, а потом вставит их заново. Это неожиданный момент для тех, кто переходит с JPA: такова плата за отсутствие отслеживания состояния – фреймворк просто пересоздает подчиненные сущности. Поэтому важно: нужно всегда загружать и подавать на сохранение полный агрегат, включая детей; иначе можно нечаянно потерять связанные данные. В частности, операция обновления агрегата в Spring Data JDBC не “сливает” изменения, как merge в JPA, а заменяет состояние: несохраненные в объекте дочерние элементы будут удалены из базы. Каскадные операции в JPA позволяют автоматически сохранять связные сущности (CascadeType.ALL и др.), а Spring Data JDBC обеспечивает каскад внутри агрегата (список детей сохранится вместе с корнем), но вне агрегата – нет (нельзя автоматом сохранить вместе две разные агрегатные корни).

Поддержка Query и SQL. Spring Data JPA дает мощный инструмент JPQL/HQL и Criteria API для создания запросов, абстрагируясь от конкретного диалекта SQL. Запросы на JPQL проверяются на этапе запуска приложения, что ловит ошибки еще до выполнения. В Spring Data JDBC тоже есть механизм составления запросов по именам методов и аннотация @Query, но запросы пишутся на чистом SQL. Например, в репозитории JDBC можно написать: @Query("SELECT * FROM users WHERE name = :name") – Spring Data выполнит этот SQL и сам преобразует ResultSet в объекты. Плюс в том, что вы полностью контролируете запрос (можно использовать специфичные функции СУБД, сложные JOIN’ы и т.д. без ограничений JPQL). Минус – привязка к диалекту: смена базы данных может потребовать переписывания таких SQL-запросов. Также строки SQL в Spring Data JDBC не валидируются при старте (в отличие от JPQL в JPA), поэтому ошибку в SQL вы увидите только в рантайме при выполнении запроса. Это нужно учитывать: тестирование здесь играет ключевую роль для проверки корректности SQL. Кроме того, генерации схемы Spring Data JDBC тоже не делает – разработчик сам пишет DDL (например, через schema.sql), тогда как JPA при необходимости может генерировать таблицы по моделям.

Чтобы наглядно сопоставить архитектуру доступа к данным в двух подходах, рассмотрим диаграмму ниже.

Рис. 1: архитектура взаимодействия с базой данных в Spring Data JPA vs Spring Data JDBC.

В JPA используется уровень ORM (EntityManager/Hibernate), который управляет объектами в Persistence Context и откладывает выполнение SQL до момента коммита/флаша. Spring Data JDBC минует ORM: вызовы репозитория сразу выполняют SQL через JdbcTemplate, возвращая полностью готовые объекты без промежуточного кеширования. Это делает поведение Spring Data JDBC более предсказуемым (нет ленивых дозагрузок или неожиданных flush()), хотя и требует более явного управления связями и запросами.

Когда Spring Data JDBC предпочтительнее JPA

Рассмотрев отличия, можно выделить конкретные ситуации, где Spring Data JDBC дает преимущества перед Spring Data JPA:

  • Микросервисная архитектура. В мире микросервисов каждый сервис обычно владеет своей небольшой базой данных и моделью данных с относительно простыми связями. В таких условиях полноценный ORM может быть избыточным. Spring Data JDBC, будучи легковесным, отлично подходит для микросервисов, где важны простота, низкое потребление ресурсов и быстрый старт. Отсутствие тяжелого контекста персистентности снижает накладные расходы: каждый сервис потребляет меньше памяти и быстрее инициализируется (нет долгого сканирования аннотаций @Entity, построения метамоделей и т.д.). Более того, микросервисы часто горизонтально масштабируются, и поддержание внутреннего кэша (как в JPA) здесь не столь важно – предпочтительно статeless-подход. Источники отмечают, что при переходе к микросервисам многие компании отказываются от тяжелых ORM-решений в пользу более простых, таких как Spring Data JDBC. Если в сервисе нет сложных транзакций, охватывающих множество связанных объектов, то выгода JPA невелика. Spring Data JDBC выигрывает для микросервисов с простыми моделями, так как обеспечивает “прямой” доступ к БД с минимальной магией и нагрузкой. Как образно отметили авторы, JPA – это «арендовать автомобиль с навигатором и сервисом, делающий все за вас”, а JDBC – «легкий мотоцикл для короткой поездки, где вы контролируете каждый аспект”. В контексте микросервисов лишние функции JPA (ленивая загрузка, второй кэш, сложный ORM) зачастую не нужны – вместо этого ценятся простота и предсказуемость.
  • Batch-обработка и массовые операции. При пакетной обработке большого объема данных (ETL-процессы, миграции, загрузка логов, batch job’ы) Spring Data JDBC часто предпочтительнее из-за более низкого потребления памяти и накладных расходов. В Hibernate при работе с тысячами объектов в рамках одной транзакции возникает рост потребления памяти: все сущности хранятся в первом уровне кэша, пока не будет выполнен flush/commit. Это может привести к значительному расходу памяти или даже OOM, если разработчик не позаботился периодически очищать контекст (через entityManager.clear() или разбивку на чанки). Кроме того, при неправильно настроенном JPA возможны многочисленные дополнительные запросы (например, лениво загружаемые связи могут выстрелить N+1 запрос) и другие сюрпризы, бьющие по производительности. Spring Data JDBC лишен такой “магии” – он просто выполняет необходимые SQL, и память освобождается по завершении каждого запроса, без удержания всех объектов в сессии. Это означает, что при одинаковом объеме работ JDBC потребляет меньше памяти, что критично для batch-задач. Даже сами разработчики Hibernate признают, что для массовых вставок/обновлений низкоуровневый JDBC часто эффективнее. Практический пример: если нужно вставить огромный объем записей, JPA придется настраивать – включать batch-режим Hibernate (например, hibernate.jdbc.batch_size), отключать генерацию IDENTITY и т.д., чтобы добиться приличной скорости. Spring Data JDBC изначально ближе к “чистому” JDBC, поэтому при прямых вставках может показать лучшую производительность. Отмечалось, например, что в сценарии массового вставления (bulk insert) Spring Data JDBC обходит JPA по скорости.

Рис. 2: сравнение времени пакетной вставки 10k записей.

В данном примере JPA без batch-конфигурации выполняет вставку значительно медленнее (каждая операция отдельно, контекст персистентности нагружен) – 12 секунд. При включении batching в Hibernate (например, batch_size=100) производительность JPA существенно улучшается – до ~4 секунд. Spring Data JDBC даже без специального batching (по умолчанию saveAll в цикле) может показать время около 3 секунд за счет отсутствия ORM-надбавки. Реальные цифры зависят от окружения, но тенденция ясна: без тонкой настройки JPA уступает по скорости, а при грамотной настройке сравним или слегка проигрывает “чистому” JDBC. Здесь стоит учесть, что Spring Data JDBC не поддерживает автоматический batch out-of-the-box – вызов repository.saveAll(Iterable) под капотом просто итерирует по записям, выполняя одиночные INSERT. Hibernate же может группировать несколько операций в одну пачку к моменту flush. Тем не менее, при batch-задачах Spring Data JDBC все равно привлекателен: во-первых, из-за экономии памяти (нет удержания контекста), во-вторых, можно реализовать batching самостоятельно. Например, можно написать кастомный метод репозитория, использующий JdbcTemplate.batchUpdate(...) для вставки сразу пачки записей. Практика показывает, что сочетание Spring Data JDBC + ручное использование JdbcTemplate для batch-вставок дает отличный контроль и производительность, обойдя JPA, который пытается подстроиться под batch через ORM прослойку. В итоге для офлайн-обработки больших данных, миграций, batch-импорта и пр. стоит рассмотреть Spring Data JDBC, чтобы избежать перегрева JPA-слоя.

  • Простая схема без сложных связей. Если ваша модель данных относительно проста – например, несколько таблиц с отношением «один-ко-многим”, без сложных графов объектов и без необходимости в inheritance mapping – то Spring Data JDBC позволит сэкономить усилия. Вы получите все преимущества Spring Data (репозитории, запросы по именам методов, интеграция с транзакциями), но без накладных расходов ORM. JDBC-репозитории идеально подходят для CRUD-приложений с простой схемой, где требуется только базовый функционал (создать/прочитать/обновить запись) и нет необходимости в ленивой загрузке или во встроенных механизмах JPA. Как отмечалось, “Spring Data JDBC – идеален для легковесных, простых приложений с минимальными связями”. Он позволяет избегать “overkill” ситуации, когда ради пары простых операций подтягивается весь Hibernate со своим «мнением” как вам работать с данными. Типичный пример – микросервис справочника или конфигурации, где 3-5 таблиц, и никакой сложной бизнес-логики на уровне БД. Зачем там ленивые прокси и кеш первого уровня? Spring Data JDBC будет проще и быстрее в реализации.
  • Прозрачность и контроль SQL. В случаях, когда критически важно точно контролировать генерируемый SQL (например, для тюнинга производительности запросов или использования специфичных SQL-конструкций), Spring Data JDBC дает преимущество. JPA старается скрыть SQL за JPQL/Criteria, и хотя допускает нативные запросы, основной сценарий – это довериться генерации SQL под капотом. Эта генерация не всегда оптимальна: могут получаться лишние JOIN’ы, непродуманные подзапросы, да и просто неочевидно, какой SQL исполнится, пока не посмотришь логи Hibernate. В JDBC же – полная предсказуемость: запросы явно написаны, “what you see is what you get”. Отладка тоже упрощается – при проблеме с запросом вы сразу видите ее в своем SQL, а не копаетесь в сгенерированном Hibernate SQL. Кроме того, если проект активный и схема данных регулярно эволюционирует сложными способами, поддерживать JPA-сущности может быть сложно – любой нестандартный запрос требует либо мудрить с JPQL, либо падать до native SQL, а там теряется часть плюсов JPA. Spring Data JDBC изначально предполагает, что разработчик не боится SQL и готов его писать там, где нужно – за это вы получаете и гибкость, и заметно более облегченный, прозрачный слой доступа к данным без скрытых нюансов транзакционного поведения. Для опытных mid/senior разработчиков, которые понимают SQL и хотят полностью контролировать взаимодействие с БД, Spring Data JDBC может быть более привлекательным выбором.

Естественно, есть и сценарии, где Spring Data JPA остается более подходящей. Если у вас сложнейшая объектная модель с множеством взаимоотношений (особенно многие-ко-многим, сложные наследования, сущности-значения и т.п.), и вы хотите оперировать именно объектами, перекладывая большую часть работы на ORM – JPA будет удобнее. Также когда приложение требует кэширования данных в памяти для производительности чтения, или используются возможности Hibernate (вторичный кэш, продвинутый маппинг, аудит изменений, EntityGraph для выборки связей) – прямой JDBC такого не даст. JPA по-прежнему хорошо справляется с “богатыми” доменными моделями и облегчает разработку CRUD-логики, скрывая SQL. Поэтому выбор всегда зависит от контекста: “правильный инструмент под конкретную задачу”. Ниже мы подытожим рекомендации явнее.

Производительность и ресурсы: сравнительный обзор

Поговорим подробнее о производительности Spring Data JDBC vs JPA. С точки зрения скорости выполнения запросов, JDBC-ориентированный подход почти всегда будет как минимум не медленнее, а зачастую – быстрее JPA. Причина в том, что JPA добавляет дополнительный уровень обработки: полученные из БД строки нужно превратить в объекты, связать их через прокси, учесть в контексте; при сохранении – отследить изменения, сформировать SQL для каждого обновленного объекта, возможно, выполнить несколько запросов (например, сначала SELECT, потом INSERT или UPDATE). В Spring Data JDBC лишнего не делается – запрос пишется напрямую, библиотека получает ResultSet и сразу мапит его на объекты, без прокси-слоев. Baeldung отмечает, что одним из крупнейших преимуществ Spring Data JDBC является улучшенная производительность доступа к БД по сравнению с JPA именно благодаря тому, что JDBC работает “напрямую” без большинства ORM-магии. В простых CRUD-операциях (вставка, выборка по ID) разница может быть не очень заметна – оба подходят к делу эффективно (JPA тоже умеет составлять подготовленные выражения и использует кеш запросов базы). Но в более сложных сценариях JPA может внезапно притормозить: например, при загрузке связанных коллекций лениво (N+1 проблема) или при обновлении огромной коллекции (помните о удалении-ставке детей в JDBC – JPA же пытается обновлять поэлементно, что не всегда оптимально).

Отдельно стоит сказать про потребление памяти и масштабирование. Как уже упоминалось, JPA держит объекты в памяти (контекст персистентности) в течение транзакции. Это не только память, но и нагрузка на Garbage Collector, особенно если в одной транзакции обрабатывается много данных. Spring Data JDBC освобождает объекты сразу после выполнения метода репозитория (если они не нужны дальше). В результате при нагрузке JDBC-ориентированное решение потребляет меньше памяти и дает более высокий пропуск трафика. Практически это означает более устойчивую работу под высокой нагрузкой записи: нет “раздувания” сессии, ниже давление на GC – система может обрабатывать больше операций параллельно. Опять же, JPA можно настраивать: уменьшать размер контекста, ограничивать диапазон сессии, отключать каскады, но все эти усилия фактически приближают его поведение к “как у JDBC”.

С другой стороны, Hibernate умеет кое-что ускорять на чтении за счет кэша: если в рамках одной транзакции вы повторно запрашиваете тот же объект по тому же идентификатору, второй раз SQL не пойдет – EntityManager вернет его из 1-го уровня кэша. В Spring Data JDBC каждый findById выполнит SQL запрос заново. Для распределенных (например, микросервисных) систем это не критично, но в монолите, где часто в больших транзакциях обращаются к одним данным, JPA может сэкономить на повторных обращениях. Также, Hibernate’s 2nd level cache может существенно ускорить повторяющиеся чтения в масштабе приложения, чего у JDBC нет из коробки (при необходимости, можно подключить Spring Cache абстракцию для методов репозиториев JDBC). В общем случае, на чтение при сложных связях JPA может показывать близкую или лучшую производительность, если все правильно настроено, благодаря кешированию и объединению запросов (через графы или join fetch). Однако в сценариях интенсивной записи или обработки данных Spring Data JDBC выигрывает за счет отсутствия overhead. Недаром отмечают, что для систем, критичных к производительности (высоконагруженные API, real-time аналитика, batch-джобы), все чаще выбирают либо чистый JDBC, либо облегченные фреймворки (MyBatis, Spring Data JDBC), отходя от привычного Hibernate.

Рис.3: использование памяти (heap), GC-активность и latency при обработке 10.000 записей с использованием JPA и Spring Data JDBC.

Подводя итог по ресурсам: Spring Data JPA добавляет уровень абстракции, за который платит и процессорным временем, и памятью. Spring Data JDBC “ближе к металлу” – меньше прослоек, лучше предсказуемость и, как правило, меньше задержки на операцию. В цифрах разница зависит от конкретного случая, но как тенденция: JDBC быстрее на миллисекунды на каждую операцию и потребляет в разы меньше памяти при большом числе сущностей (поскольку нет хранения копий объектов). Например, в одном измерении, JPA-сессия из ~10000 объектов потребляла сотни мегабайт памяти и дала ~200 операций/сек, тогда как JDBC обработал аналогичный объем, почти не увеличив usage памяти, с throughput в несколько раз выше (условный пример для иллюстрации). Конечно, JPA при правильном тюнинге (batch-операции, clear() каждые N записей, отключение ненужных фич) тоже способен показывать отличную производительность – но тогда возникает вопрос, стоит ли игра свеч, если можно изначально взять более простой инструмент.

Примеры кода с Spring Data JDBC

Рассмотрим, как работать с данными через Spring Data JDBC на практике, и сравним с JPA-подходом для понимания различий. Предположим, у нас есть простая доменная модель – пользователи и заказы: пользователь User имеет заказы Order (связь один-ко-многим). В JPA мы бы описали две @Entity и могли бы использовать @OneToMany для связи. В Spring Data JDBC сделаем немного иначе.

Сущности (Domain Model)

В Spring Data JDBC нет необходимости помечать класс аннотацией @Entity – достаточно обычного POJO. Для соответствия таблице можно использовать @Table, либо полагаться на соглашение об именах. По умолчанию названия классов и полей преобразуются из CamelCase в snake_case для таблиц и колонок. Ниже пример класса пользователя и заказа:

@Table("users")
public class User {
    @Id
    private Long id;
    private String name;
    private String email;
    
    // Связь "пользователь -> его заказы": список заказов
    private List<Order> orders = new ArrayList<>();
    
    // Конструкторы, геттеры/сеттеры опущены для краткости
}

@Table("orders")
public class Order {
    @Id
    private Long id;
    private String product;
    private int quantity;
    
    // Внешний ключ на пользователя (агрегатный root)
    private Long userId;
    // Конструкторы, геттеры/сеттеры...
}

В этом коде класс User сопоставлен с таблицей users (можно было не указывать @Table, Spring Data JDBC сам бы отобразил User -> user или users по стратегии именования). Поле id отмечено @Id – обязателен идентификатор для каждой сущности в JDBC. Обратите внимание: мы включили список orders прямо в класс User. Согласно правилам Spring Data JDBC, все объекты, достижимые из корневого объекта (User) по нестатическим ссылкам, считаются частью агрегата и будут автоматически загружены/сохранены вместе с ним. То есть заказы – дети агрегата пользователя. В классе Order присутствует поле userId (внешний ключ на владельца). При сохранении пользователя репозиторий сам проставит userId для каждого заказа и выполнит необходимые INSERT/UPDATE для orders. Здесь нет привычной для JPA двунаправленной связи – связь односторонняя: User -> Order. В JDBC не рекомендуется делать двусторонних ссылок внутри агрегата (и тем более нельзя – между агрегатами). Если нам нужен у заказа доступ к пользователю, можно хранить userId и при необходимости загрузить пользователя вручную через репозиторий.

Важно, что связь orders не помечена никакой аннотацией – Spring Data JDBC сам распознает коллекцию как дочерние объекты агрегата. Но, если нужно, можно настроить имена колонок ключа через @MappedCollection(idColumn="user_id") – в нашем случае мы явно ввели поле userId и оно автоматически будет использовано.

Репозитории

Spring Data JDBC предоставляет те же знакомые интерфейсы репозиториев, что и JPA. Можно расширить CrudRepository или PagingAndSortingRepository для получения базовых CRUD-методов. Для нашего примера создадим два репозитория:

@Repository
public interface UserRepository extends CrudRepository<User, Long> {
    // Derived query: метод будет реализован автоматически
    List<User> findByName(String name);
}

@Repository
public interface OrderRepository extends CrudRepository<Order, Long> {
    // Специальный метод: найти все заказы по товару с pagination
    List<Order> findByProduct(String product, Pageable pageable);
    
    // Пример кастомного обновления через @Query
    @Modifying
    @Query("UPDATE orders SET quantity = :q WHERE id = :id")
    boolean updateOrderQuantity(@Param("id") Long orderId, @Param("q") int newQuantity);
}

Интерфейс UserRepository достаточно пуст – благодаря расширению CrudRepository у нас уже есть методы save(), findById(), findAll(), deleteById() и т.д. Мы добавили метод findByName и Spring Data JDBC сам сгенерирует для него SQL: SELECT * FROM users WHERE name = ?. Для более сложного случая в OrderRepository приведен метод с использованием @Query – демонстрация, как выполнять произвольный SQL (здесь обновление колонки). Обратите внимание, в отличие от JPA, мы ставим аннотацию @Modifying и пишем SQL прямо внутри @Query. Spring Data JDBC не поддерживает JPQL, поэтому запросы всегда будут в синтаксисе той СУБД, что вы используете. Кроме того, нет понятия EntityManager.flush() – но для методов, возвращающих boolean или примитив, Spring Data JDBC трактует их как модифицирующие, поэтому для ясности ставится @Modifying (как показано в примере).

Еще один момент: пагинация. В JPA репозитории мы часто используем Pageable и получаем Page<T>. Spring Data JDBC поддерживает Pageable только для методов, которые явно реализованы, либо для findAll(Pageable). Если использовать @Query с Pageable – будет исключение (строковые запросы с pageable пока не поддерживаются). Поэтому выше метод findByProduct демонстрирует подход: Spring Data сам составит запрос с LIMIT/OFFSET для переданного Pageable. А вот для нестандартного @Query придется либо вручную дописывать лимиты, либо разбивать на два метода (как показано в DZone, где для Spring Data JDBC приходилось делать findAllBy... и countAllBy... и затем вручную формировать Page). Впрочем, для большинства задач pagination работает из коробки, если придерживаться именованных методов репозитория.

Использование репозиториев

Применение Spring Data JDBC практически не отличается от Spring Data JPA на уровне сервисного кода. Репозитории интегрируются с Spring Transaction Management: методы по умолчанию не открывают транзакцию (в отличие от JPA, где за вызовами репозитория часто стоит Proxy с @Transactional). Но обычно сервисы отмечаются @Transactional в обоих случаях. Пример использования нашего UserRepository и OrderRepository:

@Service
public class ShopService {
    @Autowired
    private UserRepository userRepo;
    @Autowired
    private OrderRepository orderRepo;
    
    @Transactional
    public void createSampleData() {
        User u = new User();
        u.setName("Ivan");
        u.setEmail("ivan@example.com");
        // добавим пару заказов
        Order o1 = new Order();
        o1.setProduct("Laptop");
        o1.setQuantity(1);
        Order o2 = new Order();
        o2.setProduct("Mouse");
        o2.setQuantity(2);
        u.getOrders().add(o1);
        u.getOrders().add(o2);
        userRepo.save(u);  // сохранит и пользователя, и его заказы
    }
    
    public List<Order> findOrdersByProduct(String product) {
        return orderRepo.findByProduct(product, PageRequest.of(0, 10));
    }
}

В методе createSampleData мы создаем пользователя и два связанных заказа, добавляем их в коллекцию пользователя и вызываем userRepo.save(u). Spring Data JDBC сохранит агрегат целиком: сначала вставит запись в users (получив сгенерированный id пользователя, если используется авто-инкремент), затем проставит этот id в каждому Order и вставит записи в orders таблицу. Все эти операции произойдут внутри одной транзакции (благодаря @Transactional). Нам не нужно вызывать orderRepo.save для каждого заказа – JDBC сам обработает вложенные объекты агрегата. В JPA схожий код мог бы использовать Cascade для сохранения связанных сущностей, но обратите внимание: в JDBC нет lazy loading – мы сразу явно добавили объекты в коллекцию. В JPA мы могли бы вообще не трогать поле заказов – и они бы сохранились каскадно через user.getOrders().add(). В JDBC же, если нам нужно обновить заказы пользователя, мы обязаны всегда оперировать полным списком (как обсуждалось, иначе невключенные будут удалены при сохранении агрегата).

Метод findOrdersByProduct показывает, как выполнять запрос с пагинацией – Spring Data JDBC выполнит SELECT * FROM orders WHERE product = ? LIMIT 10 (в зависимости от диалекта SQL, для H2/PostgreSQL это будет LIMIT, для Oracle – своя конструкция, но Spring это учтет). Получаем список Order, которые затем можно обработать. Если бы нам нужно было подтянуть пользователя к каждому заказу, то либо изменить запрос (join-ить таблицы и сделать проекцию), либо после получения списка пройтись и вызвать userRepo.findById(order.getUserId()) для каждого – т.е. вручную. JPA сделала бы это через @ManyToOne User user внутри Order и ленивую загрузку, но, повторимся, JDBC так не умеет – придется явно писать дополнительные запросы или расширять агрегат.

Особенности и подсказки

На этих примерах видно, что базовый CRUD с Spring Data JDBC очень похож на JPA по удобству: минимальный код, Spring сам генерирует нужный SQL для простых случаев. Но при этом разработчик должен помнить об особенностях:

  • Отсутствие автоматического слияния изменений. Если вы получили объект из базы через JDBC-репозиторий, изменили его поля, нужно вызвать save(), чтобы изменения попали в БД. В противном случае изменение будет только в памяти и потеряется. В JPA вы могли в пределах транзакции изменить объект и просто завершить метод – Hibernate сам сохранит изменения (flush) при коммите. В JDBC такого нет. Например, user = userRepo.findById(id); user.setName("New"); // транзакция завершилась – новое имя не сохранится, если не сделать userRepo.save(user).
  • Единичная загрузка без контекста. Если в пределах одной транзакции вы дважды вызовете findById(100) через JDBC, то дважды получите разные объекты (хотя с одинаковыми данными), и оба будут независимы. В JPA же второй вызов вернул бы тот же самый объект из контекста (или PersistenceContext бросил бы исключение при попытке подгрузить сущность с тем же PK, если она ужеManaged). В JDBC два объекта с одинаковым ID никак не связаны. Практический эффект этого проявляется, например, когда внутри одной транзакции вы пытаетесь модифицировать одну и ту же запись через два разных репозитория или методов: может возникнуть конфликт или потеря изменений. DZone приводит пример, где в одной транзакции вызвали два метода, один меняет объект, второй загружает по ID и меняет другое поле – в JPA это синхронизировалось бы на одном экземпляре, а в JDBC – работаем с двумя и последний save затирает изменения первого. Решение – аккуратно структурировать сервисы, избегать повторной загрузки одного и того же ID в рамках одного бизнес-операции, либо сразу работать с переданным объектом вместо ID.
  • Ограничения по отношениям. Как упоминалось, Spring Data JDBC не поддерживает сложные отношения. Например, невозможно напрямую смоделировать «много-ко-многим” таблицу связей и получать ее автоматически – нужно заводить промежуточный класс (как AuthorRef в примере от Spring team) и использовать ID. Это увеличивает немного объем кода и требует правильного DDD-мышления. Многие, привыкшие к JPA, по началу недоумевают, почему нельзя просто пометить поле аннотацией и получить связь – но JDBC делает это осознанно: вы явно работаете с идентификаторами и разделяете агрегаты. Так что при переходе на Spring Data JDBC, вероятно, потребуется пересмотреть модель данных: разбить слишком связные сущности на агрегаты, убрать двунаправленные ссылки, возможно, дублировать какие-то данные между агрегатами (денормализация) или, наоборот, сделать агрегаты крупнее, чтобы охватить нужные данные. Это архитектурная плата за простоту.
  • Отсутствие некоторых возможностей JPA. Стоит осознавать, что Spring Data JDBC – не полный аналог JPA, и некоторых привычных фич может не быть. Например, нет встроенной поддержки аудита (@CreatedDate, @Version для оптимистичного блокирования – хотя последнюю можно заменить ручным @Version полем с контролем), нет встроенной валидации связей, отсутствует EntityManager.find(..., LOCK) для блокировок (но можно всегда выполнить SELECT … FOR UPDATE через @Query). Также, генерация ID должна быть продумана: Spring Data JDBC не управляет ID так гибко, как Hibernate (который поддерживает UUID, Hi/Lo, SEQUENCE с preallocation и т.п.). В JDBC либо база сама генерирует (auto increment), либо вы вручную задаете (например, UUID перед сохранением). Это не проблема, но требует учитывать в дизайне.

В целом, когда вы пишете код на Spring Data JDBC, вы получаете простоту и прозрачность – ваш код явно отражает, какие запросы будут выполнены и когда. Для Java-разработчика, уверенного в SQL, этот подход зачастую более предсказуем и дает ощущение полного контроля.

Рекомендации: когда выбирать Spring Data JDBC

Подводя итог, перечислим четкие рекомендации, в каких случаях стоит предпочесть Spring Data JDBC вместо Spring Data JPA:

  1. Микросервисы с простым доменом. Если приложение разбито на множество небольших сервисов, каждый со своей базой, и модели данных в них не слишком сложны – смело выбирайте Spring Data JDBC. Вы снизите потребление памяти и ускорите холодный старт сервисов. JDBC-репозитории особенно хороши для сервисов-конкрет, выполняющих одну функцию, где важна легкость и скорость, а связи с другими данными минимальны. Как отмечают эксперты, в современных cloud-native микросервисах тяжелый ORM “не нужен и даже мешает”, и многие переходят на более легкие подходы (вплоть до Go-lang и прямого SQL) ради повышения производительности и удешевления обслуживания. Spring Data JDBC в этом плане – баланс между удобством Spring и эффективностью чистого SQL.
  2. Высоконагруженные системы, чувствительные к производительности. Если ваше приложение должно выдерживать тысячи операций в секунду, особенно запись/обновление, JDBC-подход даст преимущество. Он убирает часть скрытых издержек JPA (проверка контекста, создание прокси, управление кешем) и позволяет системе масштабироваться лучше под нагрузкой. Это могут быть системы сбора логов, трекинга событий, аналитические пайплайны – где счет на миллисекунды и каждая лишняя аллокация в памяти имеет значение. Spring Data JDBC обеспечивает более низкие latency и GC overhead, что подтверждается практикой. Сюда же относятся batch-джобы: импорт больших файлов, миграции – там JDBC спасет от проблем с памятью и ускорит обработку. JPA не назовешь медленным инструментом, но для “performance-critical” задач лучше прямой JDBC или его облегченное обертывание.
  3. Приложения со стабильной, простой схемой, где ORM – overkill. Если ваш проект в первую очередь ориентирован на работу с SQL (например, много сложных отчетных запросов, хранимых процедур) или схема данных очень проста, нет смысла тянуть в проект Hibernate. Spring Data JDBC позволит вам использовать всю мощь Spring Data (репозитории, DI интеграцию, тестирование через @JdbcTest) без той сложности, которую привносит JPA. Например, в приложениях IoT или простых веб-сервисах, где нужно быстро записывать данные датчиков или простые CRUD для таблиц настроек – JDBC будет проще и надежнее. Вы избегаете потенциальных проблем JPA (лень загрузки, управление транзакциями с merge/detach, синхронизация сложности) и получаете понятную, линейную логику.
  4. Необходимость тонкого контроля SQL или использование специфичных БД. Когда вам важно писать собственные запросы, оптимизировать их под свой случай или вы используете особенности конкретной СУБД (например, гео-расширения PostGIS, JSON-функции PostgreSQL, таблицы Oracle Hierarchy и т.п.), Spring Data JDBC не заставляет прятать это за абстракцией. Вы можете спокойно вызывать спец. функции в @Query и получать advantage от SQL, а не ждать поддержки в Hibernate-диалекте. Более того, если планируется поддержка разных типов хранилищ (SQL + NoSQL, polyglot persistence), JPA тут не помощник – он рассчитан только на реляционные базы. JDBC-стек проще сочетается с вызовами к другим хранилищам или API. В современных приложениях, где одна часть данных может жить в PostgreSQL, другая в Elasticsearch, третья в Cassandra, нет смысла завязываться на одну универсальную ORM-модель. Использование Spring Data JDBC в связке с другими специализированными клиентами (например, Spring Data MongoDB) может быть более однородным по подходу, чем смешение парадигм ORM и non-ORM.
  5. Команда разработчиков знакома с SQL и DDD. Наконец, фактор команды: Spring Data JDBC требует несколько большего понимания низкоуровневых деталей – нужно самому проектировать схемы, писать SQL при необходимости, понимать, как строятся агрегаты. Если ваша команда состоит из опытных мидлов и сеньоров, хорошо владеющих SQL и принципами DDD, они, вероятно, извлекут больше выгоды из JDBC-подхода. Код будет более выразительным, без “магии”, а потенциальные ошибки (например, в запросах) будут очевиднее. С другой стороны, если команда привыкла работать с JPA и не хочет/не умеет работать с чистым SQL, либо доменная модель действительно сложная, то JPA может снизить риск ошибок и ускорить разработку типичного CRUD-функционала.

Вывод: Spring Data JDBC следует выбирать, когда простота, прозрачность и эффективность важнее, чем богатые возможности ORM. Это микросервисы, простые или высоконагруженные сервисы, batch-приложения. Вы получите более предсказуемое поведение (никаких ленивых сюрпризов и внезапных каскадов), и, как правило, лучшее использование ресурсов (памяти и CPU) для прямолинейных операций с базой. Spring Data JPA же остается отличным выбором при сложных связях, когда вам нужна “из коробки” реализация паттерна Unit of Work, кеширование и когда производительность вторична по сравнению с удобством разработки и поддержкой стандарта JPA. Многие проекты даже сочетают подходы: например, 90% бизнес-функций на JPA, а для особо требовательных участков пишут native SQL или используют Spring Data JDBC в тех модулях, где это оправданно.

Сравнительная таблица Spring Data JPA vs Spring Data JDBC

КатегорияSpring Data JPASpring Data JDBC
Подход к работе с БДORM через EntityManager, контекст персистентности, отслеживание состояния объектовПрямой SQL через JdbcTemplate, без ORM и без контекста
Ленивая загрузка (Lazy loading)Поддерживается (через прокси, требует @Transactional)Не поддерживается
Отслеживание изменений (Dirty checking)Да, автоматическоеНет, требуется явный вызов save()
Сохранение связанных сущностейКаскадное сохранение (CascadeType.ALL, @OneToMany, и т.д.)Только в пределах агрегата (например, список в корне)
Модель данныхГибкая, сложные связи (Many-to-Many, Inheritance и др.)Строгие агрегаты, связи через AggregateReference, нет Many-to-Many
Генерация SQLАвтоматическая, на основе аннотаций и JPQLЯвное написание SQL (@Query), или генерация простых CRUD
Контроль SQLОграниченный (Hibernate может генерировать неоптимальные запросы)Полный контроль, всегда видно и понятно, что исполняется
Производительность: CRUDХорошая, но с накладными расходами на ORMОтличная — меньше прослоек, SQL выполняется немедленно
Производительность: batch-вставкаТребует ручной настройки (hibernate.jdbc.batch_size, flush(), clear())Простая вставка возможна сразу, возможна оптимизация через JdbcTemplate.batchUpdate
Потребление памятиВысокое — из-за контекста и хранения сущностейНизкое — объекты создаются на каждый вызов, нет кеширования
Поддержка кэша (1st, 2nd level)Есть (Persistence Context, EHCache, Hazelcast и др.)Нет (но можно интегрировать Spring Cache)
Время старта приложенияДольше — Hibernate сканирует модели, строит метамоделиБыстрее — нет инициализации ORM
Отладка SQL и логика работыСложнее — SQL генерируется Hibernate, часто неочевиденПрозрачно — SQL либо пишется явно, либо логично генерируется
МикросервисыПоддерживается, но может быть избыточным (особенно для маленьких сервисов)Отлично подходит — легковесный, быстро запускается, меньше ресурсов
Batch-обработкаМожет тормозить без настройки; требует управления контекстомБолее производителен из коробки, без накопления сущностей
Гибкость архитектуры (DDD)Менее строго, допускает “богатые” доменные моделиСледует DDD: чёткие агрегаты, ограниченные связи
Кривая обученияНиже, особенно если опыт с JPA уже естьТребует понимания DDD, ручного управления связями, SQL

Заключение

Выбор между Spring Data JPA и Spring Data JDBC – это стратегическое архитектурное решение, которое должно опираться не на привычку, а на конкретные требования проекта.

Если вы строите легковесный микросервис, работаете с простой и стабильной схемой, ориентированы на предсказуемость, высокую производительность и низкое потребление ресурсов, то Spring Data JDBC – отличный выбор. Он дает полный контроль над SQL, минимальные накладные расходы, быстрое время старта и прозрачную работу без “магии” ORM.

С другой стороны, если ваша доменная модель сложная, содержит множество взаимосвязанных сущностей, требуется ленивое извлечение, каскады, наследование и другие возможности полноценного ORM – Spring Data JPA остается мощным и удобным инструментом, особенно при правильной настройке и контроле над контекстом персистентности.

Ключевые рекомендации:

  • Используйте Spring Data JDBC для: микросервисов, batch-обработки, ETL, сценариев с высокой нагрузкой на запись, простых CRUD-приложений, когда важны контроль, масштабируемость и эффективность.
  • Используйте Spring Data JPA для: сложных бизнес-доменов с богатыми связями, когда требуется кеширование, unit-of-work, и важна скорость реализации через декларативные аннотации.

Современные системы все чаще комбинируют подходы: используют Spring Data JPA там, где это удобно, и Spring Data JDBC – там, где важна производительность и контроль. Зрелый разработчик и архитектор должен уверенно владеть обоими инструментами и использовать их по назначению.

Вывод простой: не выбирайте JPA “по умолчанию” – выбирайте подход, который лучше решает вашу конкретную задачу.

Loading