💾 Розділ 16 · Питання #23

Як правильно використовувати @OneToMany і @ManyToOne

Зв'язки @ManyToOne («багато до одного») та @OneToMany («один до багатьох») є найпоширенішим способом моделювання реляційних асоціацій у JPA та Hibernate. Помилки в їх налаштуван...

Зв’язки @ManyToOne («багато до одного») та @OneToMany («один до багатьох») є найпоширенішим способом моделювання реляційних асоціацій у JPA та Hibernate. Помилки в їх налаштуванні — найчастіша причина деградації продуктивності в корпоративних застосунках: від прихованої проблеми $N+1$ та паразитних проміжних таблиць (Join Tables) до падінь JVM через роздування колекцій в оперативній пам’яті.


🟢 Junior Level

Хто ким володіє: Власник зв’язку (Owning Side) та Зворотний бік (Inverse Side)

У реляційних базах даних зовнішній ключ (Foreign Key, FK) фізично завжди зберігається в таблиці на стороні «багато» (order_items.order_id посилається на orders.id).

У JPA цей поділ суворо відображається в коді:

  • @ManyToOne — Власник зв’язку (Owning Side): Знаходиться в дочірній сутності. Саме ця сторона керує фізичною колонкою зовнішнього ключа через @JoinColumn.
  • @OneToMany — Зворотний бік (Inverse Side): Знаходиться в батьківській сутності. Вона лише посилається на поле власника через атрибут mappedBy і сама колонкою FK не керує.
// 1. Власник зв'язку (Child): містить фізичний FK 'order_id'
@Entity
@Table(name = "order_items")
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // УВАГА: За замовчуванням у JPA fetch = EAGER! Завжди явно вказуємо LAZY!
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;
}

// 2. Зворотний бік (Parent): mappedBy вказує на ім'я Java-поля 'order' в OrderItem
@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>(); // Завжди ініціалізуємо колекцію!
}

3 критичні правила Junior-рівня

  1. FetchType.LAZY на обох сторонах: @OneToMany за замовчуванням лінивий, але @ManyToOne за специфікацією JPA за замовчуванням EAGER. Якщо не написати fetch = FetchType.LAZY, кожен запит дочірньої сутності призведе до негайного завантаження батька.
  2. Завжди ініціалізувати колекцію: Поле @OneToMany має одразу ініціалізуватися порожнім списком (new ArrayList<>()), інакше спроба додати елемент до завантаження з БД викине NullPointerException.
  3. mappedBy обов’язковий: Якщо забути mappedBy на @OneToMany, Hibernate вирішить, що це зв’язок багато-до-багатьох, і автоматично створить зайву зв’язувальну таблицю orders_order_items.

🟡 Middle Level

Обов’язкові допоміжні методи (Helper Methods)

В оперативній пам’яті JVM двонапрямлений зв’язок — це два незалежні посилання: order.getItems() та item.getOrder(). Механізм Dirty Checking Hibernate аналізує тільки сторону власника (@ManyToOne).

Якщо додати елемент до колекції батька, але забути проставити зворотне посилання:

// ❌ ГРУБА ПОМИЛКА:
Order order = new Order();
OrderItem item = new OrderItem();
order.getItems().add(item); 
// item.getOrder() залишився null! У БД піде order_id = NULL!

Щоб виключити розсинхронізацію, батьківська сутність повинна інкапсулювати керування зв’язком у єдиних хелпер-методах:

@Entity
public class Order {
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    // Хелпер додавання (синхронізує обидва боки):
    public void addItem(OrderItem item) {
        this.items.add(item);
        item.setOrder(this);
    }

    // Хелпер видалення:
    public void removeItem(OrderItem item) {
        this.items.remove(item);
        item.setOrder(null);
    }
}
// Використання в сервісі:
Order order = new Order();
order.addItem(new OrderItem("Товар 1")); // Обидві сторони гарантовано узгоджені!
entityManager.persist(order);

🔴 Senior Level

Проблема однонапрямленого зв’язку @OneToMany (Unidirectional @OneToMany)

Іноді розробники не хочуть оголошувати поле @ManyToOne у дочірньому об’єкті та роблять однонапрямлений зв’язок:

// Уявна простота: тільки батько знає про дітей
@Entity
public class Order {
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "order_id") // Без mappedBy!
    private List<OrderItem> items = new ArrayList<>();
}

Прихована катастрофа продуктивності (Зайвий UPDATE):

Оскільки дочірній клас OrderItem нічого не знає про поле order_id, Hibernate при збереженні замовлення виконує таку послідовність SQL:

  1. INSERT INTO orders ... (зберігає батька).
  2. INSERT INTO order_items (name) VALUES ('Товар 1'); (вставляє дочірній рядок з order_id = NULL!).
  3. UPDATE order_items SET order_id = ? WHERE id = ?; (зайвий UPDATE для проставлення зовнішнього ключа!).

Якщо в замовленні 100 позицій, замість 100 вставок Hibernate виконає 100 INSERT + 100 додаткових UPDATE, навантажуючи журнал транзакцій СУБД. Senior-стандарт: Однонапрямлених @OneToMany слід уникати. Завжди використовувати або двонапрямлений зв’язок із mappedBy, або однонапрямлений @ManyToOne зі сторони дитини.

            ПОРІВНЯННЯ ГЕНЕРАЦІЇ SQL ПРИ ВСТАВЦІ ЕЛЕМЕНТА
            
   1. Unidirectional @OneToMany (@JoinColumn):
      • SQL 1: INSERT INTO order_items (name) VALUES ('Item'); (FK is null!)
      • SQL 2: UPDATE order_items SET order_id = 1 WHERE id = 10; ──[ЗАЙВИЙ UPDATE!]

   2. Bidirectional @ManyToOne + @OneToMany(mappedBy):
      • SQL 1: INSERT INTO order_items (order_id, name) VALUES (1, 'Item');
      (Одразу зі значенням зовнішнього ключа! Зайвих запитів немає).

Проблема високої кардинальності (High-Cardinality Collections)

Що станеться, якщо змапити колекцію з мільйоном дочірніх записів?

@Entity
public class Company {
    @Id
    private Long id;

    // ❌ АРХІТЕКТУРНИЙ КРАХ: У компанії може бути 100 000 співробітників!
    @OneToMany(mappedBy = "company")
    private List<Employee> employees = new ArrayList<>();
}

Наслідки в Production:

  1. Варто викликати company.getEmployees().size(), і Hibernate спробує матеріалізувати 100 000 Java-об’єктів у купу JVM, миттєво викликавши OutOfMemoryError.
  2. Неможливо застосувати пагінацію (limit / offset) до гетера колекції.

DDD-рішення (Domain-Driven Design): Повністю видалити колекцію @OneToMany із класу Company. Зв’язок має залишитися суворо однонапрямленим зі сторони дитини:

@Entity
public class Employee {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "company_id")
    private Company company;
}

А вибірку співробітників здійснювати через Spring Data репозиторій із пагінацією:

public interface EmployeeRepository extends JpaRepository<Employee, Long> {
    Page<Employee> findByCompanyId(Long companyId, Pageable pageable);
}

4 Tricky Questions

1. Чому за специфікацією JPA анотація @ManyToOne за замовчуванням має FetchType.EAGER, і до яких наслідків при вибірці списку сутностей це призводить?

Відповідь:

  1. Історично розробники специфікації JPA порахували, що одиночний пов’язаний об’єкт «легший» за колекцію, і зробили дефолтом EAGER.
  2. Якщо ми виконаємо JPQL-запит entityManager.createQuery("SELECT o FROM Order o", Order.class).getResultList(), Hibernate завантажить список замовлень.
  3. Але оскільки зв’язок @ManyToOne User user за замовчуванням жадібний, для кожного з $N$ замовлень Hibernate виконає окремий SQL SELECT * FROM users WHERE id = ?.
  4. Це породжує класичну катастрофу $N+1$ запитів прямо «з коробки».
  5. Правило: Абсолютно кожен @ManyToOne у проєкті зобов’язаний мати явний fetch = FetchType.LAZY.

2. Що станеться, якщо у двонапрямленому зв’язку ми додали дитину в колекцію order.getItems().add(item), але НЕ викликали item.setOrder(order), і зберегли замовлення через entityManager.persist(order)?

Відповідь:

  1. Оскільки на зв’язку стоїть mappedBy = "order", Hibernate при скиданні змін у БД перевіряє тільки стан полів сутності OrderItem.
  2. У полі item.order міститься null.
  3. Hibernate згенерує INSERT INTO order_items (order_id, ...) VALUES (NULL, ...).
  4. Якщо в таблиці колонка order_id позначена як NOT NULL, СУБД негайно викине помилку: DataIntegrityViolationException: column "order_id" violates not-null constraint.

3. Чому використання java.util.Set замість java.util.List для @OneToMany зв’язків запобігає декартовому добутку (Cartesian Product), але вимагає граничної акуратності з equals() та hashCode()?

Відповідь:

  1. Якщо сутність завантажує дві колекції List одночасно через JOIN FETCH, Hibernate викидає виняток MultipleBagFetchException задля уникнення декартового множення рядків. Використання Set знімає це обмеження.
  2. Проте сутності в Set ідентифікуються за equals() та hashCode(). Якщо в сутності ще не згенеровано ID (id == null), некоректна реалізація equals (що порівнює за бізнес-полями або дефолтний Object.hashCode()) призведе до того, що колекція порахує два різні незбережені об’єкти дублікатами і втратить один із них при додаванні в Set.

4. Чи можна використовувати @JoinColumn одночасно з атрибутом mappedBy в одній анотації @OneToMany?

Відповідь:

  1. Категорично ні. Ці атрибути є взаємовиключними.
  2. mappedBy повідомляє: «Я не керую зовнішнім ключем, дивись на поле у пов’язаному класі».
  3. @JoinColumn повідомляє: «Я керую зовнішнім ключем, ось ім’я фізичної колонки в таблиці».
  4. Спроба вказати їх одночасно призведе до винятку ініціалізації метамоделі Hibernate: org.hibernate.AnnotationException: Association has both mappedBy and @JoinColumn: com.example.Order.items.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«У зв’язках @ManyToOne та @OneToMany власником зовнішнього ключа завжди є сторона @ManyToOne (дочірня сутність). Сторона @OneToMany є зворотною і зобов’язана оголошувати атрибут mappedBy. Критичне правило: @ManyToOne завжди повинен бути FetchType.LAZY, оскільки за замовчуванням у JPA він EAGER, що провокує проблему N+1. Для двонапрямлених зв’язків обов’язкові синхронізуючі хелпер-методи (addItem/removeItem), інакше зовнішній ключ у БД запишеться як NULL. Однонапрямлених @OneToMany без mappedBy слід уникати через зайві SQL UPDATE, а для висококардинальних зв’язків (тисячі дітей) колекцію @OneToMany потрібно видаляти з моделі та використовувати репозиторії з пагінацією».

Ключові метрики для Production-моніторингу

  • Кількість SELECT при завантаженні одиничних сутностей: перевірка відсутності забутих EAGER на @ManyToOne.
  • Кількість UPDATE одразу після INSERT: виявлення неоптимальних однонапрямлених зв’язків @OneToMany.

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «@ManyToOne за замовчуванням лінивий, як і @OneToMany» — Груба помилка. @ManyToOne за специфікацією JPA за замовчуванням EAGER!
  • ❌ «mappedBy можна вказувати на анотації @ManyToOne» — У @ManyToOne немає атрибута mappedBy, він є тільки на зворотному боці.
  • ❌ «Щоб додати дитину, достатньо просто викликати order.getItems().add(item)» — У БД запишеться NULL, якщо не викликати item.setOrder(order).
  • ❌ «Я зберігаю список із 50 000 коментарів прямо в колекції @OneToMany сутності Post» — Це гарантований OutOfMemoryError у продакшені.

Пов’язані теми