Spring REST API & Data JPA
37/46
Bài 37 / 46~12 phútRelationships & TransactionsLộ trình · chặng 16/21Miễn phí… lượt xem

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?

AnnotationQuan hệ đối tượngVị trí đặt Foreign Key (FK)Fetch mặc định
@ManyToOneNhiều → MộtNằm ở bảng Nhiều (ví dụ: tasks.project_id)EAGER (Cần override về LAZY)
@OneToManyMột → NhiềuPhía nghịch đảo của @ManyToOne qua mappedByLAZY
@OneToOneMột ↔ MộtNằm ở bảng của Owning SideEAGER (Cần override về LAZY)
@ManyToManyNhiều ↔ NhiềuBả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.
Java
@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
    }
}

Đối chiếu hai cách gắn task vào project: chỉ chạm inverse side thì task.project vẫn null và câu INSERT mang project_id NULL nên vi phạm ràng buộc nullable false, còn chạm owning side thì task.project trỏ tới Project id 7 và câu INSERT mang đúng project_id bằng 7

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:

  1. Hiệu ứng dây chuyền (Chain EAGER): Order eager load Customer → Customer eager load Address → Address eager load City. Một truy vấn tìm đơn hàng kéo theo 4-5 phép JOIN không cần thiết.
  2. Lãng phí băng thông: Danh sách 50 Task chỉ cần hiển thị tiêu đề nhưng lại ép database phải JOIN và tải toàn bộ đối tượng Project đồ sộ đi kèm.
Java
// 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).

Trục thời gian một request: transaction mở và Session sống, câu SELECT trả về task với project là proxy chưa có dữ liệu, service return thì commit và Session đóng, sau đó controller gọi getProject getName nên proxy không còn Session để bắn câu SELECT và ném LazyInitializationException

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:

Java
@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ì?

  1. Chỉ thêm phần tử vào Inverse Side: Gọi project.getTasks().add(task) mà quên task.setProject(project). Kết quả là lệnh INSERT tasks mang project_id = NULL, vi phạm ràng buộc cơ sở dữ liệu.
  2. Để 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.
  3. 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

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Trong mối quan hệ hai chiều giữa Project và Task, phía nào đóng vai trò là Owning Side?
  2. Q2
    Hiệ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?
  3. Q3
    Theo đặc tả JPA Spec, các annotation nào sau đây có chiến lược fetch mặc định là EAGER?
  4. Q4
    Tạ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?
  5. Q5
    Giả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.

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

Bài tiếp theo

Cascade Operations và Xử Lý Triệt Để Bài Toán N+1 Query