99 Ngày Spring — Ngày 27: Lazy vs Eager & bài toán N+1
Hai ngày qua ta nối entity thành quan hệ — hôm nay trả món nợ Ngày 25 đã hẹn: cái giá của việc tải quan hệ. Đây là bài quan trọng nhất về hiệu năng JPA, xoay quanh một hiện tượng mà gần như mọi dự án đều dính ít nhất một lần: bài toán N+1 — trang danh sách 20 bài viết lặng lẽ bắn 21 câu SQL. Ta sẽ hiểu LAZY/EAGER và proxy hoạt động ra sao, tự tay tái hiện N+1 bằng show-sql, rồi sửa nó bằng hai công cụ chuẩn: JOIN FETCH và @EntityGraph.

FetchType & proxy — LAZY hoạt động thế nào
// FetchType quyết định KHI NÀO dữ liệu quan hệ được tải:// EAGER: tải NGAY cùng entity chính — cần hay không cũng tải// LAZY: tải KHI CHẠM TỚI — Hibernate đặt proxy đứng chờ
// Mặc định của JPA — lệch nhau, phải thuộc lòng:// @ManyToOne / @OneToOne → EAGER (bẫy! — Ngày 25 đã dặn đặt LAZY)// @OneToMany / @ManyToMany → LAZY
@ManyToOne(fetch = FetchType.LAZY) // ghi đè mặc định — thói quen bắt buộcprivate Post post;
// LAZY hoạt động nhờ PROXY: comment.getPost() trả về "thế thân",// SQL chỉ chạy khi bạn gọi post.getTitle() — và cần session còn mở.
// Chạm proxy khi session đã đóng (ví dụ trong lúc render JSON):// → LazyInitializationException: could not initialize proxy// Sửa đúng: fetch đủ dữ liệu TRONG transaction (join fetch / @EntityGraph)// Sửa sai: spring.jpa.open-in-view=true — giấu bệnh, kéo session ra tận viewMặc định lệch nhau của JPA có lý do lịch sử: quan hệ về một bản ghi (
@ManyToOne) được coi là rẻ nên EAGER, quan hệ về nhiều (@OneToMany) đắt nên LAZY — nhưng "rẻ" nhân với vòng lặp là gốc rễ N+1, nên chuẩn hiện đại là LAZY tất cả.Proxy là thế thân Hibernate dựng bằng cách kế thừa entity của bạn: cầm được, gán được, chỉ khi chạm vào dữ liệu nó mới chạy SQL — vì vậy nó cần session còn sống, và
LazyInitializationExceptionchính là tiếng kêu "session đóng rồi mà còn gọi tôi".open-in-view(Spring Boot bật mặc định!) làm exception biến mất bằng cách kéo session sống đến hết request — nhưng đổi bằng SQL chạy ngoài transaction, connection bị giữ suốt lúc render, và N+1 được giấu thay vì sửa; dự án nghiêm túc thường tắt nó đi.
Bài toán N+1 — tái hiện tận mắt
// Bài toán N+1 — nhìn tận mắt bằng show-sql (Ngày 22):
List<Post> posts = postRepository.findAll(); // 1 câu SQLfor (Post p : posts) {
p.getComments().size(); // MỖI post thêm 1 câu!
}
// 20 post → 1 + 20 = 21 câu SQL. 100 post → 101 câu.
-- log SQL thực tế:
-- select * from posts (1)
-- select * from comments where post_id = 1 (2)
-- select * from comments where post_id = 2 (3)
-- ... x20 (21)
// Điều ác nghiệt: trang vẫn CHẠY ĐÚNG — chỉ chậm dần theo dữ liệu.// Dev test 5 bản ghi thấy êm; production 2.000 bản ghi thì sập.// EAGER không cứu được — nó chỉ đổi "chậm khi chạm" thành "chậm ngay lập tức",// và tệ hơn: chậm cả ở những màn hình không hề cần commentsCông thức đọc tên: 1 câu lấy danh sách cha + N câu lấy con cho từng cha — mỗi lần chạm
getComments()là proxy tỉnh dậy chạy một câu SQL riêng, và vòngforbiến sự tiện lợi của LAZY thành trận mưa truy vấn.N+1 độc vì im lặng: không exception, không lỗi logic — chỉ có độ trễ tăng tuyến tính theo dữ liệu; đó là lý do bài kiểm tra "đếm số câu SQL" bằng
show-sql(Ngày 22) đáng trở thành phản xạ sau mỗi màn hình mới.Trực giác "vậy chuyển sang EAGER" là sai hướng: EAGER chỉ dời thời điểm trả giá và bắt mọi màn hình cùng trả — kể cả nơi chẳng cần quan hệ đó; vấn đề không nằm ở khi nào tải mà ở chỗ tải từng-con-một thay vì một lần.
JOIN FETCH — gộp về một câu SQL
// Cách sửa 1 — JOIN FETCH trong JPQL (@Query — Ngày 24):@Query("""
SELECT DISTINCT p FROM Post p
LEFT JOIN FETCH p.comments
WHERE p.status = :status
""")
List<Post> findWithComments(@Param("status") PostStatus status);
// 1 câu SQL duy nhất: posts JOIN comments — N+1 biến mất.// LEFT để post 0 comment không bị loại; DISTINCT vì JOIN nhân bản hàng cha.
// Giới hạn phải biết:// ✗ fetch join 2 COLLECTION cùng lúc → MultipleBagFetchException// ✗ fetch join collection + Pageable → Hibernate phân trang TRONG BỘ NHỚ// (log cảnh báo HHH000104 — tải cả bảng về rồi mới cắt trang!)// → phân trang + quan hệ: tách 2 bước (trang id trước, fetch sau) hoặc DTOJOIN FETCHkhácJOINthường ở một chữ: JOIN thường chỉ lọc, còn FETCH bảo Hibernate tải luôn quan hệ vào entity trả về — collection đầy sẵn, chạm vào không sinh thêm SQL.LEFTvàDISTINCTlà bộ đôi đi kèm: thiếu LEFT thì post chưa có comment biến mất khỏi kết quả; thiếu DISTINCT thì post 5 comment xuất hiện 5 lần (từ Hibernate 6, DISTINCT trong JPQL không còn rơi xuống câu SQL — chỉ khử trùng lặp entity, không tốn thêm chi phí DB).Hai giới hạn trong code trên là kiến thức phỏng vấn thực thụ — đặc biệt bẫy phân trang trong bộ nhớ (HHH000104):
Pageable+ fetch join collection nghĩa là tải toàn bộ kết quả về rồi mới cắt trang — đúng thảm họa mà phân trang sinh ra để tránh.
@EntityGraph & chiến lược fetch tổng
// Cách sửa 2 — @EntityGraph: giữ derived query, khai báo thứ cần tải@EntityGraph(attributePaths = {"comments", "category"})
List<Post> findByStatus(PostStatus status);
// vẫn là derived query Ngày 23 — chỉ thêm một dòng khai báo
// Chiến lược tổng — kim chỉ nam fetch của cả series:// 1. LAZY ở MỌI quan hệ (ghi đè mặc định EAGER của @ManyToOne/@OneToOne)// 2. Mỗi use-case tự khai báo thứ nó cần: join fetch hoặc @EntityGraph// 3. Màn hình chỉ đọc? — DTO projection: SELECT thẳng các cột cần,// không entity, không proxy, không N+1 ngay từ thiết kế// 4. Bật show-sql ở dev và ĐẾM SỐ CÂU SQL sau mỗi màn hình mới
// EAGER toàn cục là quyết định fetch cho MỌI màn hình tương lai// tại một dòng khai báo entity — kiểu quyết định không ai đủ thông tin để đưa ra@EntityGraphcho giữ nguyên derived query (Ngày 23) và khai báo phần cần tải bằng annotation — cùng một method có thể có hai phiên bản: bản thường cho danh sách gọn, bản gắn graph cho màn hình chi tiết.Điểm mạnh thực dụng:
@EntityGraphhòa hợp với Pageable tốt hơn fetch join thủ công trong nhiều trường hợp đơn giản — nhưng với collection lớn, luật phân trang trong bộ nhớ vẫn áp dụng: đo bằng log trước khi tin.Bậc cuối của chiến lược — DTO projection — sẽ có ngày riêng ở giai đoạn Data nâng cao: màn hình chỉ đọc không cần entity được quản lý, chỉ cần đúng các cột —
SELECT newhoặc interface projection cho hiệu năng sạch ngay từ thiết kế.
Bài tập nhỏ
Bật
show-sql, nạp 20 post + comments rồi chạy vòng lặpgetComments().size()— đếm đúng 21 câu SQL trong log.Sửa bằng
JOIN FETCH— xác nhận còn 1 câu; bỏDISTINCTvà quan sát post lặp lại trong danh sách.Sửa lại lần nữa bằng
@EntityGraphtrên derived query — so log SQL hai cách.Tái hiện
LazyInitializationException: trả entity có quan hệ LAZY thẳng ra controller sau khi tắtopen-in-view— rồi giải thích chuỗi sự kiện dẫn tới exception.Thêm
Pageablevào query fetch join collection — tìm cảnh báoHHH000104trong log và giải thích nó cảnh báo điều gì.
Kết luận
Kim chỉ nam fetch gói trong bốn ý: LAZY mọi quan hệ — ghi đè mặc định EAGER của @ManyToOne/@OneToOne, vì quyết định fetch thuộc về use-case, không thuộc về entity; N+1 là 1 + N câu SQL im lặng chỉ lộ khi đếm log — hãy đếm; JOIN FETCH và @EntityGraph là hai công cụ chuẩn gộp về một câu, với bẫy phân trang trong bộ nhớ phải né; và màn hình chỉ đọc thì DTO projection thắng mọi entity. Ngày 28 ta chạm vào trái tim của tầng dữ liệu: Transaction & @Transactional — ACID, propagation, rollback và cái bẫy proxy tự gọi chính mình. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


