Java Работа с данными: JDBC, JPA/Hibernate, транзакции, N+1 и другие ловушки
0%

Работа с данными: JDBC, JPA/Hibernate, транзакции, N+1 и другие ловушки

Работа с данными: JDBC, JPA/Hibernate, транзакции, N+1 и другие ловушки

Всё, что мы делали до сих пор, жило внутри одного процесса. Объект создавался, менялся, умирал вместе с JVM. Настоящее приложение устроено иначе: процесс временный, данные вечные. Между вашим Order в heap и строкой в таблице лежит пропасть — разные модели данных, разные модели времени, разные модели отказа. Java-экосистема закрывает эту пропасть лучше почти любой другой, и именно поэтому там столько мест, где можно провалиться.

Формулировка задачи, которую решает весь этот слой: как выразить бизнес-операцию так, чтобы она была атомарной, видела согласованные данные, не держала базу дольше необходимого и при этом читалась как обычный код. Всё остальное — детали механизма.

Ключевое отличие Java от того, что вы могли видеть в динамических языках: здесь тяжёлый слой ORM выиграл индустриальный спор двадцать лет назад, и знать его устройство обязательно даже если вы предпочитаете писать SQL руками. Hibernate не «магия» — это очень конкретная машина с очень конкретными правилами, и почти все жалобы вида «ORM тормозит» на деле означают «я не знаю, что именно он сделал».

Слои доступа к данным в Java-приложении

Модель исполнения: кто что делает

Прежде чем писать код, зафиксируем участников. Хорошая новость: слоёв немного, и они честно разделены.

  • 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 Фаулера.

Persistence context изнутри

Три следствия, из которых вырастает вся идиоматика:

  1. Одна сущность = один объект в пределах контекста. em.find(Order.class, 42) дважды вернёт ту же ссылку, второй раз без SQL. Отсюда же правило: сравнивать managed-сущности можно через ==, но только внутри одного контекста.
  2. Изменения не пишутся сразу. Вы вызвали сеттер — Hibernate ничего не знает. На flush он сравнивает текущее состояние со снимком (dirty checking) и генерирует UPDATE.
  3. Контекст должен быть коротким. Он держит в 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 мс на ровном месте — и линейно растёт с числом строк.

Инструменты лечения

@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-вызов на сокете не пинит несущий поток — и это отличная новость: тысяча виртуальных потоков, ждущих БД, стоит килобайты. Но два ограничения остаются:

  1. Пул соединений — по-прежнему потолок. Виртуальных потоков может быть миллион, соединений — 16. Пул превращается в семафор, что, вообще говоря, правильно: базу нельзя перегружать. Просто не ждите, что виртуальные потоки ускорят работу с БД.
  2. 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: растущая очередь ожидания — ранний признак того, что транзакции стали длиннее.

Типичные ошибки — сводный список

  1. Незакрытый Connection/ResultSet — медленная смерть пула.
  2. getTimestamp() вместо getObject(..., OffsetDateTime.class) и timestamp вместо timestamptz в схеме.
  3. double для денег вместо BigDecimal с явными precision/scale.
  4. maximumPoolSize: 200 «чтобы точно хватило» — деградация базы вместо ускорения.
  5. connection-timeout по умолчанию 30 с — сервис умирает медленно и незаметно.
  6. @Transactional на приватном методе или вызов через this — транзакции просто нет.
  7. Checked-исключение внутри @Transactional без rollbackFor — commit вместо отката.
  8. HTTP-вызов, отправка письма или Thread.sleep внутри транзакции.
  9. FetchType.EAGER (в том числе дефолтный для @ManyToOne) — граф грузится всегда.
  10. spring.jpa.open-in-view: true — N+1 переезжает в сериализацию и становится невидимым.
  11. @Enumerated без EnumType.STRING — данные ломаются при изменении порядка констант.
  12. GenerationType.IDENTITY при массовых вставках — батчинг отключён полностью.
  13. Два List-JOIN FETCHMultipleBagFetchException; два Set-fetch — декартово произведение.
  14. Pageable вместе с JOIN FETCH коллекции — пагинация в памяти.
  15. merge() с расчётом, что переданный объект станет управляемым.
  16. equals/hashCode по всем полям или по автогенерируемому id — сущность «теряется» в HashSet.
  17. ddl-auto: update в проде.
  18. Загрузка 500 000 сущностей в один контекст без flush()/clear().
  19. @Modifying-запрос без clearAutomatically — контекст хранит устаревшие данные.
  20. Ловля 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-инструмент — для чтения. Это самый дешёвый способ получить и удобство, и производительность.

Источники

Что дальше

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

Архитектура прод-приложений: слои, модули, конфигурация, устойчивость

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов