← назад к разделу

Hibernate делает многое за вас — и именно поэтому он легко скрывает проблемы до тех пор, пока приложение не начнёт тормозить или ронять данные. Здесь — грабли, которые встречаются чаще всего. Начинаем с самой классической: сущность кладут в HashSet, и она там теряется.

HashSet: корзина выбирается по хэшу — восемь корзин, номер = hash & 7 1. объект ещё не сохранён: id = null, хэш 0 — кладём в корзину 0 0 Product 1 · 2 · 3 · 4 · 5 · 6 · 7 · 2. база назначила id = 42 при сохранении: хэш стал 42, ищем в корзине 2 0 Product 1 · 2 ? 3 · 4 · 5 · 6 · 7 · в корзине 2 пусто — contains() = false, а объект так и лежит в нулевой 3. хэш по бизнес-ключу sku: корзина 7 и до сохранения, и после 0 · 1 · 2 · 3 · 4 · 5 · 6 · 7 Product contains() = true — id появился, хэш не изменился, корзина та же

Корзину HashSet выбирает один раз — в момент вставки, по текущему хэшу. Пока id равен null, хэш нулевой и объект садится в корзину 0; после сохранения база назначает id = 42, хэш меняется, и поиск уходит в корзину 2, где пусто. Объект из множества никуда не делся — просто contains его больше не находит. Хэш по бизнес-ключу sku при сохранении не меняется, и корзина остаётся той же.

Обязательно

equals и hashCode на сущности

Когда сущность кладут в HashSet или HashMap, Java использует equals и hashCode. Не переопределили явно — работает реализация из Object, то есть сравнение по ссылке. Это почти всегда неверно.

Первый инстинкт — сгенерировать по id. Но есть ловушка: пока сущность не сохранена, id равен null. Два новых объекта с id == null окажутся «одинаковыми», а у объекта, который уже положили в сет, хэш изменится, как только база назначит id.

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Рабочий минимум: equals по id с защитой от null и от прокси
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Product p)) return false;
        return id != null && id.equals(p.getId());   // именно getId(), а не p.id
    }

    @Override
    public int hashCode() {
        return 31; // константа: хэш не изменится, когда база назначит id
    }
}

Это рабочий минимум: equals защищён от null, а константный hashCode не меняется при сохранении. У минимума есть цена, и её честнее назвать сразу: при одинаковом хэше все объекты класса садятся в одну корзину HashSet, и поиск по множеству вырождается в перебор всего содержимого. На десятке позиций заказа этого не увидеть, на тысячах — уже заметно. Поэтому дальше речь пойдёт о варианте получше.

Одна деталь принципиальна: p.getId(), а не p.id. Ленивая связь подсовывает вместо объекта прокси — наследника вашего класса, у которого собственные поля пусты, а данные подтягиваются через геттеры: прямое обращение к полю вернёт null, и сравнение молча провалится. У прокси и класс другой — поэтому в hashCode здесь константа, а не getClass().hashCode().

А вот своё поле метод читает напрямую, без геттера, — и это не небрежность, а следствие того же механизма. Когда equals вызывают у прокси, Hibernate не отвечает на него сам: он инициализирует объект и передаёт вызов настоящему экземпляру. Внутри метода this — всегда реальная сущность с заполненными полями, ходить за своими значениями через геттер незачем. Про аргумент такой гарантии нет: им запросто окажется неинициализированный прокси.

Лучший вариант — бизнес-ключ: поле, которое уникально и стабильно ещё до сохранения (артикул, email, UUID генерируемый в коде, не базой).

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String sku; // бизнес-ключ

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Product p)) return false;
        return sku != null && sku.equals(p.sku);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(sku);
    }
}

Ловушку видно и без базы — id здесь назначается прямо в коде:

живой пример

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public class EntityHashDemo {

    static class ById {
        Long id;

        @Override
        public boolean equals(Object o) {
            return o instanceof ById other && Objects.equals(id, other.id);
        }

        @Override
        public int hashCode() {
            return Objects.hashCode(id);
        }
    }

    static class BySku {
        Long id;
        final String sku;

        BySku(String sku) {
            this.sku = sku;
        }

        @Override
        public boolean equals(Object o) {
            return o instanceof BySku other && sku.equals(other.sku);
        }

        @Override
        public int hashCode() {
            return sku.hashCode();
        }
    }

    public static void main(String[] args) {
        Set<ById> byId = new HashSet<>();
        ById first = new ById();
        byId.add(first);
        System.out.println("хэш по id, пока id == null: contains = " + byId.contains(first));
        first.id = 42L;
        System.out.println("тот же объект после сохранения:  contains = " + byId.contains(first));
        System.out.println("а в множестве он лежит:          size = " + byId.size());

        Set<BySku> bySku = new HashSet<>();
        BySku second = new BySku("SKU-7");
        bySku.add(second);
        second.id = 42L;
        System.out.println("хэш по бизнес-ключу, после сохранения: contains = " + bySku.contains(second));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Короткая формула: хэш не должен меняться при переходе из transient в managed — это значит, нельзя считать его по id, если id назначает база.

Open Session in View

Open Session in View (OSIV) — шаблон, при котором сессия Hibernate остаётся открытой на всё время HTTP-запроса, включая рендеринг ответа. В Spring Boot он включён по умолчанию (spring.jpa.open-in-view=true).

На первый взгляд удобно: ленивые коллекции загружаются прямо в шаблоне, никаких LazyInitializationException. На деле — два скрытых вреда:

  1. Соединение с базой удерживается дольше нужного — в типовой конфигурации до конца запроса, включая медленный рендеринг шаблона. При нагрузке пул соединений быстро заканчивается.
  2. Маскирует N+1: запросы уходят в базу из слоя представления, где их никто не ожидает и не контролирует.
# application.yml — выключить OSIV
spring:
  jpa:
    open-in-view: false

После выключения LazyInitializationException будут явными — именно там, где данные не были загружены в транзакции, и это хорошо: проблема видна. Решение — загружать нужное в сервисном слое (через JOIN FETCH или EntityGraph) и возвращать DTO, а не сущности.

Возврат сущности из контроллера вместо DTO

Сущность — это не DTO. Если вернуть @Entity напрямую из контроллера в Jackson, случится несколько неприятностей:

  • Утечка внутренней структуры: клиент видит все поля, включая технические.
  • Рекурсия при сериализации: двунаправленные связи (@OneToMany + @ManyToOne) зацикливаются — лечится @JsonIgnore или @JsonManagedReference, которые засоряют доменный код.
  • Внезапная загрузка: Jackson обходит все поля, в том числе ленивые коллекции — при открытой сессии (OSIV) Hibernate выполнит дополнительные запросы.
// Плохо: возвращаем сущность прямо из контроллера
@GetMapping("/products/{id}")
public Product getProduct(@PathVariable Long id) {
    return productRepository.findById(id).orElseThrow();
}

// Хорошо: конвертируем в DTO в сервисном слое
@GetMapping("/products/{id}")
public ProductDto getProduct(@PathVariable Long id) {
    return productService.getById(id); // внутри — маппинг в DTO
}

Отдельный DTO на каждый ответ — не бюрократия, а граница между внутренней моделью и публичным контрактом.

CascadeType.ALL и orphanRemoval — опасная комбинация

CascadeType.ALL распространяет все операции (PERSIST, MERGE, REMOVE, REFRESH, DETACH) с родителя на дочерние сущности. В большинстве случаев это избыточно.

Особенно опасна комбинация CascadeType.ALL + orphanRemoval = true: если убрать дочерний объект из коллекции родителя, Hibernate удалит его из базы.

@Entity
public class Order {

    @OneToMany(mappedBy = "order",
               cascade = CascadeType.ALL,  // включает REMOVE
               orphanRemoval = true)        // удаляет строку из items при убирании из коллекции
    private List<OrderItem> items = new ArrayList<>();
}

// В коде:
order.getItems().clear(); // <-- это удалит ВСЕ строки в order_item для этого заказа!

Используйте только нужные cascade-типы: обычно достаточно CascadeType.PERSIST и CascadeType.MERGE. REMOVE — только там, где дочерние объекты не имеют смысла без родителя, например строки чека.

// Явно и безопасно
@OneToMany(mappedBy = "order", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<OrderItem> items = new ArrayList<>();

Отдельная ловушка orphanRemoval — подмена коллекции. Hibernate следит за той коллекцией, которую выдал при загрузке (это его обёртка, например PersistentBag); присвоить полю новый список — order.setItems(newItems) — значит, с его точки зрения, «все старые элементы удалены»: он удалит их строки и вставит новые с новыми идентификаторами, а иногда откажет с ошибкой A collection with cascade="all-delete-orphan" was no longer referenced.

Обновлять нужно содержимое существующей коллекции: order.getItems().clear(); order.getItems().addAll(newItems), а лучше — сверить списки и изменить только то, что действительно изменилось.

Массовые операции по одному объекту

Обычный подход через JPA-репозиторий: загрузить сущности, изменить в цикле, сохранить. При тысячах записей это тысячи отдельных UPDATE:

// Плохо: N запросов UPDATE в базу
List<Product> products = productRepository.findAll();
for (Product p : products) {
    p.setPrice(p.getPrice().multiply(BigDecimal.valueOf(1.1)));
    productRepository.save(p); // на самом деле лишний: объект уже управляется контекстом
}

Обратите внимание: save() здесь не пишет в базу на каждой итерации, как многие думают, — все UPDATE уйдут разом при фиксации транзакции. Плохо другое: контекст держит в памяти все загруженные товары и снимок каждого для сравнения, а в базу всё равно улетит столько отдельных UPDATE, сколько строк. На сотне тысяч записей это и память, и время.

Вместо этого — bulk-запрос через JPQL или нативный SQL:

// Хорошо: один UPDATE на все строки
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Product p SET p.price = p.price * :factor WHERE p.category = :category")
int increasePricesByCategory(@Param("category") String category,
                             @Param("factor") BigDecimal factor);

Множитель здесь параметр типа BigDecimal, а не 1.1 прямо в тексте запроса, и это не придирка. Дробное число без явного типа HQL считает double — двоичной дробью, в которой ровно 1.1 не представимо. Цена приедет в базу через это округление и вернётся не той, которую ждали. Почему деньги держат в numeric и чем опасен float — в статье Числа в PostgreSQL.

Такой запрос идёт мимо контекста: кэш первого уровня не знает об изменениях, и загруженные раньше сущности устарели. Это чинит clearAutomatically = true — он очищает контекст после запроса, и следующее чтение пойдёт в базу за свежими ценами. У flushAutomatically = true задача другая: вытолкнуть в базу то, что вы уже успели наменять в контексте, прежде чем туда уедет bulk-запрос. Иначе накопленные изменения либо пропадут при очистке, либо лягут поверх результата и перетрут его.

Подробнее о persistence context и кэшировании — в статье Persistence Context.

merge против save: что происходит

В Spring Data JPA save() делает одно из двух в зависимости от состояния объекта:

  • Объект новый — вызывает entityManager.persist().
  • Объект не новый — вызывает entityManager.merge().

Как он решает, новый ли объект, — вопрос отдельный, и по id он смотрит в последнюю очередь. Есть у сущности поле @Version — признак берётся из него: версия пуста, значит объект новый. Реализует сущность Persistable — решает её собственный isNew(). И только если ни того ни другого нет, в ход идёт id.

Отсюда практическое следствие. Если идентификатор вы назначаете сами — UUID из кода, естественный ключ, — а @Version в сущности нет, то у новой сущности id уже заполнен, и save() уходит в merge(). Каждая вставка тогда обрастает лишним SELECT: merge сначала идёт в базу проверить, нет ли там такой строки. Лечится это либо полем @Version, либо интерфейсом Persistable с честным isNew().

merge работает неочевидно: он не обновляет переданный объект, а возвращает managed-копию из persistence context. Исходный остаётся detached.

save(detached) detached, id = 42 merge managed-копия правка в managed setPrice(999) контекст следит UPDATE в базу правка в detached setPrice(0) никто не следит в базу не уйдёт

Один вызов save и два объекта после него: смотрите, в каком из них правка доезжает до базы.

Product detached = new Product();
detached.setId(42L);
detached.setName("Новое имя");

Product managed = productRepository.save(detached);
// detached — всё ещё detached, изменения не отслеживаются!
// managed — это managed-копия, с ней и нужно работать дальше

managed.setPrice(BigDecimal.valueOf(999)); // это попадёт в базу при flush
detached.setPrice(BigDecimal.valueOf(0));  // это НИГДЕ не сохранится

Чтобы обновить конкретные поля, загрузите сущность в транзакции и меняйте там — тогда merge не нужен вовсе. Когда объект пришёл из формы, делают то же самое: загружают сущность по идентификатору, переносят в неё нужные поля из формы, а версию сравнивают руками и отвечают конфликтом, если она отстала. merge с объектом из формы целиком перезапишет и те поля, которых в форме не было.

Lombok на сущности: @Data ломает три вещи сразу

Самая частая грабля из всего списка, потому что выглядит безобидно: @Data на классе сущности. Разворачивается она в три отдельные проблемы.

equals и hashCode по всем полям. Lombok включает в сравнение все поля, в том числе связи. Сравнение двух заказов загрузит их коллекции позиций (а вне транзакции уронит LazyInitializationException), а при двунаправленной связи уйдёт в бесконечную рекурсию: заказ сравнивает позиции, позиция сравнивает заказ. Плюс всё то, о чём речь выше: до сохранения идентификатора нет, после вставки он появился — и hashCode изменился, а объект, положенный в множество раньше, в нём «потерялся».

toString по всем полям. Одна строчка отладочного вывода тянет из базы все связи сущности. В журнале это выглядит как необъяснимый всплеск запросов при включении отладочного уровня; при двунаправленной связи — как StackOverflowError.

@Setter на всём. Открывает изменение идентификатора и поля версии — того, что менять не должен никто, включая вас.

Рабочий набор выглядит так:

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Version
    private long version;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderLine> lines = new ArrayList<>();

    public Order(long customerId) { ... }          // свой конструктор

    public void addLine(Product product, int qty) { ... }   // метод предметной области вместо сеттера

    @Override
    public boolean equals(Object other) { ... }    // по id, как разобрано выше
}

@Getter безопасен, защищённый конструктор без аргументов нужен Hibernate, сеттеры заменяют методами с именами операций. @ToString либо не ставят вовсе, либо с явным списком простых полей: @ToString(of = {"id", "status"}). И то же правило про журналы: в сообщение пишут идентификатор и статус, а не объект целиком.

Приём сущности снаружи: @RequestBody Entity

Возврат сущности из контроллера разобран выше, но приём опаснее. Метод вида

// так не надо
@PostMapping("/orders")
public Order create(@RequestBody Order order) { return orders.save(order); }

позволяет клиенту прислать любое поле класса. Идентификатор — и вместо создания получится перезапись чужой строки. Поле версии — и оптимистичная блокировка обойдена. Служебные поля (createdBy, status, discount) — и заказ создаётся в статусе «оплачен» со скидкой сто процентов. Связи — и в базу приедут вложенные объекты, которых клиент не имеет права трогать. Это классическое массовое присваивание, и в Java оно ловится тем, что сериализатор послушно заполняет все поля, до которых может добраться.

Лечение одно: снаружи принимают объект передачи данных, в котором есть только то, что клиенту разрешено задать.

public record CreateOrderRequest(
        @NotNull Long customerId,
        @NotEmpty List<@Valid LineRequest> lines,
        String comment) { }

@PostMapping("/orders")
public OrderResponse create(@RequestBody @Valid CreateOrderRequest request, Principal principal) {
    long orderId = createOrder.handle(request.toCommand(principal.getName()));
    return orderResponses.byId(orderId);
}

Здесь клиент физически не может задать статус, скидку или версию: этих полей нет в типе. Заодно появляется место для проверок (@Valid) и для того, чтобы взять покупателя не из запроса, а из данных аутентификации — что и есть правильный источник.

Удаление: deleteById и deleteAllInBatch

Два метода Spring Data, которые ведут себя не так, как читаются.

deleteById(id) сначала загружает сущность, а потом удаляет: два запроса вместо одного. Сделано это не зря — загруженной сущности нужны каскады и orphanRemoval, а ещё событие удаления. Но когда каскадов нет, это лишний SELECT; тогда удаляют запросом: @Modifying @Query("delete from Order o where o.id = :id").

deleteAll(Iterable) удаляет по одному, в цикле: сто заказов — сто запросов DELETE. deleteAllInBatch(Iterable) формирует один запрос с условием по списку идентификаторов — на порядок быстрее, но не выполняет каскады, orphanRemoval и события, и не обновляет контекст. То есть это операция «в обход ORM», со всеми последствиями: дочерние строки придётся удалять самому (или через ON DELETE CASCADE в схеме), а загруженные в контексте объекты станут устаревшими — после такой операции контекст очищают.

Правило простое: deleteById и deleteAll — когда объектов единицы и есть каскады; запрос delete или deleteAllInBatch — когда объектов много и связи закрыты схемой.

saveAll в цикле без clear: как получить нехватку памяти

saveAll на десять тысяч объектов выглядит как одна операция, но контекст помнит их все — плюс снимки для сравнения. Дальше тот же цикл повторяется для следующей пачки, и контекст растёт, пока не кончится память. Причём падает приложение не на базе, а на своей стороне, и в журнале это выглядит как OutOfMemoryError без всякой связи с ORM.

Лечится тем же приёмом, что и любая массовая обработка: порции плюс flush и clear между ними.

@Transactional
public void importAll(List<OrderRow> rows) {
    for (int i = 0; i < rows.size(); i++) {
        em.persist(Order.from(rows.get(i)));
        if (i % 500 == 0) {
            em.flush();
            em.clear();
        }
    }
}

Числа подбирают под размер объекта: пятьсот-тысяча обычно достаточно. Важно помнить, что после clear прежние объекты отсоединены, и обращаться к ним нельзя. Как это сочетается с пакетной вставкой на стороне драйвера — в разделе про batch_size ниже.

Дополнительно: при первом чтении можно пропустить

Глубже: пакетная вставка: batch_size, flush и clearрасширенное

«Массовые операции по одному объекту» выше про то, что нельзя. Вот как можно. Hibernate умеет собирать одинаковые INSERT и UPDATE в пакеты JDBC, но по умолчанию это выключено:

spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 50
        order_inserts: true
        order_updates: true

batch_size задаёт, сколько команд уходит одним обращением к базе; order_inserts и order_updates группируют команды по таблицам, иначе пакет рвётся на каждой смене сущности. Драйвер PostgreSQL добавляет своё: reWriteBatchedInserts=true в строке подключения склеивает пакет в одну многострочную вставку.

Две вещи ломают пакет молча. Первая, генерация ключа IDENTITY: Hibernate обязан получить идентификатор сразу после вставки, поэтому вставляет строки по одной, и никакой batch_size не помогает. Для пакетной вставки нужен SEQUENCE с оптимизатором, @SequenceGenerator(allocationSize = 50): Hibernate берёт из последовательности блок номеров и раздаёт их сам, о чём говорит статья про маппинг. Вторая, контекст персистентности: сто тысяч сущностей в нём это сто тысяч снимков для проверки изменений и память до конца транзакции. Поэтому в цикле каждые batch_size строк делают flush() и clear():

for (int i = 0; i < rows.size(); i++) {
    em.persist(toEntity(rows.get(i)));
    if (i % 50 == 0) { em.flush(); em.clear(); }
}

Когда строк миллионы, ORM для загрузки не берут вовсе: COPY или INSERT ... SELECT из раздела про PostgreSQL быстрее на порядок, а сущности здесь только мешают.

Глубже: диагностика в проде: статистика Hibernate и пулрасширенное

Про OSIV выше сказано «пул быстро заканчивается», без цифр и без способа увидеть. Вот механика. При включённом OSIV сессия живёт до конца HTTP-запроса, и ленивая загрузка после выхода из транзакции берёт соединение из пула и держит его, пока сессия не закроется, то есть до конца ответа, включая отрисовку и вызовы соседних сервисов. Двести потоков Tomcat и пул на десять соединений: под нагрузкой десять запросов держат соединения по секунде, остальные ждут, и через тридцать секунд в логе HikariPool-1 - Connection is not available, request timed out after 30000ms.

Увидеть это можно тремя способами. Метрики пула через Micrometer, они есть в Boot из коробки: hikaricp.connections.active упирается в максимум, hikaricp.connections.pending растёт, hikaricp.connections.acquire показывает, сколько ждут. Статистика Hibernate: spring.jpa.properties.hibernate.generate_statistics=true пишет на каждую сессию число запросов, время в базе и попадания в кэш, а hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=200 печатает медленные запросы с текстом. В проде статистику включают на время разбора, она стоит несколько процентов. И pg_stat_activity со стороны базы, где видно, чьи соединения idle in transaction.

Лечение обычно в двух строках: spring.jpa.open-in-view=false и DTO вместо сущностей на выходе из сервиса, чтобы ленивых загрузок за пределами транзакции не было вовсе. Размер пула после этого считают по формуле из статьи про пул соединений, а не увеличивают до ста.

Отдельно стоит завести тест, который ловит эти грабли автоматически, потому что на ревью их пропускают. Самый полезный — счётчик запросов на сценарий: он падает, когда где-то появился N+1, лишний SELECT перед обновлением или загрузка связи из журнала.

@DataJpaTest
class OrderQueriesTest {

    @Autowired EntityManager em;
    @Autowired OrderRepository orders;

    @Test
    void листСтраницыДелаетНеБольшеДвухЗапросов() {
        Statistics stats = em.getEntityManagerFactory()
                .unwrap(SessionFactory.class).getStatistics();
        stats.clear();

        orders.findPage(PageRequest.of(0, 20));

        assertThat(stats.getPrepareStatementCount()).isLessThanOrEqualTo(2);
    }
}

getStatistics() требует hibernate.generate_statistics: true в тестовой конфигурации — в тестах это бесплатно. Тот же счётчик умеют datasource-proxy и p6spy, если нужен ещё и текст запросов. Такой тест ценен не точным числом, а тем, что фиксирует ожидание: когда кто-то добавит связь с EAGER или уберёт JOIN FETCH, тест упадёт на сборке, а не в проде через месяц.

И второй страж, дешёвый и неочевидный: тест, который поднимает контекст приложения и проверяет, что ни одна сущность не аннотирована @Data, а в контроллерах нет типов сущностей в подписях методов. Пишется он один раз обходом по классам (ArchUnit или обычная рефлексия) и закрывает сразу три пункта из этой статьи.

Коротко

  • equals/hashCode по id опасны: до сохранения id == null — берут константный hashCode или бизнес-ключ. @Data на сущности запрещён: equals по связям, рекурсия в toString, сеттер на идентификатор и версию.
  • OSIV удерживает соединение до конца запроса и скрывает N+1 — выключайте и загружайте данные явно в транзакции.
  • Сущность не возвращают из контроллера и, тем важнее, не принимают снаружи: клиент пришлёт идентификатор, версию и статус. Наружу и внутрь — объекты передачи данных.
  • CascadeType.ALL + orphanRemoval удаляют строки при clear() на коллекции — применяйте только осознанно, предпочитайте явный набор типов.
  • Массовые изменения делайте запросом (@Modifying + @Query), а не в цикле, и сбрасывайте кэш первого уровня после; deleteById делает лишний SELECT, а deleteAllInBatch быстр, но обходит каскады и события.
  • save() с ненулевым id вызывает merge — возвращённый объект managed, исходный остаётся detached.
  • Пакетную вставку включают jdbc.batch_size с order_inserts; IDENTITY её отключает, нужен SEQUENCE с allocationSize; в цикле flush и clear каждые N строк.
  • OSIV держит соединение до конца ответа: смотрят hikaricp.connections.pending, статистику Hibernate и pg_stat_activity, лечат open-in-view=false и DTO.
  • Грабли ловит тест-страж: счётчик запросов на сценарий через статистику Hibernate плюс проверка обходом классов, что сущности не размечены @Data.

Что почитать дальше

  • Persistence Context — как Hibernate отслеживает изменения и когда они сбрасываются в базу.
  • Lazy vs Eager загрузка — почему LazyInitializationException возникает и как его избегать правильно.
  • Проблема N+1 — диагностика и решение самой распространённой причины медленных запросов.
  • Spring Data JPA — репозитории поверх Hibernate: методы, производные запросы, @Query.