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, và proxy đó cần một Session đang mở để đi lấy dữ liệu. 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. 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. Nhờ vậy mã ở trên chạy được: template Thymeleaf hay Jackson chạm vào lazy vẫn nạp đượ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 — 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. Ứ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.
Thử ba mươi giây
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.
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.