Về bản web

caltalys.dev

MultipleBagFetchException - cái chuông báo cháy mà ai cũng chỉ lo tắt chuông

Hibernate ném exception không phải để làm khó bạn mà để chặn một tích Descartes. Đổi List sang Set là tắt chuông chứ không phải chữa cháy.

Caltalys · 06/10/2026 · 8 phút đọc

Bài trước mình có dặn: cái chuyện mỗi cha lặp lại theo số con trong kết quả join - nó chưa xong chuyện với bạn. Thằng em không phải đợi lâu.

thang em Anh, em thừa thắng xông lên. join fetch o.items chạy ngon quá, em fetch luôn cả tags cho đủ bộ:
@Query("select o from Order o join fetch o.items join fetch o.tags")
List<Order> findFullOrders();
thang em Và app em không khởi động nổi. Chưa kịp nhận request nào - chết ngay lúc boot, vì Spring Data validate query lúc dựng repository:
org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bags:
[com.acme.Order.items, com.acme.Order.tags]
thang em Em google, câu trả lời nhiều vote nhất bảo: đổi List thành Set là hết. Em đổi. Hết lỗi thật. Nhưng em học series này đủ lâu để ngờ ngợ: một cái fix mà em không hiểu vì sao nó chạy, thì thường là em chưa fix gì cả. Đúng không anh?

Đúng. Và lần này còn tệ hơn “chưa fix”: nó vừa tắt cái chuông báo cháy trong khi đám cháy còn nguyên.

Trước hết, một từ mới cho hôm nay - chỉ một thôi: bag. Trong ngôn ngữ Hibernate, một List mà bạn không khai thứ tự (không @OrderColumn) được coi là bag: một túi đồ - chứa được phần tử trùng nhau, và không có thứ tự nào đáng tin. Java gọi nó là List, nhưng với Hibernate, không có cột nào dưới DB ghi “phần tử này đứng thứ mấy”, nên nó chỉ dám coi đó là một cái túi lộn xộn. Còn Set thì khác hợp đồng: không trùng, không hứa thứ tự.

Giờ nối vào chỗ bài trước dừng lại. Khi bạn join fetch một collection, ở tầng SQL mỗi cha nhân bản theo số con: đơn có 3 items chiếm 3 dòng. Đó là chuyện một collection. Còn fetch hai collection trong cùng một câu? SQL join tiếp - và hai phép nhân chồng lên nhau: đơn có 3 items và 2 tags chiếm 3 x 2 = 6 dòng, mỗi dòng là một tổ hợp (item nào x tag nào) - những tổ hợp mà chẳng ai hỏi, sinh ra thuần túy vì phép nhân Descartes. Dữ liệu thật của bạn có 3 + 2 = 5 mảnh; kết quả trả về 6 dòng ghép chéo.

Việc của Hibernate là nhìn đống dòng ghép chéo đó và dựng ngược lại object: đơn này thật ra có mấy item, mấy tag? Với Set, làm được - set không cho trùng, nên cứ gom hết rồi khử trùng lặp, ra đúng 3 và 2. Nhưng với bag thì bế tắc theo đúng nghĩa toán học: bag được quyền chứa trùng. Thấy item A xuất hiện 2 lần trong kết quả, Hibernate không thể biết đó là dữ liệu thật có hai bản hay ảo ảnh của phép nhân. Khử trùng lặp thì có thể xóa nhầm dữ liệu thật; giữ nguyên thì bơm ảo ảnh vào túi của bạn. Hai bag cùng lúc - không tồn tại cách dựng ngược đúng.

Và đây là chỗ mình muốn bạn nhìn Hibernate bằng con mắt khác: gặp bài toán không có lời giải đúng, nó từ chối ngay từ cửa, to, rõ, chỉ đích danh hai cái túi. MultipleBagFetchException không phải là bug của bạn bị bắt quả tang - nó là chuông báo cháy: “câu query này sẽ tạo tích Descartes mà tôi không dựng ngược nổi.” Sau một series toàn những cú im lặng, đây là lần hiếm hoi hệ thống gào lên trước khi thiệt hại xảy ra. Ồn ào là ân huệ - bạn nhớ câu đó từ bài lazy chứ.

Giờ soi cái “fix” nhiều vote nhất: đổi List thành Set. Điều nó thực sự làm: biến bài toán không giải nổi thành giải nổi - Hibernate giờ khử trùng lặp được, exception biến mất, kết quả object đúng. Nhưng câu SQL bên dưới? Vẫn nguyên phép nhân. Đơn 30 items x 20 tags = 600 dòng qua dây mạng cho một đơn hàng, 12.000 dòng cho trang 20 đơn - để dựng lại đúng 1.000 mảnh dữ liệu thật. Bạn không dập lửa. Bạn gỡ pin cái chuông. App khởi động êm, chạy đúng, và âm thầm kéo một tích Descartes qua mạng mỗi request - cho tới ngày ai đó thêm collection thứ ba, hay dữ liệu đủ lớn, và không còn exception nào đứng ra cảnh báo giùm nữa.

Một ghi chú version, vì chỗ này docs cũ mới lẫn lộn dễ lạc: triệu chứng “join fetch xong nhận cha trùng lặp trong List kết quả” - thứ làm thế hệ Hibernate 5 phải rắc distinct khắp nơi - thì từ Hibernate 6 trở đi (Boot 3.x là môi trường của cả series này; Hibernate 7 trên Boot 4 vẫn y vậy) bạn không còn thấy nữa: Hibernate tự khử trùng lặp entity cha trong kết quả. Đọc blog 2019 thấy người ta vật lộn với distinct và PASS_DISTINCT_THROUGH, đó là chuyện đời trước. Nhưng đừng nhầm: cái đổi là cách Hibernate dọn dòng thừa sau khi nhận, không phải phép nhân - ở tầng SQL, dòng vẫn nhân bản y nguyên, tiền băng thông vẫn trả đủ, và MultipleBagFetchException thì vẫn ném như ngày nào. Cái luôn-đúng là toán; cái đổi-theo-version là khâu dọn dẹp.

Vậy fix thật là gì? Quay về bài học đã có: đừng bắt một câu query gánh hai phép nhân. Đường một - tách query, mỗi collection một chuyến: query một fetch items, query hai fetch tags, cuốn sổ tự ráp (nó nhận diện cha theo danh tính trong Map - bạn biết vì sao nó ráp được rồi đấy). Hai query tuyến tính thay một query nhân - lại là “đáp án đôi khi bằng HAI” của bài trước. Đường hai, ít người biết mà rất đáng: bật batch fetching toàn cục - hibernate.default_batch_fetch_size=50 - để lazy load gom theo lô (WHERE order_id IN (...50 id...)) thay vì từng tờ giấy hẹn một; nhiều app xóa được cả N+1 lẫn nhu cầu join fetch chỉ bằng một dòng config này. Còn @OrderColumn - biến List thành list-có-thứ-tự-thật để hết là bag - thì đúng là hết exception, nhưng cũng chỉ là một cách khác để gỡ pin chuông: phép nhân còn nguyên.

Muốn tự thấy tận mắt, năm phút: giữ nguyên hai Set, bật log SQL, chạy query fetch đôi và đếm dòng kết quả bằng một câu native select count(*) cùng join - rồi so với tổng items + tags thật. Khoảng cách giữa hai con số là kích thước đám cháy mà cái chuông từng cố báo cho bạn.

Còn nguyên lý, thứ sống lâu hơn Hibernate: khi một hệ thống ném lỗi thay vì đoán, lỗi đó là món quà - và mọi cách “làm lỗi biến mất” phải bị tra hỏi xem nó giải bài toán hay chỉ giải cái thông báo. Compiler warning bị tắt bằng annotation. Test flaky bị khoác @Disabled. Type error bị dập bằng as any. Cùng một cám dỗ: sự im lặng mua được ngay, đám cháy trả góp về sau. Trong một series toàn bug im lặng, bài học hôm nay hơi ngược đời mà cùng một gốc: kẻ thù chưa bao giờ là tiếng ồn - kẻ thù là sự im lặng, kể cả thứ im lặng do chính tay bạn tạo ra.

Thằng em đọc xong, đổi lại Set thành List, tách hai query, rồi nhắn:

Gặp exception khó chịu, câu đầu tiên không phải ‘làm sao cho nó hết’ - mà là ‘nó đang cố cứu em khỏi cái gì’.

thang em Vậy từ giờ gặp exception khó chịu, câu đầu tiên em hỏi không phải ‘làm sao cho nó hết’ - mà là ‘nó đang cố cứu em khỏi cái gì’.

Đúng rồi đó. Chuông kêu thì đi tìm lửa, đừng đi tìm pin. 😃

Tài nguyên đi kèm

  • ZIP Source demo - tập 4.3

    JDK 17. mvn spring-boot:run → Episode43Runner in khối [EP 4.3]: fetch cả items lẫn tags trong một câu thì chuông nào kêu, đâm đầu chạy tiếp thì sao, và cách mỗi bag một chuyến.

    https://caltalys.dev/downloads/book1/4.3-spring-deep-dive-demo.zip

https://caltalys.dev/multiple-bag-fetch

Caltalys · 06/10/2026 · JPA · Hibernate · Mapping · Hiệu năng