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

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. На деле — два скрытых вреда:

  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<>();

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

Стандартный подход через 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.