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 và @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
Projectcó nhiềuTask(1 → N). - Mỗi
Taskthuộc đúng mộtProject(N → 1). - Một
Usercó nhiềuRole, mộtRolethuộc nhiềuUser(M ↔ N). - Một
Ordercó đúng mộtInvoice(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? Và 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)"]| Annotation | Cardinality | FK nằm ở đâu trong schema |
|---|---|---|
@OneToOne | 1 ↔ 1 | FK ở 1 trong 2 bảng (bên owning) |
@ManyToOne | N → 1 | FK ở bảng N (ví dụ tasks.project_id) |
@OneToMany | 1 → N | FK ở bảng N — inverse của @ManyToOne |
@ManyToMany | M ↔ N | Join 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 và @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ó @JoinColumn là owning 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 --- INVTạ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 để:
- Sinh DDL đúng (chỉ một bảng có cột FK).
- 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:
| FetchType | Hibernate tải khi nào | JPA default cho |
|---|---|---|
LAZY | Lần đầu truy cập field (proxy) | @OneToMany, @ManyToMany |
EAGER | Cù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
@Idbắ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:
LazyInitializationExceptionxả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 có
@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:
@ManyToOnevà@OneToOnemặc định EAGER (JPA spec).@OneToManyvà@ManyToManymặc định LAZY. - Luôn override LAZY cho
@ManyToOnevà@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
@Transactionalservice, 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
Q1Tạ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 = "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);
}
▸
@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.
Vì 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 2Q3JPA spec định nghĩa fetch default của @ManyToOne là gì? Tại sao production nên override về LAZY?▸
@ManyToOne là gì? Tại sao production nên override về LAZY?JPA spec định nghĩa @ManyToOne và @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) và @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.▸
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 toanNgoà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.
Q5So 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?▸
@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
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