«Возьмём JPA» — решение по умолчанию на JVM, и источник половины проблем с производительностью БД. ORM экономит boilerplate, но прячет SQL, который в итоге всё равно приходится понимать — обычно в проде, под нагрузкой, когда дашборд APM уже красный.
Дело не в том, что JPA/Hibernate плохи. Дело в том, что удобство объектной модели и цена по факту исполнения SQL — разные вещи, и разрыв между ними легко не заметить на этапе разработки, где таблицы маленькие, а N+1 незаметен на 3 строках. На реальных данных та же наивная обработка коллекции — уже десятки лишних запросов за один HTTP-запрос.
В этой статье — три слоя доступа к данным на одной и той же задаче (authors 1:N books, живой Postgres): сырой JDBC, JPA/Hibernate и типобезопасный jOOQ. Все три дают идентичный результат — это не абстрактное сравнение фич, а рабочий стенд с реальными SQL-логами, реальным счётчиком запросов Hibernate и реальным поведением пула соединений под нагрузкой.
По аналогии с доступом к БД в Go и C++Скоро, но со спецификой JVM-экосистемы: три конкурирующих подхода в одной платформе, а не один естественный выбор.
В статье
- JDBC как фундамент
- JPA/Hibernate: удобство объектной модели
- N+1: как это выглядит и как чинится
- Типобезопасный SQL: jOOQ
- Три подхода, один результат
- Транзакции и HikariCP: пул соединений под нагрузкой
- Ловушка: Hibernate 7 и Jakarta Persistence 3.2
- Spring Data: репозитории поверх JPA
- Когда что выбирать
JDBC как фундамент
Все три подхода в этой статье в итоге спускаются к одному и тому же: java.sql.Connection, PreparedStatement, ResultSet. JDBC — не «легаси», а фундамент, на котором стоят и Hibernate, и jOOQ, и Spring Data. Понимание того, что происходит на этом уровне, не опция — рано или поздно к нему приходится возвращаться при разборе медленного запроса или пула, который не отдаёт соединения.
Ручной JDBC — это один SQL-запрос с JOIN и маппинг ResultSet в объекты вручную:
public static Map<String, List<String>> authorsWithBooks(DataSource ds) throws SQLException {
String sql = """
SELECT a.id, a.name, b.title
FROM authors a
JOIN books b ON b.author_id = a.id
ORDER BY a.id, b.id""";
Map<String, List<String>> result = new LinkedHashMap<>();
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String author = rs.getString("name");
String title = rs.getString("title");
result.computeIfAbsent(author, k -> new ArrayList<>()).add(title);
}
}
return new TreeMap<>(result);
}Максимум контроля: запрос ровно тот, что написан, ни одного лишнего вызова к БД. Цена — весь маппинг, порядок колонок и приведение типов на вас. Для простых CRUD-сценариев с несколькими запросами это оверхед, но для узкого горячего пути (отчёт, батч, миграция) — часто самый предсказуемый вариант: никакой скрытой логики между вызовом и SQL, который реально уйдёт на сервер.
DataSource в стенде — HikariCP-пул (о нём подробнее в разделе про транзакции и HikariCP), общий для JDBC- и jOOQ-демо. JdbcTemplate из Spring снимает часть ручного кода (маппинг строк, закрытие ресурсов, конвертацию исключений), не пряча при этом сам SQL — разумный средний вариант, если ORM кажется избыточным, а голый JDBC — слишком многословным.
JPA/Hibernate: удобство объектной модели
JPA (Jakarta Persistence API) — спецификация, Hibernate — самая распространённая реализация. Вместо ResultSet — объектная модель: сущности с @Entity, связи через @OneToMany/@ManyToOne, EntityManager вместо ручного SQL.
Схема стенда — классическая связь 1:N, на которой удобно показывать проблему N+1:
@Entity
@Table(name = "authors")
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<Book> books = new ArrayList<>();
// геттеры/сеттеры
}fetch = FetchType.LAZY здесь указан явно (это и так дефолт для @OneToMany), потому что именно эта настройка — источник следующей проблемы.
N+1: как это выглядит и как чинится
Наивный обход выглядит совершенно невинно: получить список авторов, для каждого — размер коллекции книг.
public static long n1Demo(EntityManagerFactory emf) {
EntityManager em = emf.createEntityManager();
try {
TypedQuery<Author> q = em.createQuery("SELECT a FROM Author a ORDER BY a.id", Author.class);
List<Author> authors = q.getResultList(); // запрос №1
long totalBooks = 0;
for (Author a : authors) {
totalBooks += a.getBooks().size(); // лениво триггерит запрос №(1+i)
}
return statistics(emf).getPrepareStatementCount();
} finally {
em.close();
}
}Счётчик запросов в стенде — не подсчёт строк лога, а реальный Statistics#getPrepareStatementCount() из hibernate.generate_statistics=true — счётчик подготовленных JDBC statement’ов внутри самого Hibernate. Лог (hibernate.show_sql) рядом — качественное подтверждение, но для точного числа годится только статистика.
На сиде стенда (authors=20, books=74, детерминированный Random(seed=42)) наивный обход даёт ровно то, что обещает формула N+1 — 1 запрос на список авторов плюс 20 отдельных запросов на книги каждого:
Hibernate: select a1_0.id,a1_0.name from authors a1_0 order by a1_0.id
Hibernate: select b1_0.author_id,... from books b1_0 where b1_0.author_id=? (×20)
JPA N+1: authors=20, prepareStatementCount=21 (ожидание: 1 + 20 = 21), totalBooks=74
sequenceDiagram
participant App as Приложение
participant DB as PostgreSQL
App->>DB: SELECT * FROM authors (1 запрос)
DB-->>App: 20 строк authors
loop для каждого автора (20 раз)
App->>DB: SELECT * FROM books WHERE author_id = ?
DB-->>App: книги автора
end
Note over App,DB: Итого 21 запрос вместо одного JOIN
Два независимых способа фикса дают идентичный результат — по одному запросу вместо 21.
Фикс №1 — JOIN FETCH в JPQL, явное указание тянуть связь одним запросом:
public static long fetchJoinDemo(EntityManagerFactory emf) {
EntityManager em = emf.createEntityManager();
try {
TypedQuery<Author> q = em.createQuery(
"SELECT DISTINCT a FROM Author a JOIN FETCH a.books ORDER BY a.id", Author.class);
List<Author> authors = q.getResultList();
return statistics(emf).getPrepareStatementCount();
} finally {
em.close();
}
}Hibernate: select distinct a1_0.id,b1_0.author_id,... from authors a1_0
join books b1_0 on a1_0.id=b1_0.author_id order by a1_0.id
JPA JOIN FETCH: authors=20, prepareStatementCount=1, totalBooks=74DISTINCT в JPQL нужен, потому что JOIN разворачивает результат в декартово произведение по строкам books — без него один и тот же Author пришёл бы в списке несколько раз (Hibernate схлопывает дубликаты на уровне объектов, но DISTINCT в запросе всё равно нужен для корректности на уровне JPQL).
Фикс №2 — @EntityGraph, декларативное указание, какие связи подгрузить, через hint вместо явного JOIN FETCH в тексте запроса:
public static long entityGraphDemo(EntityManagerFactory emf) {
EntityManager em = emf.createEntityManager();
try {
EntityGraph<Author> graph = em.createEntityGraph(Author.class);
graph.addAttributeNodes("books");
TypedQuery<Author> q = em.createQuery(
"SELECT DISTINCT a FROM Author a ORDER BY a.id", Author.class);
q.setHint("jakarta.persistence.fetchgraph", graph);
List<Author> authors = q.getResultList();
return statistics(emf).getPrepareStatementCount();
} finally {
em.close();
}
}Hibernate: select distinct a1_0.id,b1_0.author_id,... from authors a1_0
left join books b1_0 on a1_0.id=b1_0.author_id order by a1_0.id
JPA @EntityGraph: authors=20, prepareStatementCount=1, totalBooks=74Обратите внимание — здесь LEFT JOIN, а не JOIN как в первом фиксе: entity graph не гарантирует наличие связанных строк (автор без книг всё равно должен попасть в результат), поэтому Hibernate генерирует именно внешнее соединение.
Итог одного прогона:
| Вариант | SQL-запросов |
|---|---|
| Наивный обход (N+1) | 21 (1 + 20) |
JOIN FETCH |
1 |
@EntityGraph |
1 |
Стенд не просто печатает эти числа — Main программно проверяет счётчик подготовленных запросов (getPrepareStatementCount() из Hibernate Statistics) и падает при расхождении с ожидаемым значением: 21 до фикса и 1 после каждого из двух фиксов зафиксированы ассертом, а не подобраны для текста статьи.
N+1 — не единственная классическая ловушка ORM (кеш первого/второго уровня, equals/hashCode на сущностях с ленивыми полями, каскады, которые удаляют больше, чем ожидалось, — темы для отдельного разбора), но именно она чаще всего всплывает первой: код проходит код-ревью, работает на тестовых данных и превращается в проблему производительности только на реальном объёме строк.
Типобезопасный SQL: jOOQ
jOOQ — промежуточная позиция между «SQL строками» и объектной моделью ORM: запрос строится через Java DSL (select/from/join/on), но результат — не граф сущностей, а типизированные records, близкие по духу к ResultSet.
private static final Table<?> AUTHORS = DSL.table(DSL.name("authors"));
private static final Table<?> BOOKS = DSL.table(DSL.name("books"));
private static final Field<Long> A_ID = DSL.field(DSL.name("authors", "id"), Long.class);
private static final Field<String> A_NAME = DSL.field(DSL.name("authors", "name"), String.class);
private static final Field<Long> B_ID = DSL.field(DSL.name("books", "id"), Long.class);
private static final Field<Long> B_AUTHOR_ID = DSL.field(DSL.name("books", "author_id"), Long.class);
private static final Field<String> B_TITLE = DSL.field(DSL.name("books", "title"), String.class);
public static Map<String, List<String>> authorsWithBooks(DataSource ds) throws SQLException {
Map<String, List<String>> result = new LinkedHashMap<>();
try (Connection conn = ds.getConnection()) {
DSLContext ctx = DSL.using(conn, SQLDialect.POSTGRES);
Result<Record3<Long, String, String>> rows = ctx
.select(A_ID, A_NAME, B_TITLE)
.from(AUTHORS)
.join(BOOKS).on(B_AUTHOR_ID.eq(A_ID))
.orderBy(A_ID, B_ID)
.fetch();
for (var r : rows) {
result.computeIfAbsent(r.value2(), k -> new ArrayList<>()).add(r.value3());
}
}
return new TreeMap<>(result);
}Важная оговорка про этот конкретный стенд: Table/Field объявлены вручную через DSL.name(...), без codegen. В типичном проде jOOQ используют иначе — генератор схемы (maven/gradle-плагин) читает живую БД на этапе сборки и создаёт типизированные классы таблиц и полей автоматически, включая проверку компилятором самих имён колонок. Здесь codegen сознательно пропущен: он требует доступную БД на этапе mvn generate-sources, что усложняет Docker-only сборку без внешних зависимостей на момент сборки. Итог — структура запроса (select/from/join/on) всё равно типобезопасна и ловит опечатки в порядке аргументов на этапе компиляции, но имена колонок ("author_id", "title") — обычные строки, ошибку в них компилятор не поймает, только Postgres на этапе исполнения. Это компромисс именно ради воспроизводимости стенда, не аргумент против codegen как такового — в реальном проекте с постоянным доступом к БД codegen убирает и этот класс ошибок.
Для Kotlin-проектов у похожей идеи — типобезопасный DSL поверх SQL — есть отдельная реализация, Kotlin Exposed, со своим набором trade-offs (DSL vs DAO API, миграции); в этой статье не разбирается отдельно.
Три подхода, один результат
Стенд не просто описывает три способа — он их сравнивает на одной и той же задаче и проверяет эквивалентность результата через equals() на TreeMap<String, List<String>> (падает при расхождении):
JDBC: 1 SQL-запрос (JOIN authors+books), authors=20
jOOQ: 1 SQL-запрос (JOIN authors+books через DSL), authors=20
Эквивалентность результатов: JDBC==jOOQ: true, jOOQ==JPA: trueВсе три подхода на этой задаче отдают один SQL-запрос — разница не в количестве обращений к БД (об этом предыдущий раздел про N+1), а в том, сколько ручного кода нужно вокруг: JDBC требует полного ручного маппинга, jOOQ — типизированного DSL без ручного маппинга, JPA — вообще без явного запроса, если не считать JPQL-строку.
Транзакции и HikariCP: пул соединений под нагрузкой
Открывать новое TCP-соединение к Postgres на каждый запрос — дорого (handshake, аутентификация, инициализация сессии). Пул соединений держит их переиспользуемыми. HikariCP — стандартный выбор на JVM: минимальный оверхед, простая конфигурация, дефолт в Spring Boot.
private static HikariDataSource build() {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(Db.URL);
cfg.setUsername(Db.USER);
cfg.setPassword(Db.PASSWORD);
cfg.setPoolName("data-access-pool");
cfg.setMaximumPoolSize(MAX_POOL_SIZE); // 4
cfg.setMinimumIdle(MIN_IDLE); // 1
cfg.setConnectionTimeout(5_000);
return new HikariDataSource(cfg);
}Пул в стенде нарочно маленький (maximumPoolSize=4), чтобы конкурентная нагрузка реально в него упёрлась, а не осталась скучным «всё idle». Демо запускает 8 воркеров, каждый держит соединение примерно 600 мс (SELECT pg_sleep(0.6)), и снимает метрики пула через HikariPoolMXBean до, во время пика и после:
HikariCP: maximumPoolSize=4, minimumIdle=1, 8 конкурентных воркеров
[до нагрузки ] total=2 active=0 idle=2 threadsAwaitingConnection=0
[под нагрузкой] total=4 active=4 idle=0 threadsAwaitingConnection=4
[после ] total=4 active=0 idle=4 threadsAwaitingConnection=0Пул реально насыщается (active=total=4), и ровно 4 из 8 воркеров в этот момент видны в очереди (threadsAwaitingConnection=4) — вторая половина уже держит соединения. После завершения всех воркеров пул возвращается к состоянию idle=total, ничего не «протекает».
Практический вывод из этих чисел: maximumPoolSize — не «чем больше, тем быстрее». Слишком маленький пул под реальной параллельной нагрузкой создаёт очередь ожидания (видно выше); слишком большой — упирается не в приложение, а в лимит соединений самой БД (Postgres по умолчанию — max_connections=100, и каждое соединение — это память и файловый дескриптор на стороне сервера). HikariCP-документация рекомендует отталкиваться от формулы connections = ((core_count * 2) + effective_spindle_count), но на практике надёжнее нагрузочный тест вроде этого, чем формула на бумаге.
Важная оговорка честности: этот HikariCP-пул обслуживает только JDBC- и jOOQ-демо в стенде. JPA/Hibernate-путь его не использует — Hibernate поднимает свой встроенный (built-in) connection pool, о непригодности которого для прода сам предупреждает при старте. В логе стенда это видно как стандартное предупреждение Hibernate с кодом HHH10001002 (Using Hibernate built-in connection pool — not for production use) — сигнал, что JPA-путь использует встроенный пул, а не HikariCP.
В реальном Spring Boot + Hibernate проекте HikariCP настраивается как provider для самого Hibernate (spring.datasource.hikari.* / hibernate.connection.provider_class) — тогда пул общий для всего доступа к данным. В этом стенде два контура намеренно разделены: HikariCP-демо показывает поведение пула изолированно, не смешивая его с JPA-путём, у которого своя (дефолтная, непригодная для прода) стратегия соединений.
Про транзакции и уровни изоляции на PostgreSQL конкретно — SELECT FOR UPDATE, SERIALIZABLE с retry на код ошибки 40001, advisory locks — отдельный, более глубокий разбор с нагрузочным стендом: «Транзакции в реляционных БД на практике». Здесь — только то, что напрямую касается пула и доступа к данным: JDBC по умолчанию открывает транзакцию неявно на каждый Statement/PreparedStatement (autocommit=true), JPA — привязывает транзакцию к границам EntityManager (RESOURCE_LOCAL в этом стенде, EntityTransaction/@Transactional в Spring), jOOQ — работает поверх переданного Connection, транзакционные границы — забота вызывающего кода.
Ловушка: Hibernate 7 и Jakarta Persistence 3.2
Отдельная тонкость, которую стоит задокументировать явно — она не гипотетическая, а реально воспроизведена при сборке этого стенда.
Родительский POM java-deep-dive импортирует spring-boot-dependencies:3.5.3 BOM (нужен для другого модуля, spring-vs-quarkus) — эта BOM управляет версией jakarta.persistence-api и пинует её на 3.1.0, рассчитанную на Hibernate 6.x. Hibernate 7.0.2.Final требует Jakarta Persistence 3.2 — классы вроде PersistenceUnitTransactionType появились только в этой версии спецификации. Без явного оверрайда EntityManagerFactory падает прямо на старте:
NoClassDefFoundError: jakarta/persistence/PersistenceUnitTransactionTypeФикс — явная версия зависимости прямо в pom.xml модуля: прямое указание версии на <dependency> всегда сильнее версии, унаследованной из dependencyManagement родительского BOM.
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
<version>3.2.0</version>
</dependency>Если вы подключаете Hibernate 7 в проект, где уже есть Spring Boot BOM (а таких проектов много — Spring Boot 3.5.x по умолчанию тянет Hibernate 6.6), эта проблема ждёт вас на первом же старте контекста. Проверить версию, которая реально резолвится, можно через mvn dependency:tree -Dincludes=jakarta.persistence:jakarta.persistence-api до того, как это всплывёт в рантайме.
Spring Data: репозитории поверх JPA
Spring Data JPA не добавляет новый способ доступа к данным — это слой поверх JPA/Hibernate, который убирает шаблонный код репозиториев. Вместо ручного EntityManager и JPQL-запросов — интерфейс с производными методами:
public interface AuthorRepository extends JpaRepository<Author, Long> {
List<Author> findByNameContainingIgnoreCase(String namePart);
@EntityGraph(attributePaths = "books")
List<Author> findAll(); // тот же фикс N+1, но декларативно на уровне репозитория
}Всё, что верно для N+1 и JOIN FETCH/@EntityGraph выше, остаётся верным — Spring Data не отменяет проблему, а даёт для неё более короткий синтаксис (@EntityGraph на методе репозитория вместо ручного построения графа в коде, как в entityGraphDemo этой статьи). Деривация запросов из имени метода (findByNameContainingIgnoreCase) удобна для простых фильтров и превращается в нечитаемую простыню для сложных условий — на этой границе разумнее переключиться на @Query с явным JPQL или вовсе на jOOQ/JDBC для конкретного запроса.
Как Spring Boot вообще собирает DI-контейнер, автоконфигурацию и стартеры вроде spring-boot-starter-data-jpa — отдельная тема, за деталями DI и автоконфигурации — в «Spring Boot вглубь».
Миграции схемы (Flyway, Liquibase) — отдельный, обязательный в реальном проекте слой: в этом стенде SchemaInit пересоздаёт схему (DROP+CREATE) при каждом запуске ради идемпотентности демо, что для прода, конечно, неприемлемо. Managed-миграции с версионированием — тема, достойная отдельного разбора, здесь не раскрыта.
Про то, как тот же самый выбор (ORM / query-builder / raw SQL) выглядит в других языках — Go, Python, Rust, C# — и почему конкретно N+1 и «магия» ORM не уникальны для JVM, смотрите «Доступ к данным в разных языках»Скоро. Про производственную надёжность самого соединения с PostgreSQL из Go/Java/Rust (пулы, реплики, reconnect, failover) — отдельная статья «Надёжная работа с PostgreSQL из Go, Java и Rust», которая ближе стоит к HikariCP-разделу здесь, чем к ORM-слою.
Когда что выбирать
Прагматика, а не догма:
- Сырой JDBC /
JdbcTemplate— узкий горячий путь, батчи, отчёты, миграции данных, места, где нужен полный контроль над конкретным SQL и минимум абстракций между кодом и запросом, который реально уйдёт в БД. - JPA/Hibernate — доменная модель со сложными связями, где объектное представление реально экономит код (CRUD-приложения, богатая доменная логика поверх сущностей), и где команда готова платить за это дисциплиной по N+1, кешам и границам транзакций. Не выбор по умолчанию просто потому, что «так принято» — выбор под задачу, где объектная модель окупается.
- jOOQ — когда нужен SQL как первоклассный код (с типобезопасностью, автокомплитом, рефакторингом), но без непрозрачности ORM: результат — records, а не граф связанных сущностей с ленивой загрузкой. Особенно уместен, когда команда и так думает в терминах SQL, а не объектного графа.
- Spring Data — не альтернатива, а обвязка поверх JPA (или jOOQ/MongoDB через отдельные модули): экономит boilerplate репозиториев там, где сложность запросов укладывается в деривацию или
@Query.
Ни один из подходов не отменяет необходимости понимать, какой именно SQL уходит в БД. ORM экономит код написания запроса, но не снимает ответственность за его цену — в чём и был весь смысл раздела про N+1 выше.
Документация и первоисточники
- Стенд:
digital-cookbook/java/deep-dive/data-access(JDBC / JPA-Hibernate / jOOQ на живом Postgres, схемаauthors1:Nbooks, демонстрация N+1 и его устранения, HikariCP;run.shсобирает и прогоняет внутри контейнера на сети compose) - Hibernate ORM
- jOOQ
- HikariCP — «About Pool Sizing»
- Spring Data
- Kotlin Exposed
Комментарии