Đây là vấn đề hiệu năng phổ biến nhất trong ứng dụng dùng ORM, và nó ẩn rất kỹ: mã trông hoàn toàn bình thường.
Đo
20 tác giả, mỗi người 5 bài. Ba cách lấy cùng một dữ liệu:
duyệt ngây thơ : 21 truy vấn | 100 bài | 47,2 ms
join fetch : 2 truy vấn | 100 bài | 14,3 ms
@EntityGraph : 1 truy vấn | 100 bài | 11,4 ms
Mã của dòng đầu:
for (var t : repo.findAll()) // 1 truy vấn
n += t.bais.size(); // + 1 truy vấn cho MỖI tác giả
Một câu lấy danh sách, rồi một câu nữa cho mỗi phần tử. Đó là 1 + N, và tên gọi từ đó mà ra.
Trên 20 bản ghi thì 47 mili giây, không ai để ý. Trên 5.000 bản ghi thì đó là 5.001 truy vấn — và đây chính là kiểu lỗi chạy tốt suốt quá trình phát triển rồi sập trong sản xuất.
Thời gian không phải phần đáng sợ nhất. Mỗi truy vấn là một lượt đi về mạng tới CSDL. Trong phép đo này CSDL nằm ngay cạnh; qua mạng thật với độ trễ 1 ms, 5.000 truy vấn là 5 giây thuần chờ.
Cách một: join fetch
@Query("select t from TacGia t join fetch t.bais")
List<TacGia> layKemBai();
21 xuống 2. Một câu JOIN lấy cả tác giả lẫn bài.
Ba điều phải biết:
Kết quả bị lặp. JOIN sinh một dòng cho mỗi cặp tác giả–bài, nên tác giả xuất hiện nhiều lần. Từ Hibernate 6 thì nó tự lọc trùng; với bản cũ hơn phải thêm distinct.
Không join fetch hai collection cùng lúc. Hibernate ném MultipleBagFetchException. Lý do là tích Descartes: 20 tác giả × 5 bài × 3 thẻ = 300 dòng cho 20 bản ghi. Cần nhiều collection thì tách thành nhiều truy vấn, hoặc dùng @BatchSize.
join fetch không đi cùng phân trang được. Nếu có Pageable, Hibernate cảnh báo HHH90003004: firstResult/maxResults specified with collection fetch rồi tải toàn bộ bảng lên bộ nhớ để phân trang trong JVM. Trên bảng lớn đó là cách hết bộ nhớ. Chữa bằng hai bước: lấy id theo trang, rồi join fetch theo danh sách id đó.
Cách hai: @EntityGraph
@EntityGraph(attributePaths = "bais")
List<TacGia> findAllBy();
Xuống 1 truy vấn. Cùng kết quả, ít hơn join fetch một câu.
Vì sao join fetch vẫn tốn 2
Đây là chỗ tôi phải dừng lại tìm. Cả hai cách đều nạp bais, sao một cái tốn 2?
Nguyên nhân nằm ở một chữ trong entity Bai:
@ManyToOne(fetch = FetchType.EAGER) public Muc muc;
join fetch t.bais chỉ nói về bais. Nhưng mỗi Bai có quan hệ muc khai EAGER, nên Hibernate phải chạy thêm một câu để nạp nó. @EntityGraph thì dựng một truy vấn gộp cả những quan hệ eager vào cùng một câu JOIN.
Bài học rộng hơn: EAGER là chi phí bạn trả ở mọi truy vấn, kể cả những truy vấn không cần quan hệ đó. Nó cũng không thể tắt đi tại chỗ gọi — đã khai EAGER trong entity thì mọi nơi đều nhận.
Và JPA làm việc này khó hơn cần thiết:
| Quan hệ | Mặc định |
|---|---|
@ManyToOne |
EAGER |
@OneToOne |
EAGER |
@OneToMany |
LAZY |
@ManyToMany |
LAZY |
Hai dòng đầu là mặc định sai. Quy tắc thực dụng: khai LAZY tường minh cho mọi quan hệ, rồi nạp thứ cần bằng @EntityGraph tại chỗ dùng.
@ManyToOne(fetch = FetchType.LAZY) private Muc muc;
Cách ba: @BatchSize
@OneToMany(mappedBy = "tacGia")
@BatchSize(size = 20)
private List<Bai> bais;
Thay vì một truy vấn cho mỗi tác giả, Hibernate gom 20 id lại thành WHERE tac_gia_id IN (?,?,...).
Không giảm về 1 truy vấn, nhưng giảm từ N xuống N/20 — và nó hoạt động cả khi phân trang, khác join fetch. Với dữ liệu lớn, đây thường là cách thực dụng nhất.
Đặt mặc định cho toàn ứng dụng:
spring.jpa.properties.hibernate.default_batch_fetch_size: 20
Một dòng, và mọi N+1 còn sót lại nhẹ đi hai chục lần. Tôi bật nó ngay từ đầu dự án.
Cách bốn: đừng lấy entity
Nếu chỉ cần vài trường để hiển thị, truy vấn thẳng cái cần:
@Query("select new vd.DonDto(t.ten, count(b)) from TacGia t left join t.bais b group by t.ten")
List<DonDto> thongKe();
Một truy vấn, không quan hệ, không lazy, không rủi ro. Bài 30 sẽ nói kỹ về hướng này.
Phát hiện tự động
Đếm SQL bằng mắt không mở rộng được. Ba cách:
Bật log và đếm — logging.level.org.hibernate.SQL: DEBUG. Đủ cho lúc phát triển.
datasource-proxy hoặc p6spy — bọc DataSource và đếm chính xác. Phép đo trong bài này dùng một proxy tự viết đúng chục dòng.
Làm test đỏ khi vượt ngưỡng — cách duy nhất ngăn N+1 quay lại:
@Test void danhSachDonKhongDuocQua3TruyVan() {
dem.reset();
dv.layDanhSach();
assertThat(dem.so()).isLessThanOrEqualTo(3);
}
Bài 36 sẽ dựng cái này đầy đủ.
Thử ba mươi giây
logging.level.org.hibernate.SQL: DEBUG
Gọi endpoint danh sách của bạn và đếm dòng select. Thấy cùng một câu lặp lại chỉ khác tham số là bạn đang nhìn thẳng vào N+1.
Ngày mai: LazyInitializationException — và vì sao Spring Boot bật sẵn một thứ che nó đi.