Hibernate делает многое за вас — и именно поэтому он легко скрывает проблемы до тех пор, пока приложение не начнёт тормозить или ронять данные. В этой статье — сборник граблей, которые встречаются чаще всего.
equals и hashCode на сущности
Когда сущность кладут в HashSet или HashMap, Java использует equals и hashCode. Если не переопределить их явно, работает реализация из Object — по ссылке на объект. Это почти всегда неверно.
Первый инстинкт — сгенерировать по id. Но есть ловушка: пока сущность не сохранена, id равен null. Два новых объекта с id == null окажутся «одинаковыми» — или оба лягут в сет с неправильным хэшем, а после flush хэш изменится и найти их уже не получится.
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Плохо: equals/hashCode по id — 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.id);
}
@Override
public int hashCode() {
return getClass().hashCode(); // константа — безопасно, но медленно при больших коллекциях
}
}
Вариант выше — «нулебезопасный»: equals возвращает false, если один из id равен null, а hashCode константен (хэш не меняется при сохранении). Это рабочий минимум.
Лучший вариант — бизнес-ключ: поле, которое уникально и стабильно ещё до сохранения (артикул, 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);
}
}
Короткая формула: хэш не должен меняться при переходе из 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<>();
Массовые операции по одному объекту
Стандартный подход через 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); // flush на каждой итерации
}
Вместо этого — bulk-запрос через JPQL или нативный SQL:
// Хорошо: один UPDATE
@Modifying
@Query("UPDATE Product p SET p.price = p.price * 1.1 WHERE p.category = :category")
int increasePricesByCategory(@Param("category") String category);
Важный нюанс: после @Modifying запроса кэш первого уровня (persistence context) не знает о изменениях — он устарел. Нужно либо добавить @Modifying(clearAutomatically = true), либо вручную вызвать entityManager.clear() перед следующим чтением.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Product p SET p.price = p.price * 1.1 WHERE p.category = :category")
int increasePricesByCategory(@Param("category") String category);
Подробнее о persistence context и кэшировании — в статье Persistence Context.
merge против save: что происходит
В Spring Data JPA метод save() делает одно из двух в зависимости от состояния объекта:
- Если
id == null(или объект новый поPersistable) — вызываетentityManager.persist(). - Если
idзадан — вызываетentityManager.merge().
merge работает неочевидно: он не обновляет переданный объект, а возвращает новый managed-экземпляр из persistence context. Исходный объект остаётся detached.
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)); // это НИГДЕ не сохранится
Короткая формула: после save() работайте с возвращённым объектом, не с тем, который передали.
Если нужно обновить конкретные поля сущности, лучше загрузить её из базы и изменить в рамках транзакции — тогда merge не нужен вовсе.
Коротко
equals/hashCodeпоidопасны: до сохраненияid == null. Используйте константныйhashCodeили бизнес-ключ.- OSIV удерживает соединение на весь запрос и скрывает N+1 — выключайте и загружайте данные явно в транзакции.
- Не возвращайте
@Entityиз контроллера — используйте DTO, чтобы не протечь внутреннюю модель и не получить неожиданные запросы при сериализации. CascadeType.ALL + orphanRemovalудаляют строки приclear()на коллекции — применяйте только осознанно, предпочитайте явный набор типов.- Массовые изменения делайте bulk-запросами (
@Modifying + @Query), а не в цикле; после них сбрасывайте кэш первого уровня. save()с ненулевымidвызываетmerge— возвращённый объектmanaged, исходный остаётсяdetached.
Что почитать дальше
- Persistence Context — как Hibernate отслеживает изменения и когда они сбрасываются в базу.
- Lazy vs Eager загрузка — почему
LazyInitializationExceptionвозникает и как его избегать правильно. - Проблема N+1 — диагностика и решение самой распространённой причины медленных запросов.
- Spring Data JPA — репозитории поверх Hibernate: методы, производные запросы,
@Query.