N+1: app chậm dần đều mà không một dòng lỗi
Một trăm linh một câu SELECT cho hai mươi đơn hàng, và không một dòng lỗi nào để bạn lần theo.
Cuối mùa trước mình có buông một câu: chuyện em vay bao nhiêu - mùa sau đếm. Thằng em nhớ dai. Nó nhắn:
Code của nó, và mình cá là bạn có đoạn gần giống trong codebase đang mở:
List<Order> orders = orderRepo.findByShopId(shopId); // 1 query - 20 đơn
for (Order o : orders) {
dto.add(new OrderRow(
o.getId(),
o.getCustomer().getName(), // chạm giấy hẹn số 1
o.getItems().size() // chạm giấy hẹn số 2
));
}
Tội không nằm ở câu SELECT nào cả. Tội nằm ở số lượng - và ở chỗ không một dòng code nào của nó trông giống đang gọi database.
Bạn có đủ đồ nghề để tự phá án, vì thủ phạm là nhân vật của bài giấy hẹn. Nhắc lại một câu cho ai vào thẳng bài này: field lazy không chứa dữ liệu - nó chứa một tờ giấy hẹn trỏ về cuốn sổ, chạm vào là sổ chạy SELECT trả nợ. Bài trước, giấy hẹn gây chuyện vì bị đòi sai thời điểm - sổ đã đóng, nổ ầm lên. Hôm nay là mặt còn lại, mặt im lặng: giấy hẹn bị đòi đúng thời điểm - sổ đang mở, phục vụ tận tình - nhưng bị đòi từng tờ một, trong một vòng lặp.
Tua chậm. findByShopId - một query, hai mươi Order vào sổ, mỗi order kèm hai giấy hẹn (customer, items). Vòng for bắt đầu. Đơn thứ nhất: chạm getCustomer() - sổ nhận giấy hẹn, chạy một SELECT, trả. Chạm getItems().size() - thêm một SELECT. Đơn thứ hai: hai SELECT nữa. Cứ thế. Hai mươi đơn, mỗi đơn hai giấy - bốn mươi SELECT lẻ, cộng một query gốc. (Sổ có nhớ mặt customer đã load - vài đơn trùng khách thì đỡ được vài câu - nhưng bản nhạc chung không đổi.) Công thức của thảm họa mang tên chính nó: N+1 - một query lấy danh sách, N query trả nợ từng dòng.
Câu hỏi đúng ở đây không phải “sao Hibernate ngu thế” - mà là: sao sổ không gom lại một lần cho xong? Vì cuốn sổ không nhìn thấy tương lai. Nó không biết bạn đang đứng trong vòng lặp. Mỗi cú chạm giấy hẹn đến với nó như một yêu cầu độc lập, và nó làm điều duy nhất một người phục vụ tại-thời-điểm có thể làm: trả đúng món được hỏi, ngay lúc được hỏi. N+1 không phải Hibernate phản bội bạn. Nó là sự trung thành bị đặt vào vòng lặp - mỗi lần phục vụ đều đúng, và tổng của những lần đúng là một thảm họa.
Còn vì sao “chậm dần đều” - cái vế làm bug này thành sát thủ? Vì N không phải hằng số. Ngày đầu shop có ba đơn: bốn query, chớp mắt xong, code review khen sạch. Sáu tháng sau: hai trăm đơn, bốn trăm lẻ một query, mỗi query một vòng khứ hồi tới DB - một mili giây nhân bốn trăm là gần nửa giây chỉ để đi đường. Một năm: nghìn đơn. App chậm đi từng tuần một, không có commit nào để đổ tội, không có ngày nào để khoanh “hỏng từ hôm này”. Nó không gãy - nó mòn. Git blame bó tay, vì thủ phạm không phải dòng code nào - thủ phạm là dữ liệu lớn lên chạy qua một hình dạng code đã sai từ ngày đầu mà chưa đủ N để lộ.
Và nhớ chi tiết này từ bài trước: nếu app bạn đang bật Open-Session-In-View mặc định, những cú chạm giấy hẹn còn không cần nằm trong service - chúng nằm trong template, trong Jackson, ở tầng không ai nghĩ là tầng dữ liệu. N+1 khi đó thậm chí không hiện diện trong code bạn review.
Fix thì bạn đã được hứa từ bài trước: nói cho query biết trước bạn sẽ cần gì. JOIN FETCH - select o from Order o join fetch o.items join fetch o.customer where ... - một câu SQL join, kéo cha lẫn con về cùng chuyến; hoặc @EntityGraph - cùng ý, khai bằng annotation, để repository method dùng lại được. Để ý là phải kể tên đủ những giấy hẹn mà vòng lặp sẽ chạm: quên customer thì bạn mới trả một nửa số nợ, hai mươi câu SELECT còn lại vẫn chạy y nguyên, và log ngắn đi vừa đủ để bạn tưởng mình đã xong. Kể đủ thì bốn mươi mốt query co về một. Thằng em sửa, chạy, gửi mình cái log ngắn ngủn kèm ba dấu chấm than.
Rồi hai hôm sau nó nhắn tiếp - và đây là nửa sau của bài, cái hố ngay cạnh lối ra:
join fetch vào luôn query phân trang. Chạy vẫn đúng. Nhưng trong log có một dòng WARN em chưa thấy bao giờ: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory - applying in memory nghĩa là sao? (Một ghi chú tra cứu, vì cái mã số này đổi giữa hai đời: trên Hibernate 5 nó là HHH000104, có kèm dấu chấm than ở cuối. Từ Hibernate 6 thành HHH90003004 và mất dấu chấm than. Cùng một câu, cùng một chuyện - nên nếu bạn google ra một bài blog cũ ghi HHH000104 thì đúng bài đấy, chỉ là nó viết trước lần đánh lại số.)
Nghĩa là: Hibernate vừa lịch sự thông báo rằng nó đã bỏ LIMIT ra khỏi câu SQL, kéo toàn bộ kết quả về RAM, và tự phân trang bằng tay trong bộ nhớ. Toàn bộ. Mười nghìn đơn thì kéo mười nghìn - kèm items của từng đơn - để đưa bạn trang đầu hai mươi cái.
Vì sao nó phải làm trò đó? Vì một sự thật của SQL mà join fetch phơi ra: khi cha join với con, mỗi cha xuất hiện lặp lại theo số con của nó. Đơn có ba item chiếm ba dòng kết quả. LIMIT 20 giờ nghĩa là hai mươi dòng - có thể chỉ là sáu cái đơn, thậm chí cắt một đơn làm đôi giữa chừng danh sách item của nó. Phân trang theo cha và LIMIT theo dòng không còn là một thứ. Hibernate không thể sinh câu SQL vừa fetch con vừa LIMIT đúng theo cha - nên nó chọn đường duy nhất còn lại: kéo hết, đếm cha trong RAM. Kết quả đúng tuyệt đối. Chi phí thì giấu sau một dòng WARN - và app của bạn giờ có một quả bom hẹn giờ theo kích thước bảng, được ngụy trang thành một trang hai mươi phần tử ngay ngắn.
Để ý sự thâm của tình huống: bạn vừa thoát N+1 - bug nhiều query nhỏ - và bước thẳng vào bug một query vô hạn. Hai hố ở hai phía của cùng một con đường, và tấm biển chỉ đường duy nhất là một dòng WARN. Còn cái chuyện “mỗi cha lặp lại theo số con” - dòng kết quả nhân bản ấy - nó chưa xong chuyện với bạn đâu. Nó còn một cú nữa, kỳ quái hơn, ở ngay bài sau.
Lối đi đúng cho phân trang + quan hệ: tách hai bước. Bước một, query phân trang chỉ trên cha - không fetch - lấy về hai mươi ID/entity đúng trang, LIMIT chạy dưới SQL tử tế. Bước hai, một query thứ hai join fetch (hoặc để Hibernate batch-load) con cho đúng hai mươi cha đó. Hai query thay vì một - và thay vì mười nghìn. Đôi khi câu trả lời cho “một hay N query” là hai.
Muốn tự thấy tận mắt: bật spring.jpa.properties.hibernate.generate_statistics=true, chạy lại API danh sách, và đọc dòng tổng kết Hibernate in ra cuối mỗi request - số lượng JDBC statements nằm đó, trần trụi, không cần đếm tay như thằng em. Con số ấy nên nằm trong khung theo dõi của bạn thường trực: N+1 không phòng được bằng mắt thường, chỉ phòng được bằng đếm.
Còn nguyên lý, thứ sống lâu hơn Hibernate: chi phí trong hệ có tầng trung gian không nằm ở từng thao tác - nó nằm ở hình dạng truy cập. Mỗi SELECT của thằng em đều vô tội; hình-dạng-vòng-lặp của chúng mới là tội. Gọi API trong vòng for. Đọc file từng byte. Chat hỏi từng dòng một thay vì gửi cả tài liệu. Tầng trung gian trung thành phục vụ từng yêu cầu - và chính vì nó không bao giờ từ chối, tổng chi phí trở nên vô hình cho tới khi ai đó đếm.
Thằng em, sau khi gắn statistics và nhìn con số của cả app, nhắn:
Em không có query chậm nào - em có một triệu query nhanh.
Đúng rồi đó. Người phục vụ tận tụy nhất là người em phải tự đếm hộ. 😃
Tài nguyên tập 15
Cheat-sheet tập 15 Toàn bộ cơ chế trên một trang - in ra hoặc lưu về máy được. Source demo - tập 4.2JDK 17.mvn spring-boot:run → Episode42Runner in khối [EP 4.2]: số câu SQL của vòng lặp thơ ngây, của JOIN FETCH, và của phân trang cộng collection fetch - kèm dòng WARN Hibernate nhả ra.Vị trí trong Lớp Trung Gian Vô Hình
Bài này trả lời
- Một truy vấn sinh ra hàng trăm câu lệnh SQL