Associations và Fetch Type: Chiến Lược Liên Kết Thực Thể
Phân tích 4 quan hệ JPA, nguyên lý Owning vs Inverse Side, hiểm họa FetchType.EAGER và cách xử lý LazyInitializationException không dùng OSIV.
TL;DR: JPA ánh xạ 4 quan hệ đối tượng (@OneToOne, @ManyToOne, @OneToMany, @ManyToMany) xuống Foreign Key (FK). Phía có @JoinColumn là Owning Side — nơi duy nhất Hibernate nhìn vào để phát sinh SQL INSERT/UPDATE. Mặc định @ManyToOne và @OneToOne là EAGER theo chuẩn JPA spec; trên production bắt buộc phải ghi đè về LAZY. Lỗi LazyInitializationException xảy ra khi truy cập lazy proxy sau khi Session/Transaction đã đóng; xử lý chuẩn bằng cách ánh xạ sang DTO trong Transaction hoặc dùng @EntityGraph.
Bốn kiểu liên kết trong JPA được ánh xạ ra sao?
| Annotation | Quan hệ đối tượng | Vị trí đặt Foreign Key (FK) | Fetch mặc định |
|---|---|---|---|
@ManyToOne | Nhiều → Một | Nằm ở bảng Nhiều (ví dụ: tasks.project_id) | EAGER (Cần override về LAZY) |
@OneToMany | Một → Nhiều | Phía nghịch đảo của @ManyToOne qua mappedBy | LAZY |
@OneToOne | Một ↔ Một | Nằm ở bảng của Owning Side | EAGER (Cần override về LAZY) |
@ManyToMany | Nhiều ↔ Nhiều | Bảng phụ nối trung gian (Join Table) | LAZY |
Phân biệt Owning Side và Inverse Side như thế nào?
Cơ sở dữ liệu quan hệ chỉ lưu khóa ngoại (FK) tại đúng một bảng. Do đó, JPA đặt ra quy tắc:
- Owning Side: Chứa annotation
@JoinColumn(name = "fk_col"). Hibernate chỉ đọc giá trị từ phía này để phát câu lệnh SQL INSERT hoặc UPDATE. - Inverse Side: Khai báo thuộc tính
mappedBy = "fieldName". Đóng vai trò tham chiếu đọc, Hibernate bỏ qua hoàn toàn phía này khi lưu FK.
@Entity
public class Task { // OWNING SIDE
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@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(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Task> tasks = new ArrayList<>();
// Helper method bat buoc de dong bo ca 2 phia
public void addTask(Task task) {
tasks.add(task);
task.setProject(this); // Dong bo Owning Side de Hibernate sinh SQL co project_id
}
}

Tại sao nên ghi đè FetchType.EAGER sang LAZY cho toàn bộ quan hệ?
Mặc định JPA quy định @ManyToOne và @OneToOne mang chiến lược FetchType.EAGER. Đây là nguyên nhân hàng đầu gây sụt giảm hiệu năng:
- Hiệu ứng dây chuyền (Chain EAGER):
Ordereager loadCustomer→Customereager loadAddress→Addresseager loadCity. Một truy vấn tìm đơn hàng kéo theo 4-5 phép JOIN không cần thiết. - Lãng phí băng thông: Danh sách 50
Taskchỉ cần hiển thị tiêu đề nhưng lại ép database phải JOIN và tải toàn bộ đối tượngProjectđồ sộ đi kèm.
// KHUYEN CAO: Luon override ve LAZY tren moi quan he
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
Khi dùng LAZY, Hibernate tạo một đối tượng CGLIB Proxy thay thế. Chỉ khi mã nguồn thực sự gọi getter lấy thuộc tính bên trong, Hibernate mới phát câu lệnh SQL tải dữ liệu thật.
Nguyên nhân và cách xử lý triệt để LazyInitializationException là gì?
Lỗi kinh điển could not initialize proxy - no Session xảy ra khi ứng dụng cố truy cập một thuộc tính Lazy Proxy sau khi Transaction đã kết thúc (Hibernate Session đã đóng).

Cách khắc phục chuẩn: Chuyển đổi DTO trong Transaction
Tuyệt đối không giải quyết bằng cách bật Open Session In View (OSIV) vì OSIV giữ kết nối DB Connection Pool xuyên suốt vòng đời request HTTP, gây cạn kiệt tài nguyên máy chủ.
Cách giải quyết chuẩn mực là map dữ liệu sang DTO thuần ngay bên trong Transaction của tầng Service:
@Service
@RequiredArgsConstructor
public class TaskService {
private final TaskRepository taskRepo;
@Transactional(readOnly = true)
public TaskDto getTaskDetail(Long id) {
Task task = taskRepo.findById(id).orElseThrow();
// Session van con mo trong method: Proxy tai thanh cong
String projectName = task.getProject().getName();
return new TaskDto(task.getId(), task.getTitle(), projectName);
}
}
Đối tượng DTO trả ra ngoài là một POJO/Record thông thường, không còn gắn với Hibernate Proxy, an toàn 100% khi Controller chuyển đổi sang JSON.
Những cạm bẫy phổ biến cần tránh là gì?
- Chỉ thêm phần tử vào Inverse Side: Gọi
project.getTasks().add(task)mà quêntask.setProject(project). Kết quả là lệnhINSERT tasksmangproject_id = NULL, vi phạm ràng buộc cơ sở dữ liệu. - Để mặc định FetchType.EAGER trên @ManyToOne: Gây ra hàng loạt câu lệnh JOIN dư thừa làm chậm hệ thống.
- Bật Open-Session-In-View (OSIV) để né tránh Exception: Khiến database connection pool bị giữ quá lâu, dễ dẫn tới sập hệ thống khi lưu lượng truy cập tăng đột biến.
Tự kiểm tra kiến thức
- Q1Trong mối quan hệ hai chiều giữa Project và Task, phía nào đóng vai trò là Owning Side?
- Q2Hiện tượng gì xảy ra nếu chỉ gọi 'project.getTasks().add(task)' mà không gọi 'task.setProject(project)' trước khi lưu?
- Q3Theo đặc tả JPA Spec, các annotation nào sau đây có chiến lược fetch mặc định là EAGER?
- Q4Tại sao ngoại lệ LazyInitializationException lại phát sinh khi truy cập getter của một đối tượng lazy trong Controller?
- Q5Giải pháp kiến trúc chuẩn mực nhất để phòng tránh LazyInitializationException mà không cần dùng OSIV là gì?
Bài tiếp theo: Cascade Operations & Xử Lý Triệt Để Bài Toán N+1 Query
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Relationships & Transactions — tổng quan
Bài tiếpCascade Operations và Xử Lý Triệt Để Bài Toán N+1 Query
Lộ trình Junior Java Backend Developer
Chặng 16/21 · Spring Data JPA — bài 16/20 của chặng
Ôn phỏng vấn
Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.
Thảo luận & góp ý
Hỏi đáp về bài này