LazyInitializationException: failed to lazily initialize a collection - no Session
Tắt open-in-view làm app nổ mười bảy chỗ, vì cả mười bảy chỗ đó lâu nay sống nhờ một cái filter mở sổ hộ bạn mà không ai biết.
Thằng em nhắn, và lần này thủ phạm là một bài blog.
spring.jpa.open-in-view là anti-pattern, app nghiêm túc thì phải tắt. Nghe thuyết phục lắm. Em thêm đúng một dòng open-in-view: false vào config, chạy lại - và app em nổ ở mười bảy chỗ. Cùng một exception: org.hibernate.LazyInitializationException:
failed to lazily initialize
a collection of role: com.acme.Order.items: could not initialize proxy - no Session
Câu hỏi hay nhất nó từng hỏi. Vì để trả lời, phải chập ba thứ bạn đã học ở ba mùa khác nhau vào cùng một hiện trường. Bài hôm nay là bài bản lề của cả series - nếu series này là một vòm cầu thì đây là viên đá đỉnh vòm.
Bắt đầu từ chữ proxy trong thông báo lỗi. Bạn đã gặp proxy ở mùa một - nhưng đã tám bài trôi qua, nên mình dựng lại từ đầu, coi như bạn chưa đọc: proxy là một object đóng thế, sinh lúc runtime, trông giống hệt vật thật, đứng chắn giữa bạn và vật thật. Mùa một, Spring đúc proxy để chèn transaction vào trước lời gọi. Hôm nay, Hibernate đúc proxy vì một lý do khác hẳn: để khỏi phải làm việc.
Chuyện thế này. Bạn load một Order. Trong Order có @ManyToOne Customer customer và @OneToMany List<OrderItem> items. Nếu Hibernate load tất cả - customer, rồi items, rồi những gì items trỏ tới, cứ thế lan ra - thì một cú findById có thể kéo nửa cái database vào RAM. Nên mặc định với collection, nó chọn lazy: khoan load, để đó, ai hỏi thì tính.
Nhưng field items không thể để null - null nghĩa là “không có gì”, sai bét. Nó cũng chưa thể là dữ liệu thật. Vậy Hibernate đặt vào đó một tờ giấy hẹn. Với collection, tờ giấy hẹn là một PersistentBag - một cái list rỗng biết đường quay về kho. Với @ManyToOne khai lazy - mặc định của nó là EAGER, phải tự viết fetch = LAZY thì mới có chuyện - nó là một object đóng thế đúng nghĩa: một subclass của Customer sinh lúc runtime, tên kiểu Customer$HibernateProxy$aBc123 - họ hàng xa của cái $$SpringCGLIB$$ mùa một, khác lò đúc, cùng nghề.
Hai loại giấy hẹn ấy nổ ra hai câu khác nhau, và câu nào cũng nói cho bạn biết bạn đang cầm loại nào:
failed to lazily initialize a collection of role: com.acme.Order.items
-> giay hen kieu PersistentBag
could not initialize proxy [com.acme.Customer#5] - no Session
-> giay hen kieu $HibernateProxy$
Cùng một tai nạn, hai cái tên. Đọc tên trước khi đi tìm, nó tiết kiệm cho bạn nửa buổi.
Và đây là dòng quan trọng nhất bài: tờ giấy hẹn đó ghi địa chỉ của ai? Của cuốn sổ - cái persistence context đã sinh ra nó. Khi bạn gọi order.getItems().size(), cái bag không có dữ liệu; nó cầm tờ giấy hẹn quay về đúng cuốn sổ ghi trên đó, xin chạy nốt câu SELECT còn thiếu.
Giờ ghép ba mùa lại. Mùa không: mọi thứ có thời điểm, và đứng sai chỗ trên trục thời gian thì không ai báo bạn. Mùa ba, bài một: cuốn sổ sống theo transaction - transaction đóng, sổ đóng, nằm chung cái túi của thread. Và hôm nay: tờ giấy hẹn trỏ về cuốn sổ.
Ba mảnh ấy va nhau ở đúng một khoảnh khắc: controller - hay serializer, hay template - chạm vào getItems() sau khi transaction của service đã kết thúc. Cái bag cầm giấy hẹn quay về… và văn phòng đã đóng cửa. Sổ không còn. Không sổ thì không connection, không SELECT, không gì cả. could not initialize proxy - no Session - dịch sát: tôi cầm giấy hẹn tới nơi thì người hứa đã về mất rồi.
Chú ý một điều làm exception này khác mọi bug im lặng trước nay của series: nó ồn ào. Nó nổ, nó có stacktrace, nó chỉ thẳng field nào. So với những gì bạn đã chịu đựng - số 0 im lặng, transaction bốc hơi, field lưu một nửa - thì đây gần như là ân huệ. Nhớ lấy cảm giác đó, vì lát nữa bạn sẽ hiểu: cái “fix” phổ biến nhất cho lỗi này chính là đổi một lỗi ồn ào lấy một chùm bug im lặng. Bạn đã gặp kiểu đánh đổi này ở bài @Controller rồi đấy.
Bởi vì giờ mới tới câu hỏi thứ hai của thằng em - câu sâu hơn: trước khi tắt cái cờ đó, sao mười bảy chỗ kia chạy được?
Vì Boot bật sẵn cho nó một thứ tên là Open Session In View. Lại là một lời-mời-sẵn của Boot - bạn còn nhớ bài @Async chứ: Boot mời sẵn người lo transaction nhưng không mời người lo async; ở đây Boot mời sẵn cả một cơ chế mà đa số người dùng không biết mình đang dùng. OSIV là một cái filter đứng ở cổng web: request vừa tới, nó mở cuốn sổ ngay - trước cả controller - và chỉ đóng khi response đã render xong. Transaction của service vẫn mở-đóng như thường, nhưng cuốn sổ thì sống dai hơn transaction, phủ trọn request. Thế nên giấy hẹn đòi lúc nào cũng có người tiếp: controller chạm lazy - sổ còn đó, chạy SELECT, êm. Mười bảy chỗ của thằng em chạy nhờ đúng cái đó. Boot thậm chí biết chuyện này nhạy cảm tới mức in hẳn một dòng warning lúc khởi động - đại ý “open-in-view đang bật mặc định, query có thể chạy trong lúc render view” - dòng warning mà, thành thật đi, chưa ai trong chúng ta đọc.
Vậy OSIV tốt quá, sao blog kia đòi tắt? Vì cái giá của nó ghi bằng đúng hai thứ bạn đã học.
Một - cái giá về connection. Chỗ này mình phải nói chậm, vì nó bị chép sai theo cả hai hướng. Cuốn sổ và cái suất trong pool đúng là hai thứ có vòng đời riêng: Hibernate lấy connection muộn nhất có thể, nên riêng việc OSIV mở sổ ở đầu request không tốn suất nào. Cái đó đo được, và đúng. Nhưng câu SQL đầu tiên trong phạm vi cuốn sổ ấy lấy một suất - và suất đó không trả lại khi transaction đóng. Số đo, lấy trên chính cuốn sổ mở kiểu OSIV:
mo so, chua chay gi -> 0 suat
transaction cua service dong -> 1 suat <- KHONG nha
cho 300ms -> 1 suat
cham lazy -> 1 suat
DONG so -> 0 suat
Transaction đóng không trả suất. Chỉ đóng sổ mới trả. Mà với OSIV, đóng sổ nghĩa là hết request, sau khi render xong. Nên cái blog kia, về thực chất, nói đúng: OSIV giữ connection suốt request. Nó chỉ sai một chi tiết - và chi tiết ấy mới là thứ bạn dùng khi đi chẩn đoán: nó giữ từ câu SQL đầu tiên, không phải từ đầu request. Một request không chạm DB lần nào thì không ghim gì cả. Nên đừng đọc open-in-view: true rồi kết luận ngay, mà cũng đừng bác bỏ nó bằng lý thuyết: đo bằng hikaricp.connections.active, đúng cái đồng hồ mùa hai.
Hai, và đây là cái giá không có ngoại lệ nào: khi sổ luôn sẵn sàng, mọi cú chạm lazy đều lặng lẽ thành SQL - từ template, từ Jackson, từ một vòng lặp vô tình ở tầng chẳng ai nghĩ là tầng dữ liệu. Query sinh ra ở nơi không ai canh, mỗi phần tử một câu SELECT, và app “chậm dần đều mà không một dòng lỗi”. Cái tên của thảm họa đó là N+1 - mình treo nó ở đây, mùa sau nó là nhân vật chính.
Ba - và cái này thì bài mở mùa đã hẹn với bạn rồi. Nhớ cú setName ngoài transaction ở bài cuốn sổ chứ, cái cú rơi vào khoảng không vì object đã detached? Với OSIV bật, nó không detached. Sổ còn mở nghĩa là nó vẫn có tên trong sổ - và một @Transactional bất kỳ chạy sau đó trong cùng request sẽ flush hộ bạn. Đo được:
sau khi transaction cua service dong, con managed ? true
note trong DB ngay sau cu set -> chua doi
note trong DB sau MOT transaction khac -> DA DOI
Không ai gọi save. Không ai định ghi. Và cái transaction thứ hai kia có thể đang làm một việc chẳng liên quan gì - ghi một dòng log, đếm một bảng khác - nó chỉ vô tình đi ngang một cuốn sổ đang cầm kẻ dirty. Ở bài cuốn sổ mình bảo cú set ngoài transaction là sửa hồ sơ của một người mà không văn phòng nào còn quản lý. Nói cho đủ, hôm nay: với OSIV, văn phòng chưa đóng cửa. Nó vẫn đang ghi chép, và bạn thì tưởng mình đang viết nháp.
Nên tắt OSIV không phải là gây ra bug. Nó là đòi nợ đúng hạn: mọi món lazy phải được trả trong transaction, không được vay qua đêm. Mười bảy cú nổ của thằng em là mười bảy chỗ codebase đang vay lười mà không biết. Exception ồn ào chỉ là giấy báo nợ về đúng địa chỉ.
Còn một biến thể phải kể nốt, vì nó bắt cả những người đang bật OSIV: @Async và @Scheduled. OSIV là cái filter của request thread. Method async chạy ở thread khác - túi khác, và túi đó không có sổ nào. Entity load ở thread web, đem sang thread nền chạm lazy - nổ y hệt, kể cả OSIV bật. Bạn đã học vì sao ở bài @Async rồi: việc chạy ở thread khác, thì ngữ cảnh cũng ở lại thread cũ.
Fix, xếp theo thứ tự mình khuyên. Một - trả nợ trong transaction: chỗ nào cần items, load nó ngay lúc query bằng JOIN FETCH hoặc @EntityGraph, để entity ra khỏi service là đã đầy đủ, không còn giấy hẹn nào chưa thanh toán. (Fetch cho đúng là cả một nghệ thuật có bẫy riêng - mùa sau.) Hai - đừng để entity đi xa: tầng web nhận DTO, và DTO thì được đắp đầy đủ từ trong transaction; entity mang giấy hẹn lang thang lên tầng serializer là mầm của một thảm họa mà mình để dành làm bài cuối cùng của cả series. Ba - về cái cờ: bật hay tắt OSIV đều được, miễn là quyết định có ý thức. Tội không nằm ở cái cờ. Tội nằm ở việc không biết nó tồn tại.
Muốn tự thấy tận mắt: đặt breakpoint sau findById rồi soi items - bạn sẽ thấy không phải ArrayList mà là PersistentBag, và soi customer (nhớ khai fetch = LAZY) sẽ thấy cái tên $HibernateProxy$. Tờ giấy hẹn hiện nguyên hình.
Nhưng đừng soi bằng panel biến của IDE. PersistentBag có toString() riêng, và toString() của nó đọc collection, tức là khởi tạo luôn cái thứ bạn đang muốn bắt quả tang lúc chưa khởi tạo. Debugger nào gọi toString() để hiển thị là debugger đó vừa trả lời hộ câu hỏi của bạn. In ra log thì an toàn:
System.out.println(order.getItems().getClass().getName()); // ...PersistentBag
System.out.println(Hibernate.isInitialized(order.getItems())); // false
Hibernate.isInitialized không chạm vào gì cả - nó chỉ hỏi, không đòi. Rồi bật open-in-view: false trong một nhánh test và chạy bộ integration test: mỗi chỗ đỏ là một món nợ lười codebase đang giấu bạn.
Còn nguyên lý - và vì đây là bài bản lề, nguyên lý hôm nay là ba nguyên lý cũ bắt tay nhau: một lời hứa (mùa một: cần người trực) chỉ thực hiện được khi người hứa còn tại nhiệm (mùa ba: sổ theo transaction), nên giá trị của nó phụ thuộc vào thời điểm bạn đòi (mùa không: đứng đúng chỗ trên trục thời gian). Một Future chưa ai get() thì executor đã shutdown. Một callback đăng ký vào một widget đã unmount. Một file handle dùng sau khi close. Cùng một tai nạn: giấy hẹn sống lâu hơn người hứa.
Thằng em đọc xong, nhắn:
Lazy không phải ‘chưa load’ - là ‘load bằng tiền vay’, và phải biết sổ nợ đóng cửa lúc nào.
Đúng rồi đó. Boot gia hạn hộ em từng request một. Còn chuyện em vay bao nhiêu - mùa sau đếm. 😃
Tài nguyên tập 12
Cheat-sheet tập 12 Toàn bộ cơ chế trên một trang - in ra hoặc lưu về máy được. Source demo - tập 3.3JDK 17.mvn spring-boot:run → Episode33Runner in khối [EP 3.3]: chạm collection lazy ngoài transaction ném gì, rồi so với JOIN FETCH và với cách mở session bằng tay.Vị trí trong Lớp Trung Gian Vô Hình
Bài này trả lời
-
LazyInitializationExceptionkhi trả dữ liệu ra ngoài tầng service