Hibernate делает многое за вас — и именно поэтому он легко скрывает проблемы до тех пор, пока приложение не начнёт тормозить или ронять данные. Здесь — грабли, которые встречаются чаще всего. Начинаем с самой классической: сущность кладут в HashSet, и она там теряется.
Корзину 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. На деле — два скрытых вреда:
- Соединение с базой удерживается дольше нужного — в типовой конфигурации до конца запроса, включая медленный рендеринг шаблона. При нагрузке пул соединений быстро заканчивается.
- Маскирует 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 и два объекта после него: смотрите, в каком из них правка доезжает до базы.
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.