Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

hibernate ORM

Tung Bui Thanh (TungDaDev)10 mins read

Hibernate.webp

Trong giới Java doanh nghiệp, Hibernate ORM (Object-Relational Mapping) gần như là lựa chọn mặc định suốt hai thập kỷ qua. Nó là phần chạy bên dưới đặc tả Jakarta Persistence (JPA) và là nền của Spring Data JPA.

Nhưng nhiều người vẫn nghĩ Hibernate chỉ là "công cụ sinh SQL tự động". Hiểu vậy thì sớm muộn cũng gặp mấy câu hỏi quen thuộc trên Production:

  • Tại sao mình không gọi save() hay update() mà dữ liệu trong Database vẫn bị đổi?
  • Dirty Checking chạy thế nào trong RAM?
  • 4 trạng thái vòng đời của Entity (Transient, Managed, Detached, Removed) tương tác với Persistence Context ra sao?
  • Tại sao thêm @Transactional(readOnly = true) lại đỡ tốn RAM và CPU hơn cho các luồng chỉ đọc?

Phần dưới đi qua cơ chế hoạt động của Hibernate để trả lời mấy câu đó.


# kiến trúc: sessionfactory vs session (entitymanager)

Trước hết phải phân biệt rõ hai thành phần này:

# điểm khác biệt chính:

  1. SessionFactory (hoặc EntityManagerFactory): đại diện cho toàn bộ schema và cấu hình kết nối. Khởi tạo metadata, nạp bảng mapping mất vài giây, và cả JVM dùng chung một cái.
  2. Session (hoặc EntityManager): là phiên làm việc ngắn, gắn với một Transaction của một HTTP request. Nó KHÔNG thread-safe (Non-Thread-Safe). Bên trong có một vùng nhớ cục bộ gọi là Persistence Context (First-Level Cache).

# bốn trạng thái vòng đời của entity (entity lifecycle)

Object Java khi đi qua Hibernate luôn nằm ở một trong bốn trạng thái:

Trạng tháiNằm trong Persistence Context?Có ID trong Database?Hibernate có tự động theo dõi thay đổi?
Transient (Tạm thời)🔴 Không🔴 Không🔴 Không. Chỉ là POJO Java bình thường trên Heap.
Persistent / Managed🟢 CÓ🟢 CÓ🟢 CÓ. Mọi thay đổi qua setter sẽ tự đồng bộ xuống DB.
Detached (Tách rời)🔴 Không🟢 Có🔴 Không. Session đã bị đóng; thay đổi setter sẽ không tự lưu.
Removed (Chờ xóa)🟢 Có (Được đánh dấu)🟢 Có (Sắp bị xóa)🟢 Có. Lệnh DELETE sẽ được bắn xuống DB khi commit.

Vài điểm hay bị nhầm trong sơ đồ này. Commit transaction không làm entity "biến mất": nó vẫn managed cho tới khi Session đóng (trong Spring, Session thường đóng cùng lúc với transaction, nên ra khỏi method @Transactional là entity thành detached). Gọi persist() lên một entity đang Removed thì nó quay lại trạng thái managed, chứ không về transient. Còn save(), update(), saveOrUpdate() là API cũ của Hibernate, đã deprecated từ Hibernate 6 và bị bỏ hẳn ở Hibernate 7; dùng persist(), merge() theo chuẩn JPA.


# dirty checking & first-level cache

Xem đoạn code sau:

@Transactional
public void updateUserEmail(Long userId, String newEmail) {
    User user = userRepository.findById(userId).orElseThrow();
    user.setEmail(newEmail); // Chỉ gọi setter!
    // KHÔNG CÓ DÒNG: userRepository.save(user); !!!
}

Nhiều bạn nghĩ không gọi save() thì database không đổi. Thực tế câu lệnh UPDATE vẫn được bắn xuống Database.

# dirty checking hoạt động thế nào

Khi một Entity được nạp vào Session từ DB:

  1. Lưu Entity: Hibernate đưa object user vào First-Level Cache (một Map<EntityKey, Object>).
  2. Chụp Snapshot: đồng thời lưu một bản sao trạng thái ban đầu của mọi field vào mảng loadedState (gọi là Entity Snapshot). Giá trị immutable như String, Long thì dùng chung tham chiếu; kiểu mutable như Date hay mảng byte mới được deep copy.

Nói chính xác thì dirty checking chạy lúc flush, không phải lúc method kết thúc. Flush xảy ra trước khi commit, nhưng cũng có thể xảy ra giữa chừng: với FlushMode.AUTO mặc định, trước khi chạy một câu query đụng tới bảng có entity đang bị sửa, Hibernate flush trước để query thấy dữ liệu mới. Vì vậy đôi khi bạn thấy câu UPDATE chạy ra trước cả câu SELECT trong log.


# tiết kiệm bộ nhớ với @Transactional(readOnly = true)

Dirty Checking rất tiện nhưng tốn tài nguyên: mỗi Entity nạp vào có thêm một mảng snapshot đi kèm, cộng thêm CPU để so từng field lúc flush. Load vài chục entity thì chẳng sao, load vài chục ngàn entity để export báo cáo thì khoản này bắt đầu đáng kể.

Với các thao tác chỉ đọc (Read-Only) như xem danh sách sản phẩm, export báo cáo, tìm kiếm:

@Transactional(readOnly = true)
public List<ProductDto> getProductCatalog() {
    return productRepository.findAll().stream()
        .map(this::toDto)
        .toList();
}

# bật readOnly = true thì có gì thay đổi?

  1. Không giữ Snapshot: Spring đặt Session sang chế độ read-only (setDefaultReadOnly(true)) và FlushMode.MANUAL. Entity load ra ở chế độ read-only thì Hibernate bỏ snapshot loadedState của nó.
    • Persistence Context nhẹ đi phần snapshot (bao nhiêu thì tuỳ entity, không có con số cố định).
    • Không còn dirty checking lúc kết thúc transaction. Nếu lỡ gọi setter thì thay đổi cũng không được ghi xuống DB.
  2. JDBC Driver & Database: Spring gọi connection.setReadOnly(true) trên JDBC connection. Với PostgreSQL, driver có thể chuyển nó thành transaction READ ONLY, database từ chối mọi câu ghi. Việc tự định tuyến sang Read-Replica thì không có sẵn: database không tự làm việc đó, mà phải do tầng ứng dụng (ví dụ AbstractRoutingDataSource của Spring kết hợp LazyConnectionDataSourceProxy) hoặc driver có hỗ trợ (như URL jdbc:mysql:replication:// của MySQL Connector/J) dựa vào cờ read-only này để chọn kết nối.

# first-level cache vs second-level cache vs query cache

Hibernate có cache nhiều tầng:

WARNING

Cạm bẫy của Query Cache: Tới Hibernate 5, Query Cache chỉ lưu danh sách ID (List<Long>), không lưu Entity. Bật Query Cache mà entity không nằm trong L2 Cache thì Hibernate lấy danh sách ID từ Query Cache rồi bắn N câu SELECT để load từng Entity → thành N+1 Query. Hibernate 6.0 đổi sang lưu đầy đủ dữ liệu entity trong Query Cache, và từ 6.5 có thêm hibernate.cache.query_cache_layout (mặc định AUTO: entity có L2 Cache thì chỉ lưu ID, không có thì lưu đầy đủ), nên cái bẫy này chủ yếu còn ở các bản cũ hoặc khi bạn tự chỉnh về SHALLOW. Cái giá ngược lại là Query Cache tốn RAM hơn. Nguồn: Hibernate 6.5 Migration Guide.


# sre anti-pattern: open session in view (osiv)

Trong Spring Boot, cấu hình mặc định là:

spring:
  jpa:
    open-in-view: true # 🔴 ANTI-PATTERN NGUY HIỂM BẬC NHẤT TRÊN PRODUCTION!

# osiv hoạt động thế nào

Spring giữ Hibernate Session (và cả JDBC Connection) mở suốt từ lúc HTTP Request vào Controller, qua Service, cho tới khi serialize JSON xong và trả về Client.

# hậu quả của osiv

  1. Cạn Connection Pool (HikariCP Exhaustion): client tải chậm hoặc mạng giật thì JDBC Connection bị giữ suốt thời gian đó, các request khác bị timeout (Connection is not available, request timed out).
  2. N+1 Query âm thầm lúc render JSON: lỡ gọi getter của LAZY relation trong DTO serializer thì sinh ra các câu query con nằm ngoài Transaction.

Khuyến nghị: nên tắt OSIV trong file cấu hình Production:

spring:
  jpa:
    open-in-view: false

TIP

Xử lý dữ liệu lớn với StatelessSession: khi cần nạp hoặc cập nhật hàng trăm ngàn bản ghi (Batch Processing), dùng StatelessSession. Nó làm việc trực tiếp với JDBC, không qua First-Level Cache, không snapshot, không cascade, nên khó dính OutOfMemoryError.


# tổng kết

Hibernate rất mạnh, nhưng phải để ý tới tài nguyên khi dùng:

  • Hiểu Dirty Checking: nắm Entity Lifecycle để không viết thừa save(), và biết chính xác lúc nào dữ liệu được flush xuống DB.
  • Dùng @Transactional(readOnly = true): bỏ chi phí snapshot và dirty checking cho các luồng chỉ đọc. Muốn đưa traffic đọc sang replica thì phải tự cấu hình routing, cờ này chỉ là tín hiệu.
  • Tắt spring.jpa.open-in-view: trả database connection ngay khi ra khỏi tầng Service, tránh nghẽn HikariCP Connection Pool khi mạng client chậm.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.