Spring REST API & Data JPA/Associations & Fetch Type — @OneToMany, @ManyToOne, LAZY/EAGER, LazyInitializationException
37/46
Bài 37 / 46~14 phútRelationships & TransactionsMiễn phí lượt xem

Associations & Fetch Type — @OneToMany, @ManyToOne, LAZY/EAGER, LazyInitializationException

Bài này bóc 4 kiểu association JPA, cơ chế owning side vs inverse side (FK nằm ở đâu trong schema), fetch type mặc định của từng annotation và lý do nên override về LAZY, cùng LazyInitializationException khi truy cập proxy ngoài transaction.

TL;DR: JPA map 4 kiểu quan hệ object sang foreign key: @OneToOne, @OneToMany, @ManyToOne, @ManyToMany. Side nào có @JoinColumn (giữ FK trong bảng DB) là owning side — Hibernate chỉ nhìn owning side để sinh SQL INSERT/UPDATE; inverse side dùng mappedBy và không ảnh hưởng schema. Fetch type: @ManyToOne@OneToOne mặc định EAGER theo JPA spec — luôn override về LAZY trong production. @LazyInitializationException xảy ra khi truy cập proxy ngoài transaction — fix bằng cách đảm bảo load trong transaction hoặc dùng DTO. Cascade và N+1 problem đào sâu ở bài tiếp theo.

Scenario: Project quản lý Task

Giả sử bạn đang build REST API quản lý dự án phần mềm. Schema cần biểu diễn:

  • Một Project có nhiều Task (1 → N).
  • Mỗi Task thuộc đúng một Project (N → 1).
  • Một User có nhiều Role, một Role thuộc nhiều User (M ↔ N).
  • Một Order có đúng một Invoice (1 ↔ 1).

Bốn nhu cầu này map thẳng sang 4 annotation JPA. Câu hỏi thực tế: Hibernate sinh SQL từ annotation như thế nào?fetch type ảnh hưởng gì tới hiệu năng?

1. Bốn kiểu association

flowchart LR
    A["@OneToOne<br/>(Order - Invoice)"]
    B["@OneToMany<br/>(Project - Tasks)"]
    C["@ManyToOne<br/>(Task - Project)"]
    D["@ManyToMany<br/>(User - Roles)"]
AnnotationCardinalityFK nằm ở đâu trong schema
@OneToOne1 ↔ 1FK ở 1 trong 2 bảng (bên owning)
@ManyToOneN → 1FK ở bảng N (ví dụ tasks.project_id)
@OneToMany1 → NFK ở bảng N — inverse của @ManyToOne
@ManyToManyM ↔ NJoin table riêng (ví dụ user_roles)

@OneToMany + @ManyToOne là cặp quan trọng nhất vì hầu hết domain model đều có quan hệ parent-child. Bài này đào sâu cặp này làm trung tâm, sau đó điểm qua @OneToOne@ManyToMany.

2. Owning side vs inverse side — FK nằm ở đâu quyết định

Đây là phần dễ nhầm nhất khi mới học JPA. Khi hai entity liên kết bidirectional, Hibernate cần biết bảng nào giữ FK để sinh DDL và DML đúng.

Quy tắc: side có @JoinColumnowning side — bảng của entity này có cột FK. Side kia dùng mappedBy và là inverse side — Hibernate bỏ qua inverse khi sinh INSERT/UPDATE.

@Entity
public class Task {                              // OWNING SIDE

    @Id @GeneratedValue
    private Long id;
    private String title;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "project_id", nullable = false)   // <-- FK o bang tasks
    private Project project;
}

@Entity
public class Project {                           // INVERSE SIDE

    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "project",             // <-- chi to field Task.project
               fetch = FetchType.LAZY,
               cascade = CascadeType.ALL,
               orphanRemoval = true)
    private List<Task> tasks = new ArrayList<>();
}

Schema được sinh ra:

CREATE TABLE projects (
    id      BIGSERIAL PRIMARY KEY,
    name    VARCHAR(100)
);

CREATE TABLE tasks (
    id         BIGSERIAL PRIMARY KEY,
    title      VARCHAR(200),
    project_id BIGINT NOT NULL REFERENCES projects(id)   -- FK o day
);
flowchart TB
  subgraph OWN["Owning side -- Task (co @JoinColumn)"]
    direction TB
    T1["@ManyToOne fetch=LAZY"]
    T2["@JoinColumn(name=project_id)<br/>FK nam o bang tasks"]
  end
  subgraph INV["Inverse side -- Project (mappedBy)"]
    direction TB
    P1["@OneToMany mappedBy=project"]
    P2["Hibernate bo qua khi sinh INSERT/UPDATE"]
  end
  subgraph DB["DB schema"]
    direction LR
    D1["projects(id, name)"]
    D2["tasks(id, title, project_id FK)"]
  end
  T2 -->|"sinh ra column"| D2
  P1 -. "chi doc, khong sinh FK" .-> D2
  OWN --- INV

Tại sao owning/inverse tồn tại?

Vì SQL chỉ có một chỗ lưu FK — bảng có cột đó. JPA cần biết bảng nào để:

  1. Sinh DDL đúng (chỉ một bảng có cột FK).
  2. Sinh SQL INSERT/UPDATE đúng — chỉ owning side mang giá trị FK thực.

Nếu không có khái niệm owning/inverse, Hibernate có thể sinh FK ở cả hai bảng, hoặc gửi UPDATE thừa. mappedBy là cách nói với Hibernate: "relationship này đã được quản lý ở phía kia, đừng lặp lại".

Pitfall: sửa inverse side không persist FK

// SAI -- chi set inverse side
project.getTasks().add(task);          // set inverse
// task.project van NULL → INSERT tasks SET project_id = NULL → nullable = false → exception

// DUNG -- phai set owning side
task.setProject(project);             // set owning -> Hibernate doc gia tri nay khi INSERT
project.getTasks().add(task);          // sync in-memory list de nhat quan trong current tx

Pattern tốt nhất là dùng helper method để tránh quên:

@Entity
public class Project {

    @OneToMany(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Task> tasks = new ArrayList<>();

    public void addTask(Task task) {
        tasks.add(task);
        task.setProject(this);        // sync owning side
    }

    public void removeTask(Task task) {
        tasks.remove(task);
        task.setProject(null);         // sync owning side -> trigger orphan removal
    }
}

3. Fetch type — LAZY vs EAGER

fetch parameter quyết định Hibernate tải association khi nào:

FetchTypeHibernate tải khi nàoJPA default cho
LAZYLần đầu truy cập field (proxy)@OneToMany, @ManyToMany
EAGERCùng query với entity chính (JOIN)@ManyToOne, @OneToOne

Cơ chế bên dưới — LAZY proxy

Khi @ManyToOne(fetch = FetchType.LAZY), Hibernate không tải Project khi load Task. Thay vào đó, nó tạo một CGLIB proxy — subclass giả của Project — và set vào field task.project. Proxy này chỉ chứa id, chưa có data thật.

Task task = taskRepo.findById(42L).orElseThrow();
// SQL: SELECT id, title, project_id FROM tasks WHERE id = 42
// task.project = proxy(id=7), CHUA co name, description, v.v.

String name = task.getProject().getName();
// Luc nay proxy intercept getName() -> trigger SQL thu 2:
// SELECT id, name FROM projects WHERE id = 7
// Tra ve object that, ghi de proxy

Hai query riêng biệt, chỉ tải khi cần. Đây là lợi thế LAZY — tránh load data không dùng tới.

Cơ chế bên dưới — EAGER JOIN

Khi @ManyToOne(fetch = FetchType.EAGER), Hibernate sinh JOIN ngay từ query đầu:

-- EAGER: 1 query, JOIN ngay
SELECT t.id, t.title, t.project_id, p.id, p.name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
WHERE t.id = 42;

Không có query thứ hai — project đã fully loaded. Nghe tiện, nhưng có vấn đề lớn ở production.

Tại sao nên override về LAZY hết

Vấn đề 1 — Chain EAGER: Order eager load Customer, Customer eager load Address, Address eager load Country... Mỗi findById(orderId) kéo theo 4-5 JOIN dù bạn chỉ cần order.total.

Vấn đề 2 — Load dữ liệu không cần: list 100 Task để hiển thị title và deadline — không cần Project detail. EAGER load thêm 100 Project lãng phí.

Vấn đề 3 — Khó đoán: mỗi @ManyToOne EAGER ẩn thêm 1 JOIN. Entity phức tạp → query không đoán được cost.

Khuyến nghị production — LAZY hết, fetch explicit khi cần:

@Entity
public class Order {

    @ManyToOne(fetch = FetchType.LAZY)          // override default EAGER
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @OneToOne(fetch = FetchType.LAZY)           // override default EAGER
    @JoinColumn(name = "invoice_id")
    private Invoice invoice;

    @OneToMany(mappedBy = "order")              // da LAZY theo default
    private List<OrderItem> items;

    @ManyToMany                                  // da LAZY theo default
    private Set<Tag> tags;
}

Khi thực sự cần data association, dùng JOIN FETCH trong @Query hoặc @EntityGraph — xem Cascade & N+1 problem để biết cách fetch explicit đúng cách.

4. LazyInitializationException — proxy ngoài transaction

Đây là lỗi phổ biến nhất khi mới dùng JPA với LAZY fetch.

// Controller
@GetMapping("/tasks/{id}")
public TaskDto getTask(@PathVariable Long id) {
    Task task = taskService.findById(id);
    // Transaction da ket thuc khi service return
    // Hibernate Session da dong

    String projectName = task.getProject().getName();
    // BOOM: LazyInitializationException
    // "could not initialize proxy - no Session"
    return TaskDto.from(task, projectName);
}

Cơ chế: Hibernate Session (persistence context) gắn liền với transaction. Khi transaction kết thúc, session đóng. Proxy của task.project cần session để gửi query tới DB — nhưng session đã không còn → exception.

sequenceDiagram
  participant C as Controller
  participant S as Service
  participant H as Hibernate
  participant DB as Database

  C->>S: findById(id)
  S->>H: open tx + session
  H->>DB: SELECT task WHERE id=42
  DB-->>H: task row
  H-->>S: Task(project=proxy)
  S-->>C: return task (TX CLOSED, session dong)
  C->>H: task.getProject().getName()
  H-->>C: LazyInitializationException -- no Session

Ba cách xử lý

Cách 1 — Map sang DTO trong transaction (recommend):

@Service
public class TaskService {

    @Transactional(readOnly = true)
    public TaskDto findById(Long id) {
        Task task = taskRepo.findById(id).orElseThrow();
        // Van con trong transaction -> proxy co the load
        String projectName = task.getProject().getName();   // OK
        return new TaskDto(task.getId(), task.getTitle(), projectName);
        // DTO ra ngoai, khong co proxy -> khong co exception
    }
}

DTO là plain object — không có proxy, không cần session. Controller nhận DTO, serialize JSON thoải mái.

Cách 2 — Fetch trước với @EntityGraph:

@EntityGraph(attributePaths = {"project"})
Optional<Task> findById(Long id);
// SQL: SELECT t.*, p.* FROM tasks t JOIN projects p ON t.project_id = p.id WHERE t.id = ?
// project da load day du -> khong can proxy -> khong exception

Cách 3 — Tắt OSIV (Open Session in View), bắt buộc load trong service:

Spring Boot mặc định spring.jpa.open-in-view: true — session giữ mở suốt request, che lỗi LAZY ngoài transaction. Đây là anti-pattern production vì giữ DB connection lâu. Tắt đi:

spring:
  jpa:
    open-in-view: false

Sau khi tắt, LazyInitializationException lộ ngay ở dev local — tốt hơn là lộ ở production. Fix đúng cách: map DTO trong service.

5. @OneToOne — cặp đặc biệt

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "invoice_id")     // Order la owning side
    private Invoice invoice;
}

@Entity
public class Invoice {
    @Id @GeneratedValue
    private Long id;
    private BigDecimal total;

    @OneToOne(mappedBy = "invoice", fetch = FetchType.LAZY)
    private Order order;                  // inverse side
}

Schema:

CREATE TABLE orders (
    id         BIGSERIAL PRIMARY KEY,
    invoice_id BIGINT UNIQUE REFERENCES invoices(id)   -- FK kem UNIQUE
);

Pitfall LAZY với inverse @OneToOne: khi Order là inverse side (mappedBy), Hibernate không thể tạo proxy null-safe cho order trên Invoice. Lý do: phải query DB để biết "có Order nào trỏ vào Invoice này không" — nếu không có thì trả null, có thì trả proxy. Không thể LAZY cho trường hợp nullable này.

Workaround: đặt FK ở bên bạn thực sự cần lazy (thường là owning side như ví dụ trên), tránh mappedBy trên @OneToOne nếu cần LAZY thật sự.

6. @ManyToMany — join table

@Entity
public class User {

    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(
        name = "user_roles",
        joinColumns = @JoinColumn(name = "user_id"),
        inverseJoinColumns = @JoinColumn(name = "role_id")
    )
    private Set<Role> roles = new HashSet<>();    // Set, khong phai List
}

@Entity
public class Role {

    @ManyToMany(mappedBy = "roles", fetch = FetchType.LAZY)
    private Set<User> users = new HashSet<>();
}

Schema:

CREATE TABLE user_roles (
    user_id BIGINT REFERENCES users(id),
    role_id BIGINT REFERENCES roles(id),
    PRIMARY KEY (user_id, role_id)
);

Tại sao dùng Set thay List: List trong Hibernate là "bag" — unordered, cho phép duplicate. JOIN FETCH hai List cùng lúc gây MultipleBagFetchException. Set tránh vấn đề này. Ngoài ra, Set với @ManyToMany semantically đúng hơn (role không duplicate với user).

Khi nào convert sang entity intermediate: @ManyToMany đơn thuần không lưu được extra field trên join table (ví dụ grantedAt, grantedBy). Khi cần, tạo entity UserRole:

@Entity
public class UserRole {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "role_id")
    private Role role;

    private Instant grantedAt;
    private String grantedBy;
}

Convert sớm khi nghi ngờ cần extra field — refactor sau tốn hơn nhiều.

Liên hệ các bài khác

  • Entity mapping — @Entity, @Id: định nghĩa entity và primary key là nền tảng của mọi association. Bài này giải thích tại sao @Id bắt buộc và ảnh hưởng tới cách Hibernate track entity state.
  • Cascade & N+1 problem: bài này chỉ giới thiệu association và fetch type. Cascade (propagate operation parent → child) và N+1 problem (lazy load trong loop) là hệ quả trực tiếp — đào sâu ở bài tiếp theo.
  • Transactions — @Transactional: LazyInitializationException xảy ra khi session đóng sau transaction. Bài transactions giải thích transaction boundary và tại sao service method cần @Transactional để lazy load hoạt động.

Tóm tắt

  • 4 association: @OneToOne, @ManyToOne, @OneToMany, @ManyToMany — map 4 cardinality khác nhau xuống schema SQL.
  • Owning side@JoinColumn — bảng này chứa FK. Hibernate chỉ nhìn owning để sinh INSERT/UPDATE.
  • Inverse side dùng mappedBy — không ảnh hưởng schema. Sửa inverse side không persist FK.
  • Fetch default: @ManyToOne@OneToOne mặc định EAGER (JPA spec). @OneToMany@ManyToMany mặc định LAZY.
  • Luôn override LAZY cho @ManyToOne@OneToOne — tránh chain JOIN không kiểm soát.
  • LAZY cơ chế: Hibernate tạo CGLIB proxy, query thật gửi lần đầu truy cập field.
  • LazyInitializationException: proxy truy cập sau khi session đóng. Fix: map DTO trong @Transactional service, hoặc fetch trước với @EntityGraph.
  • Helper method: bidirectional cần sync cả 2 side — addTask/removeTask đóng gói logic để tránh quên.

Tự kiểm tra

Tự kiểm tra
Q1
Tại sao khai báo mappedBy = "project" trên @OneToMany của Project? Nếu bỏ mappedBy đi thì Hibernate sinh schema như thế nào?

mappedBy nói với Hibernate rằng relationship này đã được quản lý bởi field project trên entity Task — tức là FK (project_id) nằm ở bảng tasks, không phải projects. Hibernate dùng thông tin này để tránh sinh SQL thừa khi INSERT/UPDATE.

Nếu bỏ mappedBy, Hibernate coi đây là unidirectional relationship mới, tách biệt với @ManyToOne trên Task. Kết quả: Hibernate sinh thêm một join table project_tasks (project_id, task_id) — hoàn toàn ngoài ý muốn, vừa thừa, vừa gây duplicate data.

Tóm lại: mappedBy là cách nói "tôi là inverse side, đừng sinh thêm bảng/cột cho relationship này".

Q2
Đoạn code sau có bug không? Nếu có, bug gì và fix ra sao?
@Transactional
public Task createTask(Long projectId, String title) {
  Project project = projectRepo.findById(projectId).orElseThrow();
  Task task = new Task(title);
  project.getTasks().add(task);   // chi set inverse side
  return taskRepo.save(task);
}

Có bug. project.getTasks().add(task) chỉ set inverse side (@OneToMany trên Project). Hibernate bỏ qua inverse side khi sinh SQL — nó chỉ đọc owning side (task.project) để lấy giá trị FK.

task.project vẫn là null, Hibernate INSERT task với project_id = NULL. Nếu cột có nullable = false thì ném constraint violation ngay. Nếu nullable, task được save với project_id = NULL — task bị "mồ côi".

Fix đúng: set owning side trước, hoặc dùng helper method:

// Option 1: set ca 2 side thu cong
task.setProject(project);          // owning side
project.getTasks().add(task);       // sync in-memory

// Option 2: dung helper method (recommend)
project.addTask(task);             // addTask() goi ca 2
Q3
JPA spec định nghĩa fetch default của @ManyToOne là gì? Tại sao production nên override về LAZY?

JPA spec định nghĩa @ManyToOne@OneToOne mặc định là EAGER. Spec viết khi microservice chưa phổ biến — single record load thêm 1 row nghe không đáng lo.

Production override về LAZY vì 3 lý do:

1. Chain EAGER: Order EAGER load Customer, Customer EAGER load Address, Address EAGER load Country. Một findById(orderId) kéo theo 4 JOIN dù chỉ cần order.total.

2. Load dữ liệu không dùng: list 100 task chỉ để hiển thị title — EAGER load thêm 100 project row lãng phí băng thông và bộ nhớ.

3. Khó đoán cost: LAZY explicit fetch tốt hơn EAGER ẩn. Với LAZY, bạn biết chính xác khi nào thêm query xảy ra; với EAGER, JOIN ngầm xảy ra mọi nơi entity được load.

Override cụ thể: @ManyToOne(fetch = FetchType.LAZY)@OneToOne(fetch = FetchType.LAZY). Khi cần association, fetch explicit bằng JOIN FETCH hoặc @EntityGraph.

Q4
Đoạn code sau ném LazyInitializationException ở dòng nào? Giải thích cơ chế và cách fix không dùng OSIV.

Exception ném tại task.getProject().getName() trong controller — sau khi service method đã return và transaction đã đóng.

Cơ chế: Hibernate Session (persistence context) tồn tại trong phạm vi transaction. Khi @Transactional service method kết thúc, session đóng. task.project là CGLIB proxy — nó cần session còn mở để gửi query tới DB lần đầu truy cập. Vì session đã đóng, proxy throw LazyInitializationException.

Fix đúng — map DTO trong service (không cần OSIV):

@Transactional(readOnly = true)
public TaskDto findById(Long id) {
  Task task = taskRepo.findById(id).orElseThrow();
  // Van con trong tx -> session con mo -> proxy load OK
  String projectName = task.getProject().getName();
  return new TaskDto(task.getId(), task.getTitle(), projectName);
}
// DTO la plain object, khong co proxy, controller nhan DTO la an toan

Ngoài ra có thể dùng @EntityGraph(attributePaths = project) trên repository method để Hibernate JOIN load sẵn project — không cần proxy, không cần session sau transaction.

Tắt OSIV (spring.jpa.open-in-view: false) để lỗi lộ sớm ở dev thay vì ẩn và bùng ở production khi connection pool cạn kiệt.

Q5
So sánh @ManyToMany dùng List vs Set. Khi nào nên convert sang entity intermediate thay vì dùng @ManyToMany trực tiếp?

List vs Set trong @ManyToMany:

Trong Hibernate, List là "bag" — unordered, cho phép duplicate. Khi JOIN FETCH hai collection kiểu List cùng lúc, Hibernate ném MultipleBagFetchException vì SQL Cartesian product của 2 bag không thể deduplicate an toàn. Set không phải bag — Hibernate cho phép JOIN FETCH 1 bag + 1 set.

Ngoài ra, Set semantically đúng hơn cho @ManyToMany: một user không có cùng role 2 lần. List với @ManyToMany cũng gây vấn đề khi Hibernate sinh DELETE toàn bộ rồi INSERT lại khi thêm/xóa element — rất tốn kém với collection lớn.

Khi nào convert sang entity intermediate: @ManyToMany đơn thuần không lưu được extra field trên join table. Cần grantedAt, grantedBy, expiresAt trên quan hệ user-role? Tạo entity UserRole với 2 @ManyToOne. Quy tắc: nếu có bất kỳ field nào ngoài 2 FK trên join table — convert ngay, refactor sau tốn hơn nhiều.

Bài tiếp theo: Cascade & N+1 problem

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên