Spring REST API & Data JPA/Cascade & N+1 — propagation, orphanRemoval và bug hiệu năng nguy hiểm nhất JPA
38/46
Bài 38 / 46~14 phútRelationships & TransactionsMiễn phí lượt xem

Cascade & N+1 — propagation, orphanRemoval và bug hiệu năng nguy hiểm nhất JPA

Cascade type lan truyền operation từ entity cha xuống entity con; orphanRemoval xoá entity con khi tách khỏi collection. N+1 problem xảy ra khi lazy load collection trong vòng lặp — 100 project × 1 query tasks = 101 query thay vì 1. Bài này giải thích cơ chế vì sao xảy ra và 4 cách fix (JOIN FETCH, EntityGraph, batch size, DTO projection).

TL;DR: CascadeType cho phép Spring/JPA lan truyền operation (persist, merge, remove…) từ entity cha xuống entity con — @OneToMany(cascade = ALL, orphanRemoval = true) là combo chuẩn cho quan hệ parent-child. orphanRemoval = true xoá entity con khỏi DB khi bị tách khỏi collection cha. N+1 problem là bug hiệu năng nguy hiểm nhất trong JPA: gọi findAll() lấy 100 project (1 query) rồi loop đọc p.getTasks() sinh thêm 100 query lazy load — tổng 101 query thay vì 1 JOIN. Bốn cách fix theo ưu tiên: JOIN FETCH trong @Query, @EntityGraph, @BatchSize, DTO projection với COUNT. MultipleBagFetchException xảy ra khi JOIN FETCH 2 List collection cùng lúc — tích Cartesian không thể dedup.

Bài trước (Associations & fetch type) đã thiết lập @OneToMany, @ManyToOne, và tại sao LAZY là mặc định an toàn. Bài này đào sâu hai điểm còn lại: cascade operationN+1 problem — thứ mà nếu sai sẽ khiến app chạy 100 lần chậm hơn ở production mà không lộ gì ở dev.

1. Cascade — vì sao cần lan truyền operation

Không có cascade, mỗi entity phải được persist, merge, remove thủ công:

// Khong co cascade — verbose va de quen
Project project = new Project("Mobile App");
em.persist(project);                           // phai persist project truoc

Task t1 = new Task("Design", project);
Task t2 = new Task("Develop", project);
em.persist(t1);                                // phai persist tung task
em.persist(t2);

Cascade cho phép operation lan truyền từ entity cha xuống entity con, đúng với ngữ nghĩa "cha tồn tại thì con mới tồn tại":

// Voi cascade = ALL — gon hon
Project project = new Project("Mobile App");
project.addTask(new Task("Design"));
project.addTask(new Task("Develop"));

repo.save(project);
// Spring Data goi em.persist(project)
// CASCADE PERSIST: JPA tu dong persist 2 Task
// SQL: INSERT INTO projects ...; INSERT INTO tasks ...; INSERT INTO tasks ...

Bên dưới, JPA duyệt graph entity theo hướng cascade được khai báo và áp operation tương ứng. Không có gì magic — đây là vòng lặp trên collection, gọi em.persist(child) cho từng phần tử.

2. Các CascadeType và khi nào dùng

@OneToMany(mappedBy = "project",
           cascade = CascadeType.ALL,
           orphanRemoval = true)
private List<Task> tasks = new ArrayList<>();
CascadeTypeOperation propagateVí dụ
PERSISTsave(project) → persist tasks chưa saveTạo project kèm tasks cùng lúc
MERGEsave(detached project) → merge tasksUpdate project + tasks từ DTO
REMOVEdelete(project) → delete tasksXoá project kèm toàn bộ tasks
REFRESHrefresh(project) → reload tasks từ DBĐồng bộ lại state từ DB
DETACHdetach(project) → detach tasksTách entity ra khỏi persistence context
ALLTất cả 5 loại trênQuan hệ parent-child strict ownership

2.1 orphanRemoval — xoá entity con khi tách khỏi collection

orphanRemoval = true bổ sung thêm một hành vi mà CascadeType.REMOVE không có: khi một entity con bị tách khỏi collection cha (không bị xoá cha), JPA sẽ xoá entity con đó khỏi DB.

@Transactional
public void removeTask(Long projectId, Long taskId) {
    Project project = repo.findById(projectId).orElseThrow();
    Task task = project.getTasks().stream()
        .filter(t -> t.getId().equals(taskId))
        .findFirst().orElseThrow();

    project.removeTask(task);
    // orphanRemoval = true: khi tx commit →
    // DELETE FROM tasks WHERE id = ?
    // Khong can goi taskRepo.delete(task) rieng
}

Phân biệt orphanRemovalCascadeType.REMOVE:

CascadeType.REMOVEorphanRemoval = true
TriggerKhi xoá entity chaKhi entity con bị tách khỏi collection
Ví dụdelete(project) → delete tasksproject.getTasks().remove(task) → delete task
Thường dùngCùng với ALLThêm vào @OneToMany parent-child

Cả hai thường khai báo cùng nhau trên @OneToMany parent-child có strict ownership.

2.2 Khi nào KHÔNG cascade

Cascade chi tu cha xuong con — KHONG nguoc lai

@ManyToOne (child → parent) không nên cascade, đặc biệt là REMOVE. Cascade REMOVE từ Task lên Project nghĩa là xoá 1 task → xoá toàn bộ project và tất cả task còn lại — thảm hoạ.

// SAI — cascade REMOVE tu child len parent
@ManyToOne(cascade = CascadeType.REMOVE)
private Project project;
// taskRepo.delete(task) → DELETE FROM projects WHERE id = ?
//   → xoa project → xoa het task khac (vi cascade)

// DUNG — khong cascade
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "project_id", nullable = false)
private Project project;

Quy tắc: cascade chỉ đặt trên @OneToMany (cha → con), không đặt cascade REMOVE/ALL trên @ManyToOne (con → cha).

3. Cơ chế bên dưới — N+1 problem

3.1 Vấn đề: lazy load trong vòng lặp

@Service
@Transactional(readOnly = true)
public List<ProjectDto> listAll() {
    List<Project> projects = repo.findAll();         // 1 query

    return projects.stream()
        .map(p -> new ProjectDto(
            p.getId(),
            p.getName(),
            p.getTasks().size()                       // LAZY load — 1 query per project!
        ))
        .toList();
}

SQL Hibernate thực thi:

SELECT * FROM projects;                              -- 1 query, 100 rows

SELECT * FROM tasks WHERE project_id = 1;            -- query cho project 1
SELECT * FROM tasks WHERE project_id = 2;            -- query cho project 2
-- ... 98 query nua ...
SELECT * FROM tasks WHERE project_id = 100;          -- query cho project 100

101 queries cho 100 project — đây là N+1 problem.

3.2 Vì sao N+1 xảy ra — cơ chế proxy lazy

Khi findAll() trả về danh sách Project, mỗi field tasks là một Hibernate proxy chưa được load. Proxy này không chứa dữ liệu thật — nó chỉ giữ projectId và một flag "chưa khởi tạo".

Ngay khi code gọi p.getTasks() hoặc p.getTasks().size(), proxy bắt đầu chạy:

  1. Phát hiện collection chưa được khởi tạo.
  2. Mở một JDBC statement mới.
  3. Chạy SELECT * FROM tasks WHERE project_id = ? với projectId của project đó.
  4. Điền kết quả vào collection, đánh dấu "đã khởi tạo".

Vì đây xảy ra bên trong vòng lặp stream().map(...), mỗi lần xử lý 1 project là 1 query riêng. Không có cơ chế nào gom chúng lại — trừ khi developer chủ động dùng các kỹ thuật ở phần 4.

sequenceDiagram
  participant App as App (Service)
  participant DB as Database

  App->>DB: SELECT * FROM projects -- 1 query, 100 row
  DB-->>App: [p1, p2, ..., p100]

  Note over App,DB: Loop bat dau -- moi project trigger 1 query lazy load

  App->>DB: SELECT * FROM tasks WHERE project_id = 1
  DB-->>App: tasks cua p1

  App->>DB: SELECT * FROM tasks WHERE project_id = 2
  DB-->>App: tasks cua p2

  Note over App,DB: ... lap lai 98 lan nua ...

  App->>DB: SELECT * FROM tasks WHERE project_id = 100
  DB-->>App: tasks cua p100

  Note over App,DB: Tong: 1 + 100 = 101 query thay vi 1 JOIN

3.3 Tại sao N+1 khó phát hiện ở dev

Bug này có một đặc điểm nguy hiểm: không lộ ở môi trường dev vì chi phí tỷ lệ thuận số row.

  • Local với 10 project: 11 query × 1ms = 11ms — endpoint nhìn hoàn toàn khỏe mạnh, code review cũng khó bắt.
  • Staging với 100 project: 101 query × 20ms = 2 giây — chậm nhưng vẫn accept được.
  • Production với 10.000 project: 10.001 query × 50ms = 500 giây (hơn 8 phút) — UI timeout, alert đổ lúc 2 giờ sáng. Cùng một dòng code, chỉ khác kích thước dữ liệu.
Bat SQL log tu ngay 1

Cách phát hiện N+1 sớm nhất: bật SQL log trong application-dev.yml.

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE

Khi thấy cùng 1 pattern query lặp đi lặp lại với parameter khác nhau trong cùng 1 request, đó chính là N+1. Phát hiện ở dev, fix trước khi vào production.

4. Bốn cách fix N+1

Fix 1 — JOIN FETCH trong @Query

Thay vì để Hibernate lazy load từng collection riêng, viết JPQL với LEFT JOIN FETCH để Hibernate sinh 1 SQL JOIN duy nhất:

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

// Cho single entity
@Query("SELECT p FROM Project p LEFT JOIN FETCH p.tasks WHERE p.id = :id")
Optional<Project> findByIdWithTasks(@Param("id") Long id);

SQL Hibernate sinh ra:

SELECT DISTINCT p.*, t.*
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id;
-- 1 query, toan bo du lieu trong 1 roundtrip

DISTINCT trong JPQL không map trực tiếp thành SELECT DISTINCT SQL mà yêu cầu Hibernate dedup ở tầng Java — mỗi Project chỉ xuất hiện 1 lần trong kết quả dù có nhiều Task.

Vì sao JOIN FETCH giải quyết được: thay vì proxy lazy (trigger query khi access), Hibernate nạp sẵn collection tasks cho tất cả project trong cùng 1 SQL. Khi code gọi p.getTasks(), collection đã có sẵn — không trigger query nào thêm.

Fix 2 — @EntityGraph

@EntityGraph là cách khai báo "fetch path" mà không cần viết JPQL. Về mặt SQL sinh ra, nó tương đương JOIN FETCH:

// Inline — khai bao truc tiep tren repository method
@EntityGraph(attributePaths = {"tasks"})
List<Project> findAll();

// Named — dinh nghia tren entity, tai su dung nhieu method
@NamedEntityGraph(
    name = "Project.withTasks",
    attributeNodes = @NamedAttributeNode("tasks")
)
@Entity
public class Project { ... }

// Repository dung named graph
@EntityGraph("Project.withTasks")
List<Project> findByStatus(ProjectStatus status);

// Nested — fetch tasks va tung task fetch assignee cua no
@EntityGraph(attributePaths = {"tasks", "tasks.assignee"})
Optional<Project> findById(Long id);

Ưu điểm so với JOIN FETCH: không cần viết JPQL, có thể tái sử dụng named graph trên nhiều repository method, dễ đọc hơn khi fetch path phức tạp.

Fix 3 — @BatchSize

Hai fix trên đều cần chỉnh sửa code. @BatchSize là cách nhanh nhất không cần refactor — chỉ cần cấu hình:

# application.yml — global
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 50

Hoặc per-entity:

@OneToMany(mappedBy = "project")
@BatchSize(size = 50)
private List<Task> tasks;

Khi lazy load được trigger, thay vì Hibernate chạy 100 query riêng lẻ, nó gom các project_id cần load thành batch và dùng IN:

-- Khong co batch size: 100 query
SELECT * FROM tasks WHERE project_id = 1;
SELECT * FROM tasks WHERE project_id = 2;
-- ... 98 query nua

-- Co batch_fetch_size = 50: 2 query
SELECT * FROM tasks WHERE project_id IN (1, 2, 3, ..., 50);
SELECT * FROM tasks WHERE project_id IN (51, 52, ..., 100);

Vì sao batch size work: Hibernate trì hoãn việc thực thi lazy load cho đến khi có đủ ID, rồi gom vào 1 câu SQL với mệnh đề IN. Giảm 100 roundtrip thành 2 — cải thiện đáng kể mà không phải sửa 1 dòng code service.

Khi nào dùng: quick win cho codebase lớn không thể refactor ngay, hoặc dùng song song với JOIN FETCH như lưới an toàn. Nhược điểm: vẫn là 2+ query thay vì 1, và lazy load vẫn xảy ra (chỉ được gom lại).

Fix 4 — DTO projection

Khi list endpoint chỉ cần hiển thị dữ liệu (không cần modify entity), DTO projection cho phép viết 1 JPQL SELECT new với COUNT — không load entity, không lazy load, không N+1:

// DTO record (Java 16+)
public record ProjectSummary(Long id, String name, long taskCount) {}

// Repository
@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> findSummaries();

SQL sinh ra:

SELECT p.id, p.name, COUNT(t.id)
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
GROUP BY p.id, p.name;
-- 1 query, khong load entity, khong lazy proxy

Vì sao DTO projection work: Hibernate không tạo entity object, không tạo proxy cho collection — nó chỉ map trực tiếp ResultSet sang constructor của DTO. Không có lazy field để trigger. 1 SQL cố định, bất kể có bao nhiêu project.

Bảng so sánh 4 fix

FixQuery countLoad full entityChi phí refactorDùng khi
JOIN FETCH1 (với JOIN)Trung bình (viết @Query)Cần entity đầy đủ để modify
@EntityGraph1 (với JOIN)Thấp (chỉ annotation)Giống JOIN FETCH, declarative hơn
Batch size2–N (batched IN)Không (chỉ config)Quick win, legacy app khó refactor
DTO projection1 (GROUP BY)Không (chỉ field chọn)Trung bình (DTO + JPQL)List endpoint read-only, perf critical

Khuyến nghị 2026: DTO projection cho list endpoint, JOIN FETCH hoặc @EntityGraph cho single entity cần association.

5. MultipleBagFetchException

Khi cần fetch 2 collection cùng lúc bằng JOIN FETCH:

// SAI — throw MultipleBagFetchException
@Query("SELECT p FROM Project p LEFT JOIN FETCH p.tasks LEFT JOIN FETCH p.contributors")
Optional<Project> findFullById(Long id);

Hibernate ném MultipleBagFetchException ngay khi khởi động app. "Bag" là thuật ngữ Hibernate cho List collection không có thứ tự xác định. JOIN FETCH 2 bag cùng lúc tạo ra tích Cartesian — nếu project có 10 task và 5 contributor, kết quả SQL là 10 × 5 = 50 row, và Hibernate không thể dedup chính xác.

-- Tich Cartesian khi JOIN 2 collection
SELECT p.*, t.*, c.*
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
LEFT JOIN contributors c ON c.project_id = p.id;
-- 10 task × 5 contributor = 50 row cho 1 project
-- Hibernate khong biet nen lay task nao, contributor nao tu 50 row nay

Ba cách fix:

Fix 1 — Đổi 1 List sang Set (Set không phải bag):

@OneToMany(mappedBy = "project")
private Set<Task> tasks = new HashSet<>();        // Set — khong phai bag

@OneToMany(mappedBy = "project")
private List<User> contributors = new ArrayList<>();
// Gio: 1 bag + 1 set → Hibernate chap nhan

Lưu ý: Cartesian product vẫn xảy ra ở DB, chỉ là Hibernate dedup được Set. Với dataset lớn vẫn tốn memory.

Fix 2 — Tách 2 query riêng (khuyến nghị):

@EntityGraph(attributePaths = "tasks")
Optional<Project> findByIdWithTasks(Long id);

@EntityGraph(attributePaths = "contributors")
Optional<Project> findByIdWithContributors(Long id);

2 SQL riêng: JOIN tasksJOIN contributors — tổng row 10 + 5 = 15, không phải 50. Dùng trong service:

@Transactional(readOnly = true)
public ProjectDetailDto getDetail(Long id) {
    Project withTasks = repo.findByIdWithTasks(id).orElseThrow();
    Project withContribs = repo.findByIdWithContributors(id).orElseThrow();
    // Hibernate merge 2 load vao cung 1 entity trong persistence context
    return new ProjectDetailDto(
        withTasks.getId(), withTasks.getName(),
        withTasks.getTasks().stream().map(TaskDto::from).toList(),
        withContribs.getContributors().stream().map(UserDto::from).toList()
    );
}

Fix 3 — DTO projection với 2 query riêng ghép ở service layer (performance tốt nhất cho high-traffic endpoint).

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

  • Associations & fetch type: bài này dựng nền @OneToMany, @ManyToOne, LAZY/EAGER mặc định — N+1 problem sinh ra từ đặc điểm LAZY default, cascade đặt trên annotation association. Phải đọc trước để hiểu bài này.
  • @Transactional & AOP proxy: tất cả code service trong bài này đều chạy trong @Transactional. Bài tiếp giải thích tại sao proxy AOP bọc method, propagation hoạt động ra sao, và pitfall self-call bypass — kỹ năng cần thiết để viết đúng service layer với JPA.

Tóm tắt

  • Cascade lan truyền operation từ entity cha xuống con theo chiều @OneToMany. Không cascade theo chiều @ManyToOne (đặc biệt không REMOVE).
  • CascadeType.ALL + orphanRemoval = true là combo chuẩn cho parent-child strict ownership.
  • orphanRemoval = true: entity con bị tách khỏi collection → DELETE khỏi DB. Khác CascadeType.REMOVE (chỉ trigger khi xoá cha).
  • N+1 problem: findAll() trả 100 project → loop đọc p.getTasks() trigger 100 lazy query riêng = 101 query tổng.
  • Cơ chế: mỗi tasks field là Hibernate proxy chưa load — access proxy trong loop = query per proxy.
  • Bốn fix: JOIN FETCH (1 SQL với JOIN), @EntityGraph (tương đương, declarative), @BatchSize (gom lazy load vào IN, không sửa code), DTO projection (1 SQL GROUP BY, không load entity).
  • MultipleBagFetchException: JOIN FETCH 2 List cùng lúc → tích Cartesian → Hibernate từ chối. Fix: đổi 1 List sang Set, hoặc tách 2 query riêng.

Tự kiểm tra

Tự kiểm tra
Q1
Đoạn code sau thực thi bao nhiêu SQL query? Giải thích vì sao theo cơ chế proxy lazy của Hibernate.
@Transactional(readOnly = true)
public List<String> listTaskTitles() {
  List<Project> projects = repo.findAll();  // 50 projects
  return projects.stream()
      .flatMap(p -> p.getTasks().stream())
      .map(Task::getTitle)
      .toList();
}

51 queries — N+1 problem.

Cơ chế: repo.findAll() chạy SELECT * FROM projects — 1 query, trả 50 Project. Mỗi Project có field tasks là Hibernate collection proxy ở trạng thái "chưa khởi tạo" — proxy giữ projectId nhưng chưa có dữ liệu thật.

Khi flatMap(p -> p.getTasks().stream()) gọi getTasks(), Hibernate phát hiện collection chưa được load và trigger SELECT * FROM tasks WHERE project_id = ? — 1 query per project. Với 50 project: 50 query lazy load.

Tổng: 1 + 50 = 51 query. Fix tốt nhất cho use case này là DTO projection vì chỉ cần title: SELECT t.title FROM Task t JOIN t.project p — 1 query, không load entity.

Q2
Vì sao `@BatchSize(size = 50)` giảm số query từ 100 xuống 2, nhưng JOIN FETCH chỉ cần 1 query? Cơ chế khác nhau ở điểm nào?

BatchSize — trì hoãn và gom lazy load: Hibernate vẫn dùng cơ chế lazy proxy, nhưng thay vì trigger query ngay khi access từng collection, nó gom các project_id cần load lại thành batch rồi chạy SELECT * FROM tasks WHERE project_id IN (1, 2, ..., 50). Với 100 project và batch size 50: 2 query với IN. Lazy proxy vẫn tồn tại — chỉ được thực thi theo nhóm.

JOIN FETCH — eager load ngay từ đầu: Hibernate sinh 1 SQL LEFT JOIN duy nhất, load cả Project lẫn collection Tasks trong cùng 1 roundtrip DB. Không có proxy lazy nào được tạo ra — collection đã có dữ liệu ngay khi entity được trả về. Không cần thêm query nào.

Hệ quả thực tế: JOIN FETCH luôn tốt hơn về số roundtrip (1 vs 2+), nhưng kết quả JOIN có thể lớn hơn nếu nhiều Task per Project (nhiều row SQL). Batch size là quick win không cần refactor code — nhưng vẫn là lazy load, chỉ được tối ưu.

Q3
Phân biệt `orphanRemoval = true` và `CascadeType.REMOVE`. Cho ví dụ code cụ thể cho từng trường hợp.
Nếu khai báo cả 2, chuyện gì xảy ra khi xoá project?

CascadeType.REMOVE — trigger khi entity cha bị xoá:

@OneToMany(mappedBy = "project", cascade = CascadeType.REMOVE)
private List<Task> tasks;

// Service
projectRepo.delete(project);
// CASCADE REMOVE: JPA tu dong DELETE tasks truoc, roi DELETE project
// SQL:
//   DELETE FROM tasks WHERE project_id = ?
//   DELETE FROM projects WHERE id = ?

orphanRemoval = true — trigger khi entity con bị tách khỏi collection cha (cha vẫn tồn tại):

@OneToMany(mappedBy = "project", orphanRemoval = true)
private List<Task> tasks;

// Service
project.getTasks().remove(task);   // tach task khoi collection
// Tx commit → DELETE FROM tasks WHERE id = ?
// Project van ton tai, chi task do bi xoa

Khi khai báo cả 2 (cascade = ALL, orphanRemoval = true):

Khi xoá project: CascadeType.REMOVE kích hoạt — JPA xoá tất cả tasks trước, rồi xoá project. Đây là hành vi đúng cho parent-child ownership. orphanRemoval không thêm gì trong trường hợp này vì tasks cũng đang bị xoá cùng cha.

Combo cascade = ALL, orphanRemoval = true là chuẩn cho quan hệ parent-child strict: task không thể tồn tại độc lập với project, và xoá task khỏi list cũng xoá task khỏi DB.

Q4
MultipleBagFetchException xảy ra khi nào? Tại sao Hibernate không cho phép JOIN FETCH 2 List cùng lúc?

MultipleBagFetchException xảy ra khi query JPQL có LEFT JOIN FETCH trên 2 collection kiểu List (hoặc bất kỳ unordered bag collection) cùng lúc.

Vì sao Hibernate không cho phép: JOIN 2 collection cùng 1 lúc sinh ra tích Cartesian ở DB. Nếu project có 10 task và 5 contributor, SQL trả về 10 × 5 = 50 row. Hibernate không thể xác định chính xác cách ghép 50 row này trở lại thành danh sách Task và danh sách Contributor đúng — ambiguity không giải được với unordered List. Thay vì silently cho kết quả sai, Hibernate ném exception ngay khi khởi động để buộc developer xử lý.

Fix khuyến nghị — tách 2 query:

@EntityGraph(attributePaths = "tasks")
Optional<Project> findByIdWithTasks(Long id);

@EntityGraph(attributePaths = "contributors")
Optional<Project> findByIdWithContributors(Long id);
// 2 SQL: 10 row + 5 row = 15 row, khong phai 50

Cách này tránh Cartesian, mỗi SQL chỉ join 1 collection, Hibernate dedup chính xác.

Q5
Service sau có N+1 không? Nếu có, fix bằng cách nào là tốt nhất cho use case "hiển thị danh sách project kèm số lượng task"?
@Transactional(readOnly = true)
public List<ProjectSummaryDto> getSummaries() {
  return repo.findAll().stream()
      .map(p -> new ProjectSummaryDto(p.getId(), p.getName(), p.getTasks().size()))
      .toList();
}

Có N+1. repo.findAll() chạy 1 query lấy tất cả project. Với mỗi project trong stream, p.getTasks().size() trigger lazy load SELECT * FROM tasks WHERE project_id = ? — 1 query per project. 100 project = 101 query tổng.

Fix tốt nhất cho use case này — DTO projection với COUNT:

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

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

SQL sinh ra:

SELECT p.id, p.name, COUNT(t.id)
FROM projects p LEFT JOIN tasks t ON t.project_id = p.id
GROUP BY p.id, p.name;

Vì sao DTO projection là tốt nhất ở đây: use case chỉ cần đếm task, không cần load entity Task đầy đủ. DTO projection không tạo entity object, không tạo proxy — 1 SQL duy nhất với GROUP BY trả số lượng task trực tiếp từ DB. JOIN FETCH cũng fix N+1 nhưng load toàn bộ Task entity không cần thiết — lãng phí memory và bandwidth.

Bài tiếp theo: @Transactional & AOP proxy

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