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

У чому особливості bidirectional relationships

Двонапрямлений зв'язок (Bidirectional Relationship) у JPA та Hibernate — це відношення між двома сутностями, за якого навігація по об'єктному графу можлива в обох напрямках: від...

Двонапрямлений зв’язок (Bidirectional Relationship) у JPA та Hibernate — це відношення між двома сутностями, за якого навігація по об’єктному графу можлива в обох напрямках: від батька до дітей (user.getOrders()) та від дитини до батька (order.getUser()). Попри уявну зручність, двонапрямлені зв’язки містять приховані пастки: розсинхронізацію пам’яті JVM і бази даних, порушення контрактів equals() / hashCode() у колекціях Set та фатальні StackOverflowError при серіалізації й логуванні.


🟢 Junior Level

Як влаштований двонапрямлений зв’язок

У реляційній базі даних зв’язки завжди однонапрямлені: зовнішній ключ фізично зберігається тільки в одній таблиці (на стороні «багато»).

У Java-коді сутності двонапрямленого зв’язку діляться на дві ролі:

  1. Власник зв’язку (Owning Side): Містить анотацію @ManyToOne та @JoinColumn. Тільки ця сторона визначає, яке значення буде фізично записано в колонку зовнішнього ключа в БД.
  2. Зворотний бік (Inverse Side): Містить анотацію @OneToMany(mappedBy = "..."). Вона лише вказує ім’я поля власника зв’язку та повністю ігнорується Hibernate при обчисленні змін зовнішнього ключа.
// 1. Owning Side (Власник FK):
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user; // Зміна цього поля змінює FK у базі!
}

// 2. Inverse Side (Зворотний бік):
@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // mappedBy = "user" вказує на поле 'user' у класі Order
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Order> orders = new ArrayList<>();
}
       БАЗА ДАНИХ (1 колонка FK)            ПАМ'ЯТЬ JVM (2 незалежні посилання)
       
       таблиця orders                        User@0x1
       ┌────┬─────────┬─────────┐            ├── orders: [ Order@0x2 ] ──┐
       │ id │ details │ user_id │                                        │
       ├────┼─────────┼─────────┤                                        ▼
       │ 10 │ "Item"  │    1    │            Order@0x2                   │
       └────┴─────────┴─────────┘            └── user: User@0x1 <────────┘
       (FK тільки в таблиці orders!)         (Два незалежні посилання в Heap!)

🟡 Middle Level

Обов’язок синхронізації посилань в оперативній пам’яті

JPA та Hibernate не займаються синхронізацією посилань у пам’яті Java автоматично. Якщо змінити одну сторону, друга залишиться недоторканою:

// ❌ СИТУАЦІЯ РОЗСИНХРОНІЗАЦІЇ:
User user = new User();
Order order = new Order();

// Варіант 1: змінили тільки Inverse сторону
user.getOrders().add(order);
// order.getUser() == null! У базу запишеться NULL!

// Варіант 2: змінили тільки Owner сторону
order.setUser(user);
// user.getOrders() як і раніше ПОРОЖНІЙ! 
// До закриття сесії колекція в пам'яті не побачить нове замовлення!

Рішення: Захисні допоміжні методи (Defensive Helper Methods)

Обидві сторони зв’язку повинні синхронізуватися атомарно всередині сутності:

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

    public void addOrder(Order order) {
        this.orders.add(order);
        order.setUser(this); // Синхронізуємо власника FK!
    }

    public void removeOrder(Order order) {
        this.orders.remove(order);
        order.setUser(null); // Розриваємо зв'язок власника!
    }
}

Пастка toString() та Lombok: нескінченна рекурсія

Використання анотації @Data або @ToString від Lombok на обох сторонах двонапрямленого зв’язку призводить до аварійної зупинки JVM:

  • user.toString() викликає orders.toString().
  • Кожен order.toString() викликає user.toString().
  • Стек викликів переповнюється: java.lang.StackOverflowError.

Рішення: Завжди виключати зворотний бік зв’язку з генерації рядкового представлення:

@Entity
@ToString(exclude = "orders") // Виключаємо колекцію з toString!
public class User { ... }

@Entity
@ToString(exclude = "user")   // Або виключаємо батька на стороні дитини
public class Order { ... }

🔴 Senior Level

Проблема equals() та hashCode() у колекціях Set

Якщо для зберігання зв’язків використовується Set<Order> замість List<Order>, розробник зобов’язаний перевизначити equals та hashCode. Але використання генерованого первинного ключа (@Id) призводить до зламу структури хеш-таблиці:

// ❌ ГРУБА ПОМИЛКА: equals/hashCode за генерованим ID
@Entity
public class Order {
    @Id
    @GeneratedValue
    private Long id;

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

    @Override
    public int hashCode() {
        return getClass().hashCode(); // або id != null ? id.hashCode() : 0;
    }
}

Анатомія катастрофи в HashSet:

  1. Створюємо новий об’єкт Order у стані Transient (order.getId() == null).
  2. Додаємо його до колекції user.getOrders().add(order):
    • order.hashCode() обчислюється для id == null (наприклад, повертає 0).
    • Об’єкт поміщається в кошик bucket[0] структури HashSet.
  3. Зберігаємо користувача: entityManager.persist(user).
  4. СУБД генерує первинний ключ: поле id змінюється з null на 42.
  5. Тепер викликаємо user.getOrders().contains(order):
    • HashSet обчислює новий хеш-код для id == 42 і шукає в кошику bucket[42].
    • Кошик bucket[42] порожній! Метод contains() повертає false!
    • Об’єкт «загубився» всередині HashSet, його неможливо видалити або знайти.

Канонічні рішення проблеми equals/hashCode:

  • Варіант 1 (Ідеальний): Використовувати природний бізнес-ключ (Natural ID / Business Key), незмінний протягом усього життя об’єкта (наприклад, UUID, номер паспорта, артикул SKU, email).
  • Варіант 2 (Рекомендація Влада Міхалчі): Фіксований хеш-код класу в поєднанні з перевіркою ID:
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Order)) return false;
        Order other = (Order) o;
        return id != null && id.equals(other.getId());
    }
    
    @Override
    public int hashCode() {
        // Константний hashCode гарантує, що об'єкт ніколи не змінить кошик у HashSet!
        return getClass().hashCode(); 
    }
    

4 Tricky Questions

1. Якщо в транзакції змінити ТІЛЬКИ inverse-сторону: user.getOrders().add(order), а поле order.setUser(user) залишити null, чи відправить Hibernate SQL UPDATE при коміті?

Відповідь:

  1. Ні, SQL UPDATE згенерований не буде!
  2. Механізм Dirty Checking у Hibernate відстежує зміни виключно на стороні власника зв’язку (Owning Side) — тобто в об’єкті з @ManyToOne.
  3. Поле mappedBy на стороні @OneToMany повідомляє рушію: «Ця колекція суто дзеркальна, не перевіряй її для побудови SQL».
  4. У базі даних колонка user_id залишиться рівною NULL (або старим значенням), попри те, що в пам’яті список user.getOrders() містить замовлення.

2. Що станеться, якщо у двонапрямленому зв’язку сутності Order та Item обидві використовують каскад CascadeType.ALL одна на одну?

Відповідь:

  1. Виникає взаємний циклічний каскад.
  2. При виклику entityManager.persist(order) Hibernate обходить дерево: order -> item -> order -> item.
  3. Завдяки внутрішній карті StatefulPersistenceContext.entitiesByKey Hibernate розпізнає вже відвідані об’єкти і не йде в нескінченну рекурсію JVM.
  4. Проте на фазі скидання (flush) виникає конфлікт порядку вставки в ActionQueue: щоб вставити замовлення, потрібен ID позиції, а щоб вставити позицію, потрібен ID замовлення. Це призведе або до зайвих штрафних UPDATE, або до винятку TransientPropertyValueException.

3. Якщо ми завантажили Order через find(), і звернулися до order.getUser(), чи створиться новий Java-екземпляр User?

Відповідь:

  1. Якщо сутність User із цим первинним ключем вже була завантажена в поточну сесію (PersistenceContext), Hibernate поверне рівно той самий екземпляр із кешу першого рівня L1 (assert order.getUser() == existingUser;).
  2. Якщо User ще не був завантажений, а зв’язок налаштований як FetchType.LAZY, Hibernate створить динамічний ByteBuddy-проксі цільового класу.
  3. В обох випадках дублювання реальних керованих об’єктів у межах однієї сесії не відбувається завдяки гарантії ідентичності (Identity Guarantee).

4. Чому при двонапрямленому зв’язку @OneToOne ліниве завантаження (FetchType.LAZY) не працює на стороні mappedBy без байт-код інструментування?

Відповідь:

  1. На стороні власника (@OneToOne @JoinColumn) колонка fk_id знаходиться прямо в таблиці. Якщо значення в колонці NULL, Hibernate записує null, якщо значення є — створює лінивий проксі.
  2. На зворотній стороні (@OneToOne(mappedBy = "...")) колонка зовнішнього ключа відсутня. Щоб дізнатися, чи існує взагалі пов’язаний рядок у залежній таблиці (чи має поле бути null або посиланням на проксі), Hibernate змушений виконати негайний SQL SELECT у пов’язану таблицю!
  3. У результаті FetchType.LAZY на зворотній стороні @OneToOne мовчки вироджується у FetchType.EAGER.

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

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

«Двонапрямлений зв’язок у JPA об’єднує сторону власника зовнішнього ключа (@ManyToOne) та зворотний бік (@OneToMany(mappedBy = "...")). База даних зберігає тільки один FK, але в оперативній пам’яті посилання незалежні. Головний обов’язок розробника — підтримувати консистентність посилань у пам’яті через хелпер-методи (addOrder/removeOrder), оскільки Hibernate відстежує зміни тільки зі сторони @ManyToOne. Головні ризики двонапрямлених зв’язків: переповнення стека (StackOverflowError) у toString()/серіалізації (виправляється через @ToString.Exclude та DTO) та злам контракту HashSet при генерації equals()/hashCode() за тимчасовими первинними ключами».

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

  • Null Foreign Key Inserts Rate: помилки вставки рядків із null зовнішнім ключем через відсутність синхронізації зворотного боку.
  • StackOverflowError Count: критичний індикатор забутого рекурсивного мапінгу в логуванні або JSON-серіалізаторах.

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

  • ❌ «Hibernate сам синхронізує посилання в пам’яті: якщо я викликав order.setUser(u), він додасть замовлення в u.getOrders()» — Неправда. Hibernate не мутує Java-колекції в пам’яті.
  • ❌ «mappedBy можна вказувати на стороні @ManyToOne» — mappedBy буває тільки на зворотному боці (Inverse Side).
  • ❌ «Я реалізую equals() та hashCode() просто за полем id сутності» — Це зламає HashSet при переході з Transient у Managed.
  • ❌ «Використовую анотацію @Data від Lombok на обох сторонах зв’язку» — Призведе до гарантованого StackOverflowError у toString() та hashCode().

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