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

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

Làm chủ cơ chế Cascade, orphanRemoval và giải quyết dứt điểm lỗi N+1 Query bằng JOIN FETCH, @EntityGraph, BatchSize và DTO Projection.

TL;DR: CascadeType lan truyền các thao tác (persist, merge, remove) từ thực thể cha xuống thực thể con; orphanRemoval = true tự động xóa bản ghi con khi bị tách khỏi collection cha. Bài toán N+1 Query là lỗi hiệu năng nghiêm trọng nhất trong JPA khi truy cập lazy association trong vòng lặp (1 câu nạp cha + N câu nạp con). Bốn giải pháp khắc phục triệt để gồm: JOIN FETCH, @EntityGraph, @BatchSize và DTO Projection.

Cơ chế Cascade và orphanRemoval hoạt động ra sao?

Trong quan hệ sở hữu chặt chẽ (parent-child), thực thể con không thể tồn tại độc lập nếu thiếu thực thể cha:

Java
@Entity
public class Project {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @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);
    }

    public void removeTask(Task task) {
        tasks.remove(task);
        task.setProject(null); // Kich hoat orphanRemoval khi commit
    }
}

Phân biệt CascadeType.REMOVE và orphanRemoval = true

Tiêu chíCascadeType.REMOVEorphanRemoval = true
Điều kiện kích hoạtKhi đối tượng cha bị xóa hoàn toàn khỏi DBKhi đối tượng con bị gỡ bỏ khỏi collection của cha
Thao tác mẫuprojectRepo.delete(project)project.getTasks().remove(task)
Hành vi SQLDELETE FROM tasks rồi DELETE FROM projectsChỉ DELETE FROM tasks (Project cha vẫn tồn tại)
Tuyệt đối không cascade REMOVE từ con lên cha

Trên @ManyToOne, không bao giờ đặt cascade = CascadeType.REMOVE. Nếu đặt, xóa 1 Task sẽ xóa sổ toàn bộ Project cha và kéo theo xóa tất cả các Task khác trong project đó!

Vì sao bài toán N+1 Query lại là hiểm họa hiệu năng số một?

Hãy quan sát đoạn mã lấy danh sách dự án và đếm số task:

Java
@Transactional(readOnly = true)
public List<ProjectDto> listProjects() {
    List<Project> projects = repo.findAll(); // 1 cau lenh SQL: Lay 100 projects

    return projects.stream().map(p -> new ProjectDto(
        p.getId(),
        p.getName(),
        p.getTasks().size() // LAZY LOAD: Phat them 1 cau query tren moi project!
    )).toList();
}

Chuỗi câu lệnh SQL phát sinh:

SQL
SELECT * FROM projects; -- 1 cau query lay 100 dong
SELECT * FROM tasks WHERE project_id = 1; -- Query cho project 1
SELECT * FROM tasks WHERE project_id = 2; -- Query cho project 2
-- ... 98 cau query tiep theo ...

Tổng cộng 101 round-trip tới database cho 100 bản ghi!

  • Môi trường dev (10 bản ghi): Chạy mất 10ms, không phát hiện lỗi.
  • Môi trường production (10.000 bản ghi): Chạy mất hơn 500 giây, gây nghẽn toàn bộ connection pool và sập hệ thống.

Đường đi của một vòng lặp: service gọi findAll sinh một câu SELECT trên bảng projects trả về 100 dòng, mỗi vòng lặp chạm collection proxy chưa khởi tạo nên bắn thêm một câu SELECT trên bảng tasks theo project_id, sau 100 vòng là 101 query; chú thích bên phải so chi phí local 11 query mất 11ms với production 10.001 query mất khoảng 500 giây

Bốn phương pháp xử lý triệt để bài toán N+1 Query

1. JOIN FETCH trong @Query (Khuyến nghị chuẩn)

Chỉ thị Hibernate thực hiện phép LEFT JOIN FETCH để nạp sẵn dữ liệu quan hệ ngay trong câu truy vấn đầu tiên:

Java
@Query("SELECT DISTINCT p FROM Project p LEFT JOIN FETCH p.tasks")
List<Project> findAllWithTasks();

Câu lệnh SQL sinh ra chỉ là 1 truy vấn duy nhất nối hai bảng projects và tasks. Từ khóa DISTINCT giúp Hibernate khử trùng lặp bản ghi cha ở tầng Java.

2. Sử dụng @EntityGraph (Declarative)

Giải pháp tương đương JOIN FETCH nhưng được khai báo linh hoạt thông qua annotation:

Java
@EntityGraph(attributePaths = {"tasks"})
List<Project> findAll();

3. Cấu hình Hibernate Batch Size (Giải pháp Quick-Win)

Gom các truy vấn con riêng lẻ thành câu lệnh sử dụng mệnh đề IN:

YAML
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 50

Thay vì 100 câu truy vấn riêng, Hibernate gom lại thành 2 câu lệnh: SELECT * FROM tasks WHERE project_id IN (1, 2, ..., 50).

4. DTO Projection với hàm tổng hợp (Tối ưu tuyệt đối)

Khi chỉ cần hiển thị số lượng (count), không nạp bất kỳ Entity hay collection proxy nào vào bộ nhớ:

Java
public record ProjectSummary(Long id, String name, long taskCount) {}

@Query("""
    SELECT new com.olhub.dto.ProjectSummary(p.id, p.name, COUNT(t.id))
    FROM Project p LEFT JOIN p.tasks t
    GROUP BY p.id, p.name
""")
List<ProjectSummary> findProjectSummaries();

Bảng so sánh 4 giải pháp xử lý N+1 Query

Giải phápSố câu SQLTải Entity vào HeapCông sức refactorTrường hợp áp dụng tối ưu
JOIN FETCH1Đầy đủTrung bìnhCần Entity để cập nhật dữ liệu
@EntityGraph1Đầy đủRất thấpKhai báo linh hoạt theo method
Batch Size1 + ceil(N / batch_size)Đầy đủ0 (Chỉ sửa cấu hình)Hệ thống lớn khó sửa toàn bộ mã nguồn
DTO Projection1❌ KhôngTrung bìnhDanh sách hiển thị đọc (Read-Only)

Xử lý ngoại lệ MultipleBagFetchException ra sao?

Khi thực hiện JOIN FETCH đồng thời trên 2 collection kiểu List:

Java
// GAY LOI: MultipleBagFetchException luc startup
@Query("SELECT p FROM Project p LEFT JOIN FETCH p.tasks LEFT JOIN FETCH p.contributors")
Optional<Project> findFullProject(Long id);

Hibernate ném ngoại lệ vì phép nối 2 collection "Bag" (danh sách không thứ tự) sẽ sinh tích Đề-các (Cartesian Product) làm nhân bản số dòng dữ liệu và Hibernate không thể khử trùng lặp an toàn.

Giải pháp: Tách thành 2 truy vấn riêng biệt trong cùng 1 Transaction để Hibernate nạp tuần tự vào First-Level Cache:

Java
@Transactional(readOnly = true)
public Project getFullProject(Long id) {
    Project p = repo.findByIdWithTasks(id).orElseThrow();
    repo.findByIdWithContributors(id).orElseThrow(); // Hibernate tu dong hop nhat vao p
    return p;
}

Những cạm bẫy phổ biến cần tránh là gì?

  1. Đặt Cascade ALL trên @ManyToOne: Dẫn tới việc xóa dữ liệu cha hàng loạt ngoài ý muốn.
  2. Không phát hiện N+1 sớm ở môi trường Dev: Cần bật log SQL trong môi trường phát triển để kiểm soát số lượng truy vấn phát sinh.
  3. Phân trang kèm JOIN FETCH collection: Gây cảnh báo bộ nhớ nghiêm trọng (Hibernate buộc phải tải toàn bộ bảng về RAM rồi mới phân trang in-memory).

Tự kiểm tra kiến thức

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Bài toán N+1 Query trong JPA phát sinh do nguyên nhân cốt lõi nào?
  2. Q2
    Sự khác biệt cốt lõi giữa 'CascadeType.REMOVE' và 'orphanRemoval = true' là gì?
  3. Q3
    Giải pháp nào sau đây giúp loại bỏ hoàn toàn bài toán N+1 mà KHÔNG cần sửa đổi mã nguồn Java?
  4. Q4
    Tại sao câu lệnh JPQL chứa 'LEFT JOIN FETCH' trên hai collection kiểu List cùng lúc lại gây ra ngoại lệ MultipleBagFetchException?
  5. Q5
    Khi cần hiển thị danh sách 50 dự án kèm số lượng task tương ứng trên giao diện (Read-Only), giải pháp tối ưu hiệu năng nhất là gì?

Bài tiếp theo: @Transactional và AOP Proxy: Cơ Chế Quản Trị Giao Dịch

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 17/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

@Transactional và AOP Proxy: Cơ Chế Quản Trị Giao Dịch