Работа с данными: JDBC, JPA/Hibernate, транзакции, N+1 и другие ловушки
Всё, что мы делали до сих пор, жило внутри одного процесса. Объект создавался, менялся,
умирал вместе с JVM. Настоящее приложение устроено иначе: процесс временный, данные
вечные. Между вашим Order в heap и строкой в таблице лежит пропасть — разные модели
данных, разные модели времени, разные модели отказа. Java-экосистема закрывает эту пропасть
лучше почти любой другой, и именно поэтому там столько мест, где можно провалиться.
Формулировка задачи, которую решает весь этот слой: как выразить бизнес-операцию так, чтобы она была атомарной, видела согласованные данные, не держала базу дольше необходимого и при этом читалась как обычный код. Всё остальное — детали механизма.
Ключевое отличие Java от того, что вы могли видеть в динамических языках: здесь тяжёлый слой ORM выиграл индустриальный спор двадцать лет назад, и знать его устройство обязательно даже если вы предпочитаете писать SQL руками. Hibernate не «магия» — это очень конкретная машина с очень конкретными правилами, и почти все жалобы вида «ORM тормозит» на деле означают «я не знаю, что именно он сделал».
Модель исполнения: кто что делает
Прежде чем писать код, зафиксируем участников. Хорошая новость: слоёв немного, и они честно разделены.
- JDBC (
java.sql) — стандартный API драйверов. ОпределяетConnection,PreparedStatement,ResultSet, транзакции, метаданные. Это фундамент: всё остальное в итоге вызывает JDBC. - Драйвер — реализация JDBC для конкретной СУБД (pgjdbc, MySQL Connector/J, Oracle thin). Говорит по бинарному протоколу, переводит Java-типы в типы БД.
- Пул соединений — обёртка над
DataSource, которая переиспользует физические соединения. Де-факто стандарт — HikariCP. - JPA (Jakarta Persistence) — спецификация ORM: аннотации,
EntityManager, JPQL. Реализация по умолчанию почти везде — Hibernate ORM. - Менеджер транзакций — то, что связывает границу метода с
commit/rollback. В Spring этоPlatformTransactionManagerи аннотация@Transactional.
Сравнение с .NET, чтобы сразу расставить якоря: JDBC ≈ ADO.NET, Hibernate ≈ EF Core,
HikariCP ≈ встроенный пул SqlConnection, Flyway ≈ EF Migrations. Разница в том, что в Java
эти части разных вендоров и меняются независимо — это и гибкость, и источник
несовместимостей версий.
JDBC: слой, который надо знать даже если вы его не пишете
Начнём снизу. Голый JDBC — это пятнадцать строк вместо одной, зато в них не спрятано ничего.
// Ручной JDBC: каждый шаг виден. Обратите внимание на try-with-resources —
// ресурсы закрываются в обратном порядке: rs, потом ps, потом conn (возврат в пул).
public Optional<OrderRow> findById(long id) throws SQLException {
var sql = """
SELECT id, customer_id, status, total_amount, created_at
FROM orders
WHERE id = ?
""";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id); // параметр, а не конкатенация строк
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return Optional.empty();
return Optional.of(new OrderRow(
rs.getLong("id"),
rs.getLong("customer_id"),
OrderStatus.valueOf(rs.getString("status")),
rs.getBigDecimal("total_amount"), // деньги — только BigDecimal
rs.getObject("created_at", OffsetDateTime.class))); // не getTimestamp!
}
}
}
Три вещи здесь принципиальны.
PreparedStatement, а не Statement. Дело не только в SQL-инъекциях (хотя это главное).
Подготовленный запрос отправляется в БД с плейсхолдерами, и сервер может переиспользовать
план выполнения. Строковая конкатенация даёт уникальный текст запроса на каждый вызов —
кэш планов забивается мусором. В PostgreSQL включите prepareThreshold (по умолчанию 5:
после пяти выполнений драйвер переводит запрос в server-side prepared).
Типы даты и времени. rs.getObject("created_at", OffsetDateTime.class) — правильный
современный способ (JDBC 4.2). Устаревший getTimestamp() возвращает java.sql.Timestamp,
который молча применяет часовой пояс JVM. Классическая продовая авария: приложение
переехало в контейнер с UTC, а разработка была в Europe/Moscow — все временные метки
поехали на три часа. Правило: в БД колонка timestamptz, в Java OffsetDateTime или
Instant, часовой пояс JVM явно выставлен в UTC (-Duser.timezone=UTC).
try-with-resources обязателен. Незакрытый Connection не возвращается в пул. Через
maximumPoolSize таких утечек приложение встаёт намертво с
Connection is not available, request timed out after 30000ms.
Батчи: одна главная оптимизация JDBC
Тысяча одиночных INSERT — это тысяча сетевых round-trip. При RTT в 0,5 мс это 500 мс
чистого ожидания. Батч склеивает их в пачки.
// Вставка 50 000 позиций: с батчем — единицы секунд, без него — минуты.
public void insertItems(Connection conn, long orderId, List<Item> items) throws SQLException {
var sql = "INSERT INTO order_item(order_id, sku, qty) VALUES (?, ?, ?)";
conn.setAutoCommit(false); // без этого каждый батч = отдельная транзакция
try (var ps = conn.prepareStatement(sql)) {
int n = 0;
for (var item : items) {
ps.setLong(1, orderId);
ps.setString(2, item.sku());
ps.setInt(3, item.qty());
ps.addBatch();
if (++n % 500 == 0) ps.executeBatch(); // сбрасываем пачками, чтобы не расти в памяти
}
ps.executeBatch(); // хвост
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
}
}
Важная деталь, которую пропускают: драйвер должен уметь склеивать батч на уровне протокола.
Для PostgreSQL нужен параметр URL reWriteBatchedInserts=true — тогда pgjdbc превратит пачку
INSERT ... VALUES (?) в один многострочный INSERT ... VALUES (?),(?),(?). Для MySQL —
rewriteBatchedStatements=true. Без этих флагов addBatch() даёт заметно меньший выигрыш,
чем вы ожидаете.
Потоковое чтение: как не положить heap одним SELECT
По умолчанию драйвер вычитывает весь ResultSet в память клиента. SELECT * FROM events
на таблице в 50 млн строк — гарантированный OutOfMemoryError. Чтобы читать курсором:
conn.setAutoCommit(false); // pgjdbc игнорирует fetchSize при autoCommit = true!
try (var ps = conn.prepareStatement("SELECT id, payload FROM events WHERE ts >= ?",
ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
ps.setFetchSize(1_000); // серверный курсор, по 1000 строк за round-trip
ps.setObject(1, from);
try (var rs = ps.executeQuery()) {
while (rs.next()) { handle(rs.getLong(1), rs.getString(2)); }
}
}
Условие «autoCommit = false» для PostgreSQL — самая частая причина, почему setFetchSize
«не работает». У MySQL другой рецепт: setFetchSize(Integer.MIN_VALUE) на forward-only
read-only запросе.
Пул соединений: почему он должен быть маленьким
Соединение с БД — дорогой объект: TCP-сокет, аутентификация, у PostgreSQL ещё и отдельный серверный процесс. Пул держит их открытыми и раздаёт в аренду.
Контринтуитивный факт: большой пул делает систему медленнее. Если у базы 8 ядер, то
64 одновременных запроса не выполняются в 64 раза быстрее — они конкурируют за CPU, диск и
блокировки, растёт время контекстных переключений и вероятность дедлоков. Рекомендация из
HikariCP About Pool Sizing:
connections = (число_ядер_БД × 2) + число_независимых_дисков. Для типичного сервиса
это 10–20 соединений, а не 200.
# application.yml — минимальный продовый набор
spring:
datasource:
url: jdbc:postgresql://db:5432/shop?reWriteBatchedInserts=true&ApplicationName=shop-api
username: shop
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 16 # считаем от ядер БД, а не от числа потоков приложения
minimum-idle: 16 # держим фиксированный пул: избегаем «прогрева» на пике
connection-timeout: 3000 # лучше быстро упасть, чем ждать 30 секунд
max-lifetime: 1740000 # 29 мин: меньше, чем таймаут БД/файрвола (обычно 30 мин)
leak-detection-threshold: 20000 # лог со стектрейсом, если соединение не вернули за 20 с
validation-timeout: 2000
pool-name: shop-pool
connection-timeout: 3000 — недооценённая настройка. Дефолтные 30 секунд означают, что при
исчерпании пула ваши потоки будут висеть полминуты, копя очередь; сервис умрёт медленно и
непонятно. Три секунды превращают это в честный быстрый отказ, который увидит circuit
breaker.
Обратите внимание на состояние Leaked: это не гипотетика, а самая частая причина
«приложение работало неделю и встало». Именно поэтому leak-detection-threshold должен быть
включён в проде.
Спектр инструментов: не только JPA
Прежде чем нырять в Hibernate, честно перечислим альтернативы. Выбор здесь — архитектурное решение, а не вопрос вкуса.
| Инструмент | Что даёт | Цена | Когда брать |
|---|---|---|---|
| Голый JDBC | полный контроль, ноль магии | много кода на маппинг | утилиты, миграции данных, hot path |
JdbcTemplate / JdbcClient (Spring 6.1+) |
закрывает ресурсы, маппит строки | SQL пишете сами | CRUD-сервисы, отчёты, CQRS-чтение |
| JDBI 3 | декларативные SQL-объекты | ещё одна библиотека | когда нужен SQL, но не хочется boilerplate |
| jOOQ | типобезопасный SQL DSL из схемы БД | генерация кода; коммерческая лицензия для Oracle/SQL Server | сложные запросы, аналитика, «SQL-first» команды |
| Spring Data JDBC | агрегаты без ленивости и dirty checking | нет ленивых связей, дети перезаписываются | DDD-агрегаты умеренного размера |
| JPA/Hibernate | объектный граф, кэш, dirty checking | нужно понимать модель исполнения | классические доменные модели с богатыми связями |
Практическое правило зрелых команд: JPA для записи (там, где нужны инварианты агрегата и оптимистичная блокировка), SQL-инструмент для чтения (списки, отчёты, экраны). Это дешёвая версия CQRS, и она снимает 80 % проблем производительности ORM. Подробнее о самом паттерне — в CQRS и event sourcing.
Модель данных: пример, на котором всё разберём
Заметьте version в ORDERS — колонка оптимистичной блокировки, к ней вернёмся. И
timestamptz, а не timestamp: временна́я зона хранится в БД, а не додумывается клиентом.
JPA/Hibernate: persistence context как модель исполнения
Всё поведение Hibernate выводится из одной структуры — persistence context (в терминах
Hibernate — Session, в терминах JPA — EntityManager). Это реализация паттернов Identity
Map и Unit of Work из PoEAA Фаулера.
Три следствия, из которых вырастает вся идиоматика:
- Одна сущность = один объект в пределах контекста.
em.find(Order.class, 42)дважды вернёт ту же ссылку, второй раз без SQL. Отсюда же правило: сравнивать managed-сущности можно через==, но только внутри одного контекста. - Изменения не пишутся сразу. Вы вызвали сеттер — Hibernate ничего не знает. На
flushон сравнивает текущее состояние со снимком (dirty checking) и генерируетUPDATE. - Контекст должен быть коротким. Он держит в heap и объекты, и их снимки. Загрузили 100 000 сущностей — израсходовали вдвое больше памяти и получили flush, который сравнивает миллионы полей.
Стрелка Detached --> Managed подписана капсом не случайно: merge() не делает переданный
объект управляемым, он копирует его состояние в новый managed-экземпляр и возвращает его.
Код em.merge(order); order.setStatus(PAID); не сохранит статус — менять надо
возвращённое значение.
Сущность: как её писать в 2026 году
@Entity
@Table(name = "orders")
public class Order {
@Id
// SEQUENCE, а не IDENTITY: IDENTITY заставляет Hibernate делать INSERT
// немедленно при persist() и полностью отключает батчинг вставок.
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "orders_seq", allocationSize = 50)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false) // ЯВНО LAZY: по спецификации ToOne — EAGER!
@JoinColumn(name = "customer_id")
private Customer customer;
@Enumerated(EnumType.STRING) // НИКОГДА не ORDINAL: вставили значение в середину enum — поехали все данные
@Column(nullable = false, length = 32)
private OrderStatus status;
@Column(name = "total_amount", nullable = false, precision = 19, scale = 2)
private BigDecimal totalAmount;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Version // оптимистичная блокировка: Hibernate сам добавит WHERE version = ?
private int version;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<OrderItem> items = new LinkedHashSet<>(); // Set, а не List — см. MultipleBagFetchException
protected Order() { } // обязательный конструктор без аргументов (может быть protected)
// Двусторонняя связь синхронизируется В КОДЕ. Hibernate этого не делает за вас.
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
// equals/hashCode для сущности с генерируемым id: постоянный hashCode + сравнение по id.
// Иначе объект «теряется» в HashSet после того, как persist() назначит id.
@Override public boolean equals(Object o) {
return o instanceof Order other && id != null && id.equals(other.id);
}
@Override public int hashCode() { return getClass().hashCode(); }
}
Разберём решения, которые здесь неочевидны.
record не может быть сущностью. Ему нужен неизменяемый набор полей, а JPA требует
конструктор без аргументов, не-final класс и изменяемые поля — Hibernate проксирует сущность
для ленивой загрузки. Зато record отлично работает как проекция и, начиная с
Hibernate 6, как @Embeddable-компонент. Про records подробно —
в ООП в Java.
allocationSize = 50. Hibernate использует pooled-оптимизатор: берёт из
последовательности одно значение и раздаёт 50 идентификаторов без обращения к БД. Дефолтный
allocationSize = 1 даёт SELECT nextval(...) на каждую вставку.
Set вместо List для коллекций. С двумя List-коллекциями в одной сущности запрос с
двумя JOIN FETCH упадёт с MultipleBagFetchException, потому что Hibernate не может
восстановить порядок элементов из декартова произведения. Set (без @OrderColumn) этой
проблемы не имеет.
hashCode(), возвращающий константу класса. Выглядит дико, но это единственный корректный
вариант для сущностей с генерируемым id: hashCode обязан быть неизменным всё время жизни
объекта, а id появляется только после persist(). Альтернатива — натуральный
бизнес-ключ (например, email у клиента), если он есть и не меняется. Развёрнутое
обоснование — у Влада Михалчи.
Транзакции: где на самом деле проходит граница
Транзакция — это не BEGIN/COMMIT, а утверждение о том, что откатывается целиком.
В Spring она задаётся аннотацией, и здесь начинается самое опасное место всего трека, потому
что аннотация выглядит как заклинание, а работает как прокси.
@Service
public class OrderService {
private final OrderRepository orders;
private final PaymentGateway payments;
private final OutboxRepository outbox;
// Границы транзакции = границы этого метода. Всё внутри — атомарно.
@Transactional
public OrderId place(PlaceOrderCommand cmd) {
var customer = customers.getReference(cmd.customerId()); // без SELECT: только прокси для FK
var order = Order.draft(customer, clock.instant());
cmd.lines().forEach(l -> order.addItem(OrderItem.of(l.sku(), l.qty(), l.price())));
orders.save(order);
// Внешний вызов ВНУТРИ транзакции — так делать нельзя (см. ниже).
// Вместо этого кладём событие в outbox: та же транзакция, никакой сети.
outbox.save(OutboxMessage.of("order.placed", order.getId()));
return new OrderId(order.getId());
}
// Чтение: readOnly даёт Hibernate право пропустить dirty checking и снимки,
// а PostgreSQL — начать READ ONLY транзакцию (может уйти на реплику).
@Transactional(readOnly = true)
public OrderView view(long id) {
return orders.findViewById(id).orElseThrow(() -> new OrderNotFound(id));
}
}
Обратите внимание на шаг 6: соединение берётся лениво, при первом SQL, и держится до конца транзакции. Отсюда главное правило:
Внутри
@Transactionalне должно быть сетевых вызовов, отправки писем, обращений к Kafka и любых операций с непредсказуемой длительностью. Каждая секунда HTTP-таймаута — это секунда, на которую занято соединение из пула размером 16.
Правильный ответ на «нужно и сохранить, и уведомить» — transactional outbox: пишем событие в ту же БД в той же транзакции, а отдельный процесс его отправляет. Гарантии доставки и идемпотентность разобраны в Идемпотентность и доставка.
Четыре ловушки @Transactional, на которых спотыкаются все
1. Self-invocation. Spring реализует @Transactional через прокси. Вызов this.method()
идёт мимо прокси — аннотация не работает, транзакции нет.
@Service
public class ReportService {
@Transactional
public void generateAll(List<Long> ids) {
for (var id : ids) generateOne(id); // ← @Transactional на generateOne ИГНОРИРУЕТСЯ
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void generateOne(long id) { /* ... */ }
}
Лечится вынесением метода в другой бин (правильно) или самоинъекцией через @Lazy
(некрасиво, но работает). Аналогично не работает аннотация на private, final и
static методах.
2. Правило отката по умолчанию. Spring откатывает транзакцию только на
RuntimeException и Error. Проверяемое исключение commit’ит транзакцию. Это наследие
EJB и постоянный источник «частично применённых» операций:
@Transactional(rollbackFor = Exception.class) // если в проекте есть checked-исключения — пишите явно
public void transfer(...) throws InsufficientFundsException { ... }
Про то, почему checked-исключений в современном коде лучше избегать вовсе, — в Идиоматика и обработка ошибок.
3. Пойманное исключение внутри REQUIRED. Если внутренний метод с
propagation = REQUIRED бросил исключение, Spring помечает транзакцию
rollback-only. Вы поймали исключение и продолжили — а на COMMIT получаете
UnexpectedRollbackException: Transaction silently rolled back. Хотите изолировать сбой —
нужен REQUIRES_NEW.
4. REQUIRES_NEW в цикле = дедлок пула. Внешняя транзакция держит соединение, внутренняя
просит второе. Двадцать параллельных запросов при пуле в 16 — и все потоки ждут соединения,
которое никто не отпустит. Правило: на один поток — одно соединение; REQUIRES_NEW
только там, где вы посчитали пул.
Уровни изоляции
JPA принимает @Transactional(isolation = ...), но выбирать уровень нужно, понимая
аномалии конкретной СУБД: PostgreSQL реализует REPEATABLE READ через snapshot isolation и
не даёт phantom read, а MySQL InnoDB на том же уровне ведёт себя иначе. Полный разбор
аномалий, MVCC и практики выбора —
в Транзакции и изоляция.
Практика по умолчанию: READ COMMITTED + оптимистичная блокировка через @Version.
N+1: диагноз, механизм, лечение
Самая известная патология ORM. Механизм прост: вы загрузили N сущностей одним запросом, а потом обратились к ленивой связи каждой — Hibernate честно сходил в базу N раз.
// Выглядит невинно. Работает как 1 + N запросов.
@Transactional(readOnly = true)
public List<OrderSummary> recent() {
return orders.findTop50ByOrderByCreatedAtDesc().stream() // 1 запрос: SELECT ... FROM orders
.map(o -> new OrderSummary(
o.getId(),
o.getCustomer().getName(), // +50 запросов: SELECT ... FROM customer
o.getItems().size())) // +50 запросов: SELECT ... FROM order_item
.toList();
}
Лог покажет ровно это:
Hibernate: select o1_0.id, ... from orders o1_0 order by o1_0.created_at desc limit ?
Hibernate: select c1_0.id, c1_0.email, c1_0.name from customer c1_0 where c1_0.id=?
Hibernate: select c1_0.id, c1_0.email, c1_0.name from customer c1_0 where c1_0.id=?
... (× 50)
Hibernate: select i1_0.order_id, i1_0.id, ... from order_item i1_0 where i1_0.order_id=?
... (× 50)
101 запрос вместо одного. На локальной базе с RTT 0,05 мс это незаметно; в проде с RTT 1,5 мс это 150 мс на ровном месте — и линейно растёт с числом строк.
или БД под нагрузкой"] --> B["Включить учёт запросов:
hibernate.generate_statistics
+ datasource-proxy на тесте"] B --> C{"Запросов на один
сценарий больше 10?"} C -- нет --> D["Это не N+1.
Взять самый долгий запрос
и посмотреть EXPLAIN ANALYZE"] C -- да --> E{"Много одинаковых запросов,
отличающихся параметром?"} E -- нет --> F["Скорее всего лишние flush
или сохранение по одному.
Смотреть батчинг"] E -- да --> G{"Связь нужна ВСЕГДА
в этом сценарии?"} G -- да --> H["JOIN FETCH или @EntityGraph
в конкретном методе репозитория"] G -- иногда --> I["default_batch_fetch_size=100:
N запросов схлопнутся в N/100
через WHERE id IN (...)"] H --> J{"Нужна пагинация
вместе с коллекцией?"} J -- да --> K["Два запроса: сначала страница ID,
потом JOIN FETCH WHERE id IN (:ids)"] J -- нет --> L["Готово"] I --> L K --> L D --> M["Индексы и план запроса —
это уже трек про базы данных"]
Инструменты лечения
@EntityGraph — точечно и декларативно:
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findTop50ByOrderByCreatedAtDesc();
// JPQL с явным fetch: то же самое, но виден SQL
@Query("select distinct o from Order o join fetch o.items i join fetch i.product where o.id = :id")
Optional<Order> findWithItems(@Param("id") long id);
}
default_batch_fetch_size — глобально и почти бесплатно:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # ленивые связи грузятся пачками: WHERE id IN (?, ?, ... )
jdbc.batch_size: 50
order_inserts: true
order_updates: true
Это первая настройка, которую стоит включить в любом проекте на Hibernate: она превращает
1+N в 1+ceil(N/100) без единой правки кода. Она не отменяет JOIN FETCH там, где связь
нужна всегда, но снимает хвост случайных N+1.
DTO-проекция — лучший вариант для экранов. Если вы не собираетесь менять сущности, не грузите их вовсе:
// record как проекция: Hibernate заполнит его через конструктор, сущности не создаются
public record OrderSummary(Long id, String customerName, long itemCount) { }
@Query("""
select new com.shop.OrderSummary(o.id, c.name, count(i))
from Order o join o.customer c left join o.items i
group by o.id, c.name
order by o.createdAt desc
""")
List<OrderSummary> summaries(Pageable page);
Нет управляемых сущностей — нет снимков, нет dirty checking, нет ленивых связей, нет N+1. Для сложных чтений это часто вдвое-втрое быстрее и всегда предсказуемее.
Родственные патологии
Декартово произведение. join fetch o.items + join fetch o.payments вернёт
|items| × |payments| строк. Hibernate соберёт объекты правильно, но по сети приедет мусор.
Лечение: две коллекции — два запроса (второй использует уже прогретый контекст).
Пагинация с коллекцией. JOIN FETCH + Pageable даёт предупреждение
HHH000104 (Hibernate 5) / HHH90003004 (Hibernate 6): firstResult/maxResults specified
with collection fetch; applying in memory. Hibernate вытянет все заказы в память и
нарежет страницу там. При росте таблицы это отказ сервиса. Лечение — двухзапросная
стратегия из блока K на схеме выше.
count(*) на каждый список. Page<T> делает второй запрос для общего числа. На больших
таблицах он дороже основного. Если UI не показывает «страница 3 из 4172», используйте
Slice<T> — он читает limit + 1 и не считает всё.
Остальные грабли Hibernate
LazyInitializationException. Обратились к ленивой связи после закрытия контекста —
исключение. Spring Boot по умолчанию включает Open Session In View
(spring.jpa.open-in-view: true), который держит контекст открытым до конца рендеринга
ответа и маскирует проблему. Цена — соединение занято на всё время сериализации, а N+1
происходит в web-слое, где вы его не увидите. Выключайте: spring.jpa.open-in-view: false.
После этого все LazyInitializationException вылезут сразу — и это хорошо: вы явно решите,
что грузить.
save() делает лишний SELECT. JpaRepository.save() вызывает persist() для новой
сущности и merge() для остальных, а «новизну» определяет по null в @Id. Если id
назначаете вы (UUID из кода), Hibernate считает сущность существующей и делает SELECT
перед каждым INSERT. Лечение — реализовать Persistable<UUID> с честным isNew(), либо
хранить флаг «новая» в самой сущности.
Двусторонние связи не синхронизируются сами. order.getItems().add(item) без
item.setOrder(order) даст null в FK: владеющая сторона — та, где @JoinColumn. Метод
addItem() из примера выше — не украшение, а необходимость.
list.clear(); list.addAll(newItems); на коллекции с orphanRemoval порождает
DELETE всех строк и INSERT всех заново. Меняйте коллекцию точечно.
Ленивый @OneToOne на не-владеющей стороне не работает. Hibernate не может создать прокси,
не зная, есть ли строка вообще, — и делает SELECT всегда. Обходится либо разделением на
@ManyToOne с уникальным индексом, либо байткод-энхансментом.
toString(), включающий коллекции. Логирование сущности внезапно подгружает весь граф.
Генерируйте toString только по скалярным полям.
Кэш первого уровня растёт в цикле. Массовая вставка без flush() + clear() каждые N
итераций съест heap. Для по-настоящему массовых операций используйте StatelessSession
(нет контекста, нет кэша, нет каскадов) или обычный JDBC-батч.
Кэш второго уровня. Разделяемый между транзакциями кэш сущностей (Ehcache, Infinispan,
Redis). Полезен для маленьких редкоизменяемых справочников. Опасен для всего остального:
инвалидация в кластере — тяжёлая распределённая задача, а прямые UPDATE мимо Hibernate
делают кэш неверным молча. Начинайте без него.
Блокировки: оптимистичная и пессимистичная
Оптимистичная (@Version) — рабочая лошадка. Hibernate добавляет WHERE version = ? в
UPDATE; если обновилось 0 строк, значит кто-то опередил, и летит
OptimisticLockException. Стоимость — ноль блокировок в БД; цена — нужно уметь
повторять операцию.
// Повтор при конфликте версий. Транзакция ДОЛЖНА быть внутри повтора, а не снаружи.
@Retryable(retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 3, backoff = @Backoff(delay = 50, multiplier = 2, random = true))
@Transactional
public void applyDiscount(long orderId, Percent p) {
var order = orders.findById(orderId).orElseThrow();
order.applyDiscount(p); // инвариант проверяется в домене
} // COMMIT: UPDATE orders SET ..., version = version + 1 WHERE id = ? AND version = ?
Пессимистичная нужна там, где конфликты — норма, а не исключение: очереди задач,
резервирование остатков. Здесь бесценен SKIP LOCKED:
public interface JobRepository extends JpaRepository<Job, Long> {
// FOR UPDATE SKIP LOCKED: каждый воркер забирает СВОИ строки и не ждёт чужие.
// Это самый дешёвый способ сделать надёжную очередь в PostgreSQL без брокера.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2")) // -2 = SKIP_LOCKED
@Query("select j from Job j where j.status = 'READY' order by j.createdAt")
List<Job> claimBatch(Pageable page);
}
Правила пессимистичных блокировок: захватывать в одинаковом порядке во всех местах кода
(иначе дедлок), держать как можно короче, и всегда ставить таймаут ожидания
(lock_timeout в PostgreSQL), чтобы зависшая транзакция не парализовала систему.
Миграции схемы
spring.jpa.hibernate.ddl-auto: update — удобно на ноутбуке и запрещено в проде: Hibernate
не удаляет колонки, не переименовывает, не умеет откатываться и не воспроизводим между
версиями. В проде — только явные миграции.
-- db/migration/V7__add_order_version.sql (Flyway)
-- Expand-фаза: добавляем колонку с дефолтом, старый код продолжает работать.
ALTER TABLE orders ADD COLUMN version integer NOT NULL DEFAULT 0;
-- Индекс без блокировки таблицы — на проде обязательно CONCURRENTLY.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_created
ON orders (customer_id, created_at DESC);
Практика: Flyway для SQL-first команд,
Liquibase там, где нужны абстрактные changelog’и и откаты.
В проде обязательно ddl-auto: validate — Hibernate сверит маппинг со схемой на старте и
упадёт при расхождении, а не в рантайме на первом запросе.
Совместимость релизов делается по схеме expand → migrate → contract: сначала добавили колонку, потом выкатили код, который её пишет и читает, и только следующим релизом удалили старую. Иначе rolling-деплой сломает половину подов.
Массовые операции и цифры
Порядки величин, которые полезно держать в голове (PostgreSQL, RTT ~0,5 мс, вставка 100 000 строк):
| Способ | Round-trip | Время | Комментарий |
|---|---|---|---|
save() в цикле, IDENTITY |
100 000 | ~60 с | батчинг невозможен в принципе |
save() в цикле, SEQUENCE, без batch_size |
100 000 | ~55 с | генерация id пачками помогает мало |
saveAll + batch_size=50 + order_inserts |
~2 000 | ~4 с | штатный режим Hibernate |
JDBC-батч + reWriteBatchedInserts |
~200 | ~1,2 с | драйвер склеивает VALUES |
COPY (pgjdbc CopyManager) |
1 поток | ~0,4 с | для загрузки данных вне транзакционной логики |
Вывод не «Hibernate медленный», а «Hibernate решает другую задачу». Для ETL и массовых
загрузок берите COPY/LOAD DATA; для доменных операций разница между вариантами 3 и 4
редко имеет значение.
Массовое обновление через JPQL обходит контекст и потому требует осторожности:
// Один UPDATE вместо загрузки миллиона сущностей. Но: persistence context НЕ узнает
// об изменениях, поэтому очищаем его явно, иначе будете работать с устаревшими объектами.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update Order o set o.status = 'ARCHIVED' where o.createdAt < :before")
int archiveOlderThan(@Param("before") OffsetDateTime before);
JDBC и виртуальные потоки
Виртуальные потоки (см. Конкурентность) меняют картину меньше, чем хотелось бы. Блокирующий JDBC-вызов на сокете не пинит несущий поток — и это отличная новость: тысяча виртуальных потоков, ждущих БД, стоит килобайты. Но два ограничения остаются:
- Пул соединений — по-прежнему потолок. Виртуальных потоков может быть миллион, соединений — 16. Пул превращается в семафор, что, вообще говоря, правильно: базу нельзя перегружать. Просто не ждите, что виртуальные потоки ускорят работу с БД.
synchronizedв драйверах. До JDK 24 (JEP 491) блокировка внутриsynchronizedпинила несущий поток. Многие JDBC-драйверы использовалиsynchronizedвнутри — pgjdbc перевели наReentrantLock(версия 42.5.1+), но старые драйверы и обёртки могут деградировать. Проверяйте-Djdk.tracePinnedThreads=fullна JDK 21–23.
Механизм Spring, связывающий транзакцию с потоком (TransactionSynchronizationManager на
ThreadLocal), с виртуальными потоками работает корректно — ThreadLocal у каждого
виртуального потока свой.
Чем это всё измерять
- Считать запросы в тестах. Самый ценный инструмент: тест, который падает, когда сценарий начал делать больше SQL, чем раньше.
@Test
void листингЗаказовДелаетРовноДваЗапроса() {
var stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.clear();
orderService.recent();
// Регрессия N+1 ловится здесь, а не на проде.
assertThat(stats.getPrepareStatementCount()).isEqualTo(2);
}
Настройка окружения с настоящим PostgreSQL в контейнере — в Тестировании.
hibernate.generate_statistics: trueплюсhibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 100— лог медленных запросов с указанием места.- datasource-proxy или
p6spy — логирование SQL вместе с подставленными
параметрами (штатный
show-sqlпечатает?). EXPLAIN (ANALYZE, BUFFERS)на подозрительном запросе. Это уже территория индексов и планов запросов.- Метрики HikariCP (
hikaricp.connections.pending,.usage,.acquire) в Micrometer: растущая очередь ожидания — ранний признак того, что транзакции стали длиннее.
Типичные ошибки — сводный список
- Незакрытый
Connection/ResultSet— медленная смерть пула. getTimestamp()вместоgetObject(..., OffsetDateTime.class)иtimestampвместоtimestamptzв схеме.doubleдля денег вместоBigDecimalс явнымиprecision/scale.maximumPoolSize: 200«чтобы точно хватило» — деградация базы вместо ускорения.connection-timeoutпо умолчанию 30 с — сервис умирает медленно и незаметно.@Transactionalна приватном методе или вызов черезthis— транзакции просто нет.- Checked-исключение внутри
@TransactionalбезrollbackFor— commit вместо отката. - HTTP-вызов, отправка письма или
Thread.sleepвнутри транзакции. FetchType.EAGER(в том числе дефолтный для@ManyToOne) — граф грузится всегда.spring.jpa.open-in-view: true— N+1 переезжает в сериализацию и становится невидимым.@EnumeratedбезEnumType.STRING— данные ломаются при изменении порядка констант.GenerationType.IDENTITYпри массовых вставках — батчинг отключён полностью.- Два
List-JOIN FETCH—MultipleBagFetchException; дваSet-fetch — декартово произведение. Pageableвместе сJOIN FETCHколлекции — пагинация в памяти.merge()с расчётом, что переданный объект станет управляемым.equals/hashCodeпо всем полям или по автогенерируемомуid— сущность «теряется» вHashSet.ddl-auto: updateв проде.- Загрузка 500 000 сущностей в один контекст без
flush()/clear(). @Modifying-запрос безclearAutomatically— контекст хранит устаревшие данные.- Ловля
OptimisticLockExceptionснаружи транзакции — повторяется мёртвая транзакция.
Честно о месте Java в работе с данными
Где выигрывает. Транзакционный слой Java — вероятно, лучший в индустрии. JDBC-драйверы зрелые и быстрые, включая проприетарные СУБД; Hibernate после двадцати лет эволюции умеет почти всё, что вообще умеют ORM; jOOQ даёт типобезопасный SQL, аналогов которому в других экосистемах немного; JTA и XA существуют для тех редких случаев, когда двухфазный коммит действительно нужен; Testcontainers сделал интеграционные тесты с настоящей базой рутиной. Экосистема покрывает всё от встраиваемой H2 до Oracle RAC.
Где проигрывает. Цена абстракции высока и плохо видна. Hibernate — большой фреймворк с неочевидной семантикой; чтобы писать на нём хорошо, надо понимать persistence context, flush, каскады и стратегии выборки. Junior-код на JPA почти всегда содержит N+1 и лишние транзакции. Стартовое время и память Hibernate заметны в serverless-сценариях (частично лечится Quarkus/Micronaut с compile-time обработкой). Для аналитики и потоковой обработки данных Java уступает Python/SQL-first инструментам не по скорости, а по эргономике.
Сравнение с .NET. Ниша практически совпадает, различия поучительны. EF Core тоже имеет
change tracker (аналог persistence context) и unit of work, но: ленивая загрузка там
выключена по умолчанию (нужны прокси или явный Include) — из-за чего N+1 в EF Core
случается реже, зато LazyInitializationException заменяется на тихий null; LINQ
компилируется в SQL и типобезопасен, тогда как JPQL — строка (Criteria API типобезопасен, но
многословен настолько, что им редко пользуются); миграции в EF Core встроены, а в Java —
внешние инструменты Flyway/Liquibase, что даёт больше контроля и больше настройки; пул
соединений в .NET встроен в провайдер, в Java это отдельная библиотека. Навыки переносятся
почти один в один — трек C#/.NET на портале даёт
вторую точку зрения на те же паттерны.
Куда не стоит тащить. JPA плохо ложится на: агрегаты-гиганты (тысячи детей в коллекции), аналитические запросы с оконными функциями, массовые ETL-загрузки, схемы с динамическими атрибутами, документные и графовые модели. Во всех этих случаях либо берите SQL-инструмент, либо другое хранилище — см. обзор ландшафта БД. И отдельно: не пытайтесь спрятать за репозиторием разницу между БД. Абстракция, которая одинаково работает с PostgreSQL и MongoDB, обычно не работает хорошо ни с одной из них.
Мини-итог
- Слоёв немного: JDBC → драйвер → пул → (ORM) → транзакционный менеджер. Диагностика всегда начинается с вопроса «сколько запросов ушло», а не «какой запрос медленный».
PreparedStatement,try-with-resources,OffsetDateTime,BigDecimal— четыре привычки, снимающие половину проблем на уровне JDBC.- Пул должен быть маленьким и с коротким
connection-timeout. Утечки ловитleak-detection-threshold. - Hibernate = persistence context. Identity map + снимок состояния + очередь действий
объясняют dirty checking, flush,
merge,LazyInitializationExceptionи рост памяти. @Transactional— это прокси. Self-invocation, checked-исключения,REQUIRES_NEWи сетевые вызовы внутри транзакции — четыре главные ловушки.- N+1 лечится тремя инструментами:
@EntityGraph/JOIN FETCHточечно,default_batch_fetch_sizeглобально, DTO-проекции для экранов чтения. - Выключите
open-in-view. Включитеgenerate_statistics. Считайте запросы в тестах. - Схему двигают миграции, а не
ddl-auto. В продеvalidateи expand-contract. - JPA — для записи с инвариантами, SQL-инструмент — для чтения. Это самый дешёвый способ получить и удобство, и производительность.
Источники
- Jakarta Persistence 3.1 Specification — первоисточник семантики сущностей, контекста и транзакций.
- Hibernate ORM 6 User Guide — главный документ: fetching, batching, locking, statistics.
- Java SE: JDBC Basics (Oracle Tutorial) — базовый API без надстроек.
- pgJDBC documentation — курсоры,
reWriteBatchedInserts,CopyManager, тайм-ауты. - HikariCP wiki: About Pool Sizing — почему маленький пул быстрее.
- Spring Framework: Data Access & Transaction Management —
прокси, propagation,
JdbcClient, правила отката. - Spring Data JPA Reference —
проекции,
@EntityGraph,@Modifying,Persistable. - jOOQ Manual — SQL-first подход в типобезопасном виде.
- Flyway documentation — версионирование схемы.
- Vlad Mihalcea, High-Performance Java Persistence — лучшая книга по теме: батчинг, кэши, блокировки, планы запросов, замеры.
- Martin Fowler, Patterns of Enterprise Application Architecture — Unit of Work, Identity Map, Data Mapper: словарь, на котором говорят все ORM.
- Bill Burke, Richard Monson-Haefel, Enterprise JavaBeans 3.1 — исторический контекст: откуда взялись правила отката и propagation.
- Markus Winand, SQL Performance Explained — про индексы и планы; читать параллельно с любым ORM.
Что дальше
Мы прошли путь от сокета до сущности и обратно и знаем, где на этом пути обычно ломается производительность и согласованность. Осталось собрать всё в работающее приложение: как разложить код по слоям и модулям так, чтобы доменная логика не зависела от Hibernate, где хранить конфигурацию, как устроить graceful shutdown, таймауты и деградацию под нагрузкой — то есть всё, что превращает набор классов в систему, которую не страшно выкатывать.
Архитектура прод-приложений: слои, модули, конфигурация, устойчивость