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.

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(); } - 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?
- 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? - Q4
MultipleBagFetchExceptionxảy ra khi nào? Tại sao Hibernate không cho phép JOIN FETCH 2Listcùng lú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(); }
Bài tiếp theo: @Transactional & AOP proxy
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.
Bài này có giúp bạn hiểu bản chất khô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.
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