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 operation và N+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<>();
| CascadeType | Operation propagate | Ví dụ |
|---|---|---|
PERSIST | save(project) → persist tasks chưa save | Tạo project kèm tasks cùng lúc |
MERGE | save(detached project) → merge tasks | Update project + tasks từ DTO |
REMOVE | delete(project) → delete tasks | Xoá project kèm toàn bộ tasks |
REFRESH | refresh(project) → reload tasks từ DB | Đồng bộ lại state từ DB |
DETACH | detach(project) → detach tasks | Tách entity ra khỏi persistence context |
ALL | Tất cả 5 loại trên | Quan 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 orphanRemoval và CascadeType.REMOVE:
CascadeType.REMOVE | orphanRemoval = true | |
|---|---|---|
| Trigger | Khi xoá entity cha | Khi entity con bị tách khỏi collection |
| Ví dụ | delete(project) → delete tasks | project.getTasks().remove(task) → delete task |
| Thường dùng | Cùng với ALL | Thê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
@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:
- Phát hiện collection chưa được khởi tạo.
- Mở một JDBC statement mới.
- Chạy
SELECT * FROM tasks WHERE project_id = ?vớiprojectIdcủa project đó. - Đ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.
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
| Fix | Query count | Load full entity | Chi phí refactor | Dùng khi |
|---|---|---|---|---|
| JOIN FETCH | 1 (với JOIN) | Có | Trung bình (viết @Query) | Cần entity đầy đủ để modify |
| @EntityGraph | 1 (với JOIN) | Có | Thấp (chỉ annotation) | Giống JOIN FETCH, declarative hơn |
| Batch size | 2–N (batched IN) | Có | Không (chỉ config) | Quick win, legacy app khó refactor |
| DTO projection | 1 (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 tasks và JOIN 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ôngREMOVE). CascadeType.ALL+orphanRemoval = truelà combo chuẩn cho parent-child strict ownership.orphanRemoval = true: entity con bị tách khỏi collection → DELETE khỏi DB. KhácCascadeType.REMOVE(chỉ trigger khi xoá cha).- N+1 problem:
findAll()trả 100 project → loop đọcp.getTasks()trigger 100 lazy query riêng = 101 query tổng. - Cơ chế: mỗi
tasksfield 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àoIN, không sửa code), DTO projection (1 SQL GROUP BY, không load entity). MultipleBagFetchException: JOIN FETCH 2Listcùng lúc → tích Cartesian → Hibernate từ chối. Fix: đổi 1ListsangSet, hoặc tách 2 query riêng.
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();
}
▸
@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.
Q2Vì 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.
Q3Phâ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?▸
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 xoaKhi 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.
Q4MultipleBagFetchException 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 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 50Cách này tránh Cartesian, mỗi SQL chỉ join 1 collection, Hibernate dedup chính xác.
Q5Service 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();
}
▸
@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
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