Lazy vs Eager: lúc nào Hibernate mới chạy SELECT?
Tiếp đi: Lazy loading.
Bài trước (N+1) ta thấy một vòng lặp có thể sinh ra hàng trăm câu SELECT. Gốc của chuyện đó là fetch type: Hibernate load quan hệ khi nào. Bài này đi thẳng vào Lazy vs Eager trên project shop-app (Order, OrderItem, User, Product, PostgreSQL).
1. Bài toán
Bạn cần lấy một Order:
Order order = orderRepository.findById(1L).get();
Nhưng Order có User, có OrderItems, và mỗi OrderItem lại có Product. Có cần load tất cả những dữ liệu đó ngay không?
Order ──► User
│
└──► OrderItem ──► Product
OrderItem ──► Product
...
2. Vấn đề
- Load hết (EAGER) → màn hình chỉ cần
order.idvàstatusmà vẫn kéo cả User, Items, Products. Tốn query, tốn RAM. - Load lười (LAZY) → truy cập quan hệ sau khi transaction đóng thì nổ
LazyInitializationException.
Giá trị mặc định của JPA lại không đồng nhất, đây là chỗ hay bị hỏi:
| Annotation | Mặc định | Nên đặt |
|---|---|---|
@ManyToOne | EAGER | LAZY |
@OneToOne | EAGER | LAZY |
@OneToMany | LAZY | LAZY |
@ManyToMany | LAZY | LAZY |
Quy tắc dễ nhớ: "to-One" mặc định EAGER, "to-Many" mặc định LAZY. Thực tế nên đặt LAZY hết, rồi fetch theo từng use case.
3. Giải pháp: LAZY mặc định + fetch có chủ đích
Lazy loading:
@ManyToOne(fetch = FetchType.LAZY)
private User user;
Hibernate trì hoãn việc load User cho đến khi cần truy cập. Field user lúc đầu là một proxy (lớp con sinh động, chỉ biết id).
@Transactional
public void demo() {
Order order = orderRepository.findById(1L).get();
System.out.println(order.getId());
System.out.println(order.getUser().getName());
}
SQL có thể diễn ra:
SELECT * FROM orders WHERE id = 1;
SELECT * FROM users WHERE id = 10; -- chỉ chạy khi gọi getName()
Eager loading:
@ManyToOne(fetch = FetchType.EAGER)
private User user;
Hibernate phải bảo đảm User được load cùng lúc load Order, nhưng không nhất thiết bằng một SQL JOIN; với query JPQL nó có thể bắn thêm SELECT, tức là N+1 trá hình.
4. Code từng bước
Bước 1: Entity đặt LAZY cho mọi quan hệ
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
}
Bước 2: Config, tắt open-in-view, bật log SQL khi dev
spring:
jpa:
open-in-view: false
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
Bước 3: Code gây lỗi
public Order getOrder(Long id) { // không @Transactional
return orderRepository.findById(id).get();
}
// Controller
Order order = orderService.getOrder(1L);
order.getItems().size(); // Persistence Context đã đóng
org.hibernate.LazyInitializationException:
could not initialize proxy - no Session
Bước 3b: Sửa đúng bằng cách load đúng thứ cần trong transaction
public interface OrderRepository extends JpaRepository<Order, Long> {
// Cách 1: JOIN FETCH
@Query("""
SELECT o FROM Order o
JOIN FETCH o.user
LEFT JOIN FETCH o.items i
LEFT JOIN FETCH i.product
WHERE o.id = :id
""")
Optional<Order> findDetailById(@Param("id") Long id);
// Cách 2: EntityGraph
@EntityGraph(attributePaths = {"user", "items", "items.product"})
Optional<Order> findWithItemsById(Long id);
}
@Service
public class OrderQueryService {
private final OrderRepository repo;
OrderQueryService(OrderRepository repo) { this.repo = repo; }
@Transactional(readOnly = true)
public OrderDto getDetail(Long id) {
Order o = repo.findDetailById(id).orElseThrow();
return OrderDto.from(o); // map sang DTO TRONG transaction
}
}
Bước 4: Test bằng curl
curl http://localhost:8080/orders/1
# log: đúng 1 câu SELECT ... join users ... join order_items ... join products
5. Cách hoạt động
findById(1)
│
▼
Order{id=1, user = UserProxy(id=10, initialized=false),
items = PersistentBag(initialized=false)}
│
├─ order.getId() → không query (id có sẵn)
├─ order.getUser().getName()
│ │
│ ▼ Session còn mở?
│ ┌──┴──┐
│ Có Không
│ │ └─► LazyInitializationException
│ ▼
│ SELECT * FROM users WHERE id = 10
└─ ...
Open Session in View (OSIV). Spring Boot mặc định spring.jpa.open-in-view=true và in ra WARN khi khởi động. OSIV mở EntityManager từ đầu đến cuối HTTP request, nên lazy load trong Controller hay lúc Jackson serialize "vẫn chạy".
HTTP request ─────────────────────────────────────────► response
[ EntityManager mở (OSIV) ...................................... ]
[ @Transactional service ]
Controller / Jackson
→ lazy SELECT ngoài transaction
→ giữ DB connection lâu hơn
Vì sao nên tắt:
- Che giấu N+1: query bắn ra ở tầng view, khó thấy.
- Giữ connection của pool lâu hơn (cả lúc render JSON) → dưới tải cao dễ cạn HikariCP.
- Lazy load chạy ngoài transaction (auto-commit), khó kiểm soát.
Tắt OSIV, lỗi lộ ra sớm ở dev, và bạn buộc phải fetch đúng trong service.
6. So sánh
| Cách | Ưu | Nhược |
|---|---|---|
| Đổi sang EAGER | Hết LazyInitializationException | Load thừa ở mọi chỗ, N+1 với JPQL, không tắt được theo use case |
| JOIN FETCH | 1 query, rõ ràng | Fetch nhiều collection → MultipleBagFetchException; lỗi với pagination |
| @EntityGraph | Khai báo fetch plan trên repository method | SQL cụ thể do provider quyết định |
| DTO projection | Chỉ lấy field cần, không có proxy | Phải viết DTO/query riêng |
| OSIV = true | Code "chạy được" nhanh | Ẩn N+1, giữ connection lâu |
Hibernate.initialize() | Ép init trong transaction | Vẫn bắn query riêng |
7. Khi nào nên dùng / không nên dùng
- Nên: LAZY cho mọi quan hệ; fetch bằng JOIN FETCH / EntityGraph / DTO đúng theo màn hình.
- Nên:
open-in-view: falsecho REST API. - Không nên: đổi tất cả sang EAGER để "chữa" lỗi.
- Không nên: trả Entity thẳng ra Controller rồi để Jackson chạm vào quan hệ lazy.
- Không nên: bật
hibernate.enable_lazy_load_no_trans, mỗi lần lazy load mở một session tạm, N+1 nặng hơn.
Vậy cứ thêm @Transactional vào controller là hết LazyInitializationException đúng không?
Hết lỗi thì đúng, nhưng đó là chữa triệu chứng. Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: lỗi xảy ra vì truy cập quan hệ khi Persistence Context đã đóng. Kéo transaction ra tận controller thì context sống lâu hơn, nhưng:
- Transaction (và DB connection) bị giữ trong lúc làm việc không liên quan DB.
- N+1 vẫn còn, chỉ là không còn báo lỗi.
Cách đúng: service có @Transactional(readOnly = true), fetch đúng quan hệ cần, map sang DTO bên trong service, controller chỉ nhận DTO.
order.getUser().getId() có bắn query không?
Thường là không. Proxy của @ManyToOne đã biết id (lấy từ cột user_id của bảng orders), nên Hibernate trả id luôn mà không initialize proxy, kể cả ngoài session (với access type theo field, Hibernate 6). Gọi getName() mới cần SELECT.
Còn với @OneToOne phía không sở hữu (mappedBy), Hibernate không biết có bản ghi hay không nên thường không làm lazy được, sẽ load ngay. Đây là lý do @OneToOne hai chiều hay gây query bất ngờ.
Phỏng vấn hỏi Lazy vs Eager, LazyInitializationException, open-in-view thì trả lời sao?
Q1. Why is Lazy Loading generally preferred? / Vì sao nên dùng LAZY?
🇻🇳 Tiếng Việt:
Lazy loading tránh load những entity liên quan không cần thiết và cho developer kiểm soát việc truy cập database. Lưu ý
@ManyToOnevà@OneToOnemặc định là EAGER, em thường đặt LAZY cho tất cả rồi fetch theo use case bằng JOIN FETCH, EntityGraph hoặc DTO projection. Đổi lại phải thiết kế ranh giới transaction và query cẩn thận.
🇬🇧 English:
Lazy loading avoids loading unnecessary related entities and gives developers more control over database access. Note that
@ManyToOneand@OneToOneare EAGER by default, so I make every association LAZY and fetch per use case with JOIN FETCH, an entity graph or a DTO projection. However, it requires careful transaction boundaries and query design.
Q2. LazyInitializationException là gì, xử lý thế nào?
🇻🇳 Tiếng Việt:
Là lỗi khi truy cập một proxy hoặc collection lazy chưa được khởi tạo sau khi Persistence Context đã đóng, thông báo "could not initialize proxy - no Session". Em xử lý bằng cách load đúng dữ liệu trong transaction ở service, dùng JOIN FETCH, EntityGraph hoặc DTO projection, rồi trả DTO. Em không đổi sang EAGER và không dựa vào open-in-view.
🇬🇧 English:
It is thrown when code touches an uninitialized lazy proxy or collection after the persistence context is closed: "could not initialize proxy - no Session". I fix it by loading exactly what the use case needs inside a service transaction, with JOIN FETCH, an entity graph or a DTO projection, and returning DTOs. I do not switch to EAGER and I do not rely on open-in-view.
Q3. Open-in-view là gì, vì sao nên tắt?
🇻🇳 Tiếng Việt:
Spring Boot bật mặc định, giữ EntityManager mở suốt HTTP request để lazy loading chạy được ở controller và lúc serialize JSON. Nhược điểm: che giấu N+1, query chạy ngoài transaction, và giữ DB connection lâu hơn nên dễ cạn connection pool khi tải cao. Em tắt nó cho REST API và fetch rõ ràng ở service.
🇬🇧 English:
Spring Boot enables it by default. It keeps the EntityManager open for the whole HTTP request so lazy loading works in controllers and during JSON serialization. The downsides are hidden N+1 queries, queries running outside a transaction, and database connections held longer, which can exhaust the pool under load. I disable it for REST APIs and fetch explicitly in the service layer.
Cách nhớ ngắn gọn
- "to-One" mặc định EAGER, "to-Many" mặc định LAZY → đặt LAZY hết.
- LAZY field = proxy; chạm vào ngoài session → LazyInitializationException.
- Chữa đúng: JOIN FETCH / EntityGraph / DTO trong transaction; không chữa bằng EAGER.
open-in-view: false.
Tự kiểm tra
Fetch type mặc định của @ManyToOne và @OneToMany?
@ManyToOne (và @OneToOne) mặc định EAGER; @OneToMany (và @ManyToMany) mặc định LAZY.
EAGER có đảm bảo chỉ một câu JOIN không?
Không. Hibernate chỉ đảm bảo dữ liệu được load cùng lúc; với JPQL thường là thêm SELECT riêng, dễ thành N+1.
Vì sao getUser().getId() thường không bắn query?
Proxy đã biết id từ cột khóa ngoại nên không cần initialize.
Ba tác hại của open-in-view?
Che giấu N+1, lazy query chạy ngoài transaction, giữ DB connection suốt request.
Cách đúng để controller trả chi tiết Order kèm items?
Service @Transactional(readOnly = true) load bằng JOIN FETCH/EntityGraph, map sang DTO trong service, trả DTO.