Hình dung một quan hệ LAZY như một tấm phiếu hẹn lấy hàng. Khi bạn lấy bản ghi tác giả, danh sách bài của họ chưa nằm trong tay — bạn chỉ cầm một tấm phiếu "đến quầy lấy sau". Và tấm phiếu đó chỉ đổi được khi quầy còn mở (session còn sống). Giao dịch đóng là quầy đóng cửa, tấm phiếu thành vô giá trị. Bài hôm nay về ngoại lệ mà mọi người dùng JPA đều gặp, và về cấu hình mặc định gây tranh cãi nhất của Spring Boot.
Ngoại lệ
@Transactional(readOnly = true)
public TacGia layMot() { return repo.findAll().get(0); } // giao dịch đóng ở đây
// bên ngoài:
var t = dv.layMot();
t.bais.size();
Hibernate.isInitialized(bais) = false
LazyInitializationException: failed to lazily initialize a collection of role:
TacGia.bais: could not initialize proxy - no Session
Quan hệ LAZY chưa được nạp. Nó chỉ là một proxy rỗng — tấm phiếu — và để đổi được nó cần một Session đang mở. Giao dịch đã đóng, session đã đóng, nên nó ném.
Dòng isInitialized = false là cách kiểm tra không gây nổ — hữu ích khi gỡ lỗi.
Bốn cách sửa
Nạp sẵn thứ bạn cần (khuyên dùng):
@EntityGraph(attributePaths = "bais")
List<TacGia> findAllBy();
Rõ ràng, và bài 24 đã đo là nhanh nhất.
Chuyển sang DTO trong giao dịch (khuyên dùng nhất):
@Transactional(readOnly = true)
public TacGiaDto layMot() {
var t = repo.findAll().get(0);
return new TacGiaDto(t.getTen(), t.getBais().size()); // chạm lazy ở ĐÂY
}
Entity không bao giờ ra khỏi tầng service. Mọi tấm phiếu được đổi ngay khi còn ở quầy, và bạn bước ra với hàng thật trong tay — không có lazy nào bị chạm ở ngoài, nên vấn đề biến mất tận gốc.
Mở rộng giao dịch — đôi khi đúng, nhưng cẩn thận: giao dịch kéo dài giữ kết nối trong pool, và bài 34 sẽ cho thấy pool là trần thông lượng.
Chuyển sang EAGER — đừng. Bài 24 đã đo cái giá: mọi truy vấn đều trả tiền, kể cả những truy vấn không cần.
OSIV: cái mặc định gây tranh cãi
Spring Boot mặc định bật spring.jpa.open-in-view: true, và nó cảnh báo về chính nó ở mỗi lần khởi động:
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be
performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable
this warning
OSIV giữ EntityManager mở suốt cả request, không chỉ trong @Transactional — giữ cái quầy mở cả buổi bạn còn trong cửa hàng. Nhờ vậy mã ở trên chạy được: template Thymeleaf hay Jackson chạm vào lazy vẫn đổi phiếu được.
Nghe tiện. Đây là cái giá:
N+1 trở nên vô hình. Template lặp qua danh sách và chạm quan hệ lazy — người bán chạy vào kho lấy từng món một, mỗi lần là một truy vấn, phát sinh trong lúc render, không nằm trong bất kỳ phương thức service nào. Bài 24 vừa đo 21 truy vấn cho 20 bản ghi; với OSIV, chúng phát sinh ở chỗ bạn không nghĩ tới mà tìm.
Kết nối bị giữ lâu hơn cần. Kết nối được giữ tới khi response ghi xong — cái quầy duy nhất bị chiếm tới lúc bạn rời cửa hàng. Ứng dụng chậm ở tầng render sẽ giữ kết nối trong khi không dùng, và pool cạn — đúng vấn đề bài 34.
Truy vấn ngoài giao dịch. Chúng chạy ở chế độ tự động commit, mỗi câu một giao dịch riêng. Bạn mất tính nhất quán mà không nhận ra.
Nó che lỗi thiết kế. Mã chạy được, nên không ai biết tầng service đang trả về entity chưa nạp đủ. Tắt OSIV sau hai năm là hàng chục chỗ nổ cùng lúc.
Tắt hay không
spring:
jpa:
open-in-view: false
Dự án mới: tắt. Nó buộc bạn nghĩ về việc nạp dữ liệu ngay từ đầu, và đó là thói quen đúng.
Dự án đang chạy: cẩn thận. Tắt sẽ làm lộ ra mọi chỗ đang dựa vào nó, cùng lúc. Cách tôi làm: tắt ở môi trường dev trước, sửa dần, rồi mới tắt ở sản xuất.
Cân nhắc trung thực: với ứng dụng render phía máy chủ, quy mô nhỏ, một người duy trì — OSIV thật sự tiện và rủi ro thấp. Với API và ứng dụng nhiều người dùng, nó là nợ kỹ thuật tích lũy im lặng.
Và dù chọn gì, hãy khai tường minh. Để mặc định nghĩa là bạn chưa quyết định, và bạn có một dòng WARN ở mỗi lần khởi động — mà bài 60 sê-ri Java đã nói: log ồn thì che mất cảnh báo thật.
Proxy: hai chuyện lạ
getId() không kích hoạt nạp. Hibernate biết id mà không cần truy vấn, nên đọc id trên proxy là rẻ. Mọi thuộc tính khác đều kích hoạt.
instanceof và equals gặp proxy. Proxy là lớp con do Hibernate sinh, nên t.getClass() == TacGia.class trả false. Đây là lý do equals phải dùng instanceof chứ không so lớp — đúng như hợp đồng equals ở bài 18 sê-ri Java. Với Hibernate, dùng Hibernate.getClass(x) để lấy lớp thật.
Cách gọn nhất: đừng để entity ra ngoài
Nghe lặp lại, nhưng nó giải quyết cả bài này lẫn bài 15:
Controller <-> DTO
|
Service <-> entity, trong giao dịch
|
Repository
Entity sống trong tầng service, trong giao dịch. Ra khỏi đó là DTO. Không có LazyInitializationException, không có N+1 lúc render, không có vòng lặp tham chiếu khi serialize, không lộ cột nội bộ.
Tốn thêm mã. Đổi lại là bốn loại lỗi biến mất — và mỗi loại đều mất hàng giờ để tìm ra khi gặp lần đầu.
Nếu chỉ kiểm một thứ sau bài này, xem dự án bạn đã quyết định về OSIV chưa:
grep -rn "open-in-view" src/main/resources/
Không có kết quả nghĩa là bạn đang chạy với OSIV bật mà chưa quyết định điều đó. Thêm một dòng — dù chọn true hay false — cũng tốt hơn là để mặc định.
Mẫu số chung
Cái proxy-cần-session-sống này không phải đặc sản Hibernate — nó là bản chất của mọi ORM kiểu "đơn vị công việc" (unit of work). Quan hệ lazy ở đâu cũng là một tấm phiếu chỉ đổi được khi đơn-vị-công-việc còn mở, và chạm vào nó sau khi đóng thì cùng một lỗi, khác tên:
- SQLAlchemy (Python) ném
DetachedInstanceErrorkhi bạn chạm thuộc tính lazy sau khi session đóng — bản sao gần như y hệt. - EF Core (.NET) ném lỗi "context đã bị dispose" trong đúng tình huống đó.
- Rails ActiveRecord và Django thì không ném mà lặng lẽ bắn thêm truy vấn — chính là N+1 lúc render, chỉ khác là không có cả tiếng động để cảnh báo.
Và "giữ session mở cả request" (OSIV cùng họ hàng của nó) là một tiện nghi rò rỉ kinh điển: nó làm lỗi biến mất bằng cách dời việc xuống tầng view — đúng nơi cái giá của nó (N+1, kết nối bị giữ, truy vấn ngoài giao dịch) khó nhìn thấy nhất. Đây cùng một bài học với "hãy để lỗi nổ ở chỗ phạm sai, đừng để nó trôi xuống ba tầng sau".
Sợi chỉ chung đáng mang theo: một tay cầm nạp-lười chỉ còn sống bên trong cái phạm vi sinh ra nó — hãy lấy thứ bạn cần trước khi phạm vi đó đóng (nạp sẵn, DTO, join tường minh), và nghi ngờ bất kỳ cấu hình nào mà việc duy nhất của nó là cho bạn quên mất cái ranh giới đó, vì nó không xoá cái giá, nó chỉ giấu cái giá ở tầng render.
Ngày mai: @Transactional bên trong — và vì sao gọi phương thức của chính mình thì nó không chạy.