Spring REST API & Data JPA
27/46
Bài 27 / 46~12 phútJPA FundamentalsLộ trình · chặng 16/21Miễn phí… lượt xem

Entity Lifecycle và equals/hashCode Trong JPA

Khám phá 4 trạng thái vòng đời entity, 7 lifecycle callbacks, cạm bẫy Lombok @Data và kỹ thuật cài đặt equals/hashCode an toàn với Hibernate Proxy.

TL;DR: Entity JPA trải qua 4 trạng thái vòng đời: Transient, Managed, Detached, và Removed. JPA hỗ trợ 7 lifecycle callbacks (@PrePersist, @PostPersist, @PreUpdate...) để can thiệp vào các mốc thay đổi. Cài đặt equals/hashCode trên JPA Entity là bài toán hóc búa vì ID bị null trước khi persist và Hibernate sử dụng CGLIB proxy cho lazy load. Tuyệt đối không dùng Lombok @Data cho Entity.

Bốn trạng thái vòng đời của Entity hoạt động như thế nào?

Mọi đối tượng JPA tại một thời điểm đều nằm trong 1 trong 4 trạng thái được quản lý bởi persistence context:

Trạng tháiĐịnh danh IDĐược theo dõi (Tracking)?Mô tả
TransientnullKhôngĐối tượng mới khởi tạo qua new, chưa lưu xuống DB.
ManagedĐã cóCóNằm trong persistence context, Dirty Checking kích hoạt.
DetachedĐã cóKhôngTransaction đã đóng, thay đổi setter không tự động flush.
RemovedĐã cóCóĐã đánh dấu xóa qua em.remove(), chờ phát lệnh DELETE.

Chỉ khi Entity ở trạng thái Managed, cơ chế Dirty Checking mới theo dõi thay đổi để tự động phát sinh câu lệnh SQL UPDATE.

Bảy Lifecycle Callbacks can thiệp vào vòng đời ra sao?

JPA cung cấp 7 annotation đánh dấu method void nội bộ trong entity:

Java
@Entity
public class Project {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Instant createdAt;
    private Instant updatedAt;

    @PrePersist
    protected void onCreate() {
        // Chay truoc INSERT khi id van con null
        this.createdAt = Instant.now();
    }

    @PreUpdate
    protected void onUpdate() {
        // Chay truoc UPDATE khi Dirty Checking phat hien thay doi
        this.updatedAt = Instant.now();
    }

    @PostPersist
    protected void afterInsert() {
        // Chay sau INSERT, id da duoc DB gan thanh cong
    }

    @PostLoad
    protected void afterLoad() {
        // Chay sau khi Hibernate load entity tu DB len bo nho
    }
}

Trục thời gian một lần save: PrePersist chạy trước khi ghi nhận, Hibernate phát lệnh SQL flush, và PostPersist được gọi sau khi database đã cấp phát ID thành công

Tuyệt đối không gọi External API trong Callback

Các callback như @PrePersist, @PreUpdate chạy trực tiếp bên trong database transaction. Nếu gọi HTTP request hoặc network I/O, transaction sẽ bị treo, giữ kết nối database pool gây nghẽn hệ thống.

Tại sao cài đặt equals/hashCode cho Entity lại phức tạp?

Trong Java thông thường, equals và hashCode được sinh tự động dựa trên tất cả các trường dữ liệu. Tuy nhiên, với JPA Entity, hai trở ngại lớn xuất hiện:

  1. Khóa chính ID bị null trước khi lưu: Khi entity ở trạng thái Transient, id bằng null. Nếu tính hashCode theo ID, giá trị hash ban đầu sẽ là 0.
  2. Hibernate CGLIB Proxy: Đối tượng tải qua lazy load là một proxy con (subclass), không phải class gốc.

Thảm họa HashSet mất dấu Entity

Hãy xem xét kịch bản sau nếu hashCode() tính theo id:

Java
Set<Project> set = new HashSet<>();
Project project = new Project("TaskFlow"); // id = null -> hashCode = 0
set.add(project); // Luu vao Bucket 0

projectRepository.save(project); // DB sinh id = 42 -> hashCode bien thanh 42!

boolean exists = set.contains(project); // HashSet tim tai Bucket 42 -> Tra ve FALSE!

Mặc dù đối tượng vẫn nằm trong HashSet, nhưng set.contains() trả về false vì hash code đã thay đổi giữa chừng.

Sau khi lưu vào database, ID được cấp phát làm giá trị hash thay đổi, khiến HashSet tra cứu nhầm bucket và làm mất dấu object

Triển khai equals và hashCode chuẩn mực như thế nào?

Công thức an toàn và được Hibernate khuyến nghị:

  • Dùng instanceof trong equals: Tương thích với CGLIB Proxy subclass của Hibernate (thay vì so sánh getClass() == o.getClass() vốn sẽ trả về false với proxy).
  • So sánh ID an toàn: Chỉ coi hai entity bằng nhau nếu cả hai đều có ID khác null và bằng nhau.
  • Trả về hằng số cố định trong hashCode(): Đảm bảo bucket trong HashSet không bao giờ bị thay đổi trước và sau khi persist.
Java
@Entity
public class Project {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Project other)) return false; // Tuong thich Hibernate Proxy
        return id != null && id.equals(other.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode(); // Hang so co dinh cho ca class
    }
}

Lưu ý: Trả về getClass().hashCode() khiến mọi instance của Project rơi vào cùng bucket trong Set, lookup chuyển thành O(N). Nhưng trong thực tế, các bộ Set chứa entity thường có kích thước rất nhỏ (vài chục phần tử), trade-off này hoàn toàn đáng giá để bảo toàn tính toàn vẹn dữ liệu.

Vì sao không bao giờ được dùng Lombok @Data trên Entity?

Annotation @Data của Lombok là nguyên nhân hàng đầu gây ra các lỗi runtime bí ẩn trong JPA:

  1. Sinh equals và hashCode trên mọi field: Vi phạm nguyên tắc bất biến của HashSet và gây kích hoạt Lazy Loading ngoài ý muốn khi truy cập field quan hệ.
  2. Sinh toString() bao gồm cả quan hệ hai chiều: Khi Entity A trỏ tới B và B trỏ ngược lại A, gọi toString() sẽ gây ra ngoại lệ StackOverflowError (vòng lặp vô tận).
  3. Mặc định sinh constructor không mong muốn: Thiếu no-arg constructor cần thiết cho Hibernate.
Java
// KHUYEN CAO: Thay @Data bang cac annotation cu the
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Project { ... }

Những cạm bẫy phổ biến cần tránh là gì?

  1. Gọi getClass() == o.getClass() trong equals: Sẽ trả về false khi một bên là Hibernate Lazy Proxy, phá vỡ logic so sánh.
  2. Gọi network I/O trong @PrePersist: Giữ connection pool quá lâu, làm cạn kiệt tài nguyên database.
  3. Dựa vào ID trong hashCode: Khiến đối tượng bị biến mất khỏi Set hoặc Map sau khi gọi repository.save().

Tự kiểm tra kiến thức

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao trong phương thức equals() của JPA Entity nên dùng 'o instanceof MyEntity' thay vì 'getClass() == o.getClass()'?
  2. Q2
    Hiện tượng gì xảy ra nếu phương thức hashCode() của Entity được tính toán dựa trên trường ID tự tăng?
  3. Q3
    Tại sao không nên sử dụng annotation @Data của Lombok trên các JPA Entity?
  4. Q4
    Callback nào trong 7 Lifecycle Callbacks của JPA được kích hoạt ngay trước khi câu lệnh SQL INSERT được flush?
  5. Q5
    Tại sao việc gọi một dịch vụ bên ngoài (External REST API) bên trong @PrePersist hoặc @PreUpdate lại là một thói quen xấu?

Bài tiếp theo: Tổng Kết Module 04: JPA Fundamentals

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

Chặng 16/21 · Spring Data JPA — bài 6/20 của chặng

Ôn phỏng vấn

Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Tổng Kết Module 04: Nền Tảng JPA và Ánh Xạ Thực Thể