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

Explore
MikeDaDev's Blog
in jpa

@OneToOne trong JPA

Tung Bui Thanh (TungDaDev)11 mins read

One to one relationship.webp

Trong các kiểu quan hệ ORM của JPA, @OneToOne là đứa có nhiều bẫy hiệu năng nhất. Nhiều bạn khai báo fetch = FetchType.LAZY cẩn thận, tới lúc mở SQL Log trên Production mới tá hoả: Hibernate vẫn bắn cả ngàn câu SELECT lẻ cho từng bản ghi (N+1 Query Problem). Sao Hibernate lại lờ đi chữ LAZY của mình? Và xử lý dứt điểm ở mức thiết kế database thế nào?

Khi thiết kế database cho mấy thực thể dính chặt với nhau, kiểu User và UserProfile, Order và Invoice, Employee và Contract, thì quan hệ 1-1 là lựa chọn tự nhiên.

Có điều map sai bằng JPA là dính ngay N+1 queries mà không hề hay biết, thêm lỗi StackOverflowError vì Lombok gọi đệ quy hai chiều, chưa kể tốn bộ nhớ vô ích.

Bài này mổ xẻ cách Hibernate Proxy hoạt động và đưa ra 3 cách thiết kế ổn cho quan hệ 1-1: dùng chung khoá chính, bytecode enhancement, và quan hệ một chiều (xem bảng so sánh cuối bài).


# vì sao FetchType.LAZY không ăn ở non-owning side?

Xét mô hình quan hệ hai chiều (Bidirectional @OneToOne) quen thuộc:

Giờ chạy query lấy danh sách 100 User:

List<User> users = userRepository.findAll();

Dù đã cấu hình fetch = FetchType.LAZY, Hibernate vẫn bắn thêm ngay 100 câu SELECT vào bảng user_profiles!

# sao Hibernate buộc phải query ngay?

  1. Trong Java, một biến tham chiếu chỉ có 2 khả năng: là null, hoặc trỏ tới một object nào đó (ở đây là Hibernate Dynamic Proxy).
  2. Khi load bản ghi từ bảng users, vì bảng này không có cột user_id hay profile_id, Hibernate không thể biết user đó trong database đã có Profile hay chưa.
  3. Để biết nên gán user.setProfile(null) hay user.setProfile(proxyInstance), Hibernate buộc phải query ngay sang bảng user_profiles xem có dòng dữ liệu nào không.
  4. → Kết quả là FetchType.LAZY mất tác dụng hoàn toàn ở Non-Owning side!

Phía owning (UserProfile.user) thì không gặp chuyện này: bảng user_profiles có sẵn cột user_id, cột đó null thì gán null, có giá trị thì gán proxy, không cần hỏi thêm database.

Nếu chưa sửa được thiết kế ngay thì vẫn có hai cách giảm đau. Một là luôn JOIN FETCH profile khi load danh sách (select u from User u left join fetch u.profile), biến 101 câu thành 1. Hai là bật hibernate.default_batch_fetch_size (vd 50) để Hibernate gom các câu lẻ thành WHERE user_id IN (...). Cả hai chỉ giảm số query, profile vẫn bị load dù bạn không cần.


# cách 1: dùng chung khoá chính (@MapsId - shared primary key)

Thay vì tạo thêm một cột khoá ngoại riêng (user_id) kèm một unique index, cách làm chuẩn của dân thiết kế database là: lấy luôn Primary Key của bảng cha làm Primary Key kiêm Foreign Key cho bảng con.

# được lợi gì

  1. Ràng buộc 1-1 được đảm bảo ngay ở tầng Database Constraint: không bao giờ có chuyện 2 profile cùng trỏ vào 1 user.
  2. Đỡ tốn đĩa và RAM: không phải tạo thêm B-Tree Index riêng cho cột Foreign Key.
  3. Không cần quan hệ ngược nữa: profile có cùng id với user, nên muốn lấy profile của một user chỉ cần profileRepository.findById(user.getId()). Bỏ được field profile trong User là bỏ luôn gốc rễ của N+1 ở trên.

# code mẫu với Spring data JPA

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    private String username;
    // Bỏ hẳn quan hệ 2 chiều mappedBy nếu không cần thiết!
}
 
@Entity
@Table(name = "user_profiles")
public class UserProfile {
    @Id
    private Long id; // Không dùng @GeneratedValue! Id này sẽ lấy từ User
 
    private String avatarUrl;
    private String bio;
 
    @OneToOne(fetch = FetchType.LAZY)
    @MapsId // Lấy luôn Primary Key của User làm Primary Key của chính mình!
    @JoinColumn(name = "id")
    private User user;
 
    // Constructors, Getters & Setters
}

Khi lưu dữ liệu:

User user = new User("tungdadev");
userRepository.save(user);
 
UserProfile profile = new UserProfile();
profile.setUser(user); // @MapsId sẽ tự động copy user.getId() vào profile.getId()
profileRepository.save(profile);

# cách 2: Bytecode enhancement (lazy loading ở mức field)

Nếu bắt buộc phải giữ quan hệ hai chiều và muốn fetch = FetchType.LAZY chạy thật ở Non-Owning side, bạn phải dùng tới Hibernate Bytecode Enhancement.

# cấu hình trong pom.xml

<plugin>
    <groupId>org.hibernate.orm.tooling</groupId>
    <artifactId>hibernate-enhance-maven-plugin</artifactId>
    <version>6.5.2.Final</version>
    <executions>
        <execution>
            <configuration>
                <enableLazyInitialization>true</enableLazyInitialization>
            </configuration>
            <goals>
                <goal>enhance</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Plugin này sửa file .class ngay lúc build: mọi chỗ đọc/ghi field của entity được thay bằng lời gọi qua interceptor, đọc tới field nào thì Hibernate mới query field đó. Field không cần là proxy nữa nên Hibernate không phải biết trước nó null hay không, và lazy chạy được ở cả phía mappedBy.

Vài lưu ý khi dùng:

  • Ở Hibernate 5 phải gắn thêm @LazyToOne(LazyToOneOption.NO_PROXY) lên field quan hệ thì mới có lazy kiểu này. Từ Hibernate 6.2 annotation này bị deprecated, nhưng user guide 6.6 vẫn ghi là "hiện vẫn cần", nên cách chắc nhất là bật SQL log và kiểm tra thật.
  • Mặc định mọi thuộc tính lazy nằm chung một fetch group: chạm vào một field là Hibernate load luôn các field lazy còn lại. Muốn tách thì dùng @LazyGroup.
  • Trong Hibernate 6.x, tuỳ chọn enableLazyInitialization đã bị đánh dấu deprecated for removal (tính năng sẽ luôn bật), nên khi nâng version có thể bỏ dòng cấu hình này.
  • Code chạy test bằng class chưa enhance (vd chạy trong IDE mà bỏ qua bước Maven) sẽ cư xử khác production. Nhớ cho bước enhance chạy cả ở build test.

# anti-pattern: gắn Lombok @Data lên JPA entity

Trong mấy ví dụ cơ bản, nhiều bạn gắn @Data của Lombok lên cả hai Entity có quan hệ @OneToOne:

// ĐỪNG LÀM THẾ NÀY: @Data trên entity hai chiều gây vòng lặp vô hạn
@Entity
@Data // Tự động sinh toString, equals, hashCode bao gồm TẤT CẢ các fields!
public class User {
    @OneToOne(mappedBy = "user")
    private UserProfile profile;
}
 
@Entity
@Data
public class UserProfile {
    @OneToOne
    private User user;
}

# sao @Data là đại kỵ trên JPA Entity?

  1. StackOverflowError trong toString(): hai entity hai chiều gọi chéo nhau cho tới khi cạn Thread Stack. hashCode() sinh bởi @Data cũng đi theo quan hệ nên đệ quy y hệt.
  2. Làm hỏng Set và HashSet: @EqualsAndHashCode của @Data dùng tất cả các field (kể cả generated ID lúc đầu đang là null). Khi entity được persist và có ID mới, hashCode đổi theo, thế là entity "lạc mất" trong HashSet!
  3. Vô tình kích hoạt lazy loading: toString(), equals() chạm vào field quan hệ là bắn query, hoặc ném LazyInitializationException nếu session đã đóng (ví dụ lúc log ở tầng controller).

# nên làm thế nào

  • Chỉ dùng @Getter và @Setter.
  • Override equals() và hashCode() chỉ dựa trên một Business Key duy nhất, hoặc chỉ dựa trên id (khi không null). Với id sinh tự động thì hashCode() phải trả về hằng số, vì id đổi từ null sang có giá trị sau khi persist. Đừng dùng getClass().hashCode(): proxy của Hibernate là một subclass sinh ra lúc chạy, getClass() của nó khác User.class, thế là user.equals(proxy) ra true mà hai hashCode lại khác nhau.
@Entity
@Table(name = "users")
@Getter
@Setter
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User other)) return false;
        return id != null && id.equals(other.getId());
    }
 
    @Override
    public int hashCode() {
        return User.class.hashCode(); // Hằng số: không đổi trước/sau persist, proxy cũng ra cùng giá trị
    }
}

# được và mất: dự án nên chọn cách nào?

Cách thiết kếƯu điểmNhược điểmNên dùng khi
Bidirectional @OneToOne cơ bảnDễ hiểu, lúc đầu viết nhanhDính N+1 queries ở Non-Owning side, dễ đệ quy toString❌ Tránh, trừ khi lúc nào cũng JOIN FETCH
Shared Primary Key (@MapsId)Đỡ tốn index, ràng buộc 1-1 chặt, phía con lazy load ngon lànhPhải tạo dữ liệu đúng thứ tự (cha trước, con sau)🟢 Lựa chọn tốt nhất cho cặp cha-con 1-1
Unidirectional @ManyToOne với Unique ConstraintBản chất vẫn là 1-1, Lazy Loading lúc nào cũng chạy ổnKhông đi ngược từ bảng cha sang bảng con được🟢 Gọn gàng nhất, hợp với đa số trường hợp
Bytecode EnhancementGiữ được quan hệ 2 chiều mà vẫn Lazy Loading đượcBuild pipeline (Maven/Gradle) phức tạp hơnHệ thống lớn, domain phức tạp

Cách thứ ba trong bảng chưa nói ở trên: chỉ giữ quan hệ ở phía có khoá ngoại (UserProfile.user), không có field ngược trong User, và đặt unique constraint cho cột user_id. Viết bằng @ManyToOne hay @OneToOne một chiều đều được, vì phía owning luôn lazy được bằng proxy. Hibernate 6 tự sinh unique constraint cho cột join của @OneToOne khi tạo schema, còn với @ManyToOne thì bạn phải tự khai trong migration. Cần đi từ user sang profile thì viết một query findByUserId trong repository.


# checklist trước khi lên production (architect's checklist)

  • 1. Soi SQL log khi load danh sách: chạy thử findAll() trên Entity cha rồi xem log SQL, phải chỉ có đúng 1 câu query, không có câu query phụ nào sang bảng con.
  • 2. Không dùng @Data của Lombok: rà lại và thay hết @Data trên @Entity bằng @Getter, @Setter.
  • 3. Không để @ToString chạm vào quan hệ: đã dùng @ToString thì luôn gắn @ToString.Exclude lên mọi field quan hệ, để chặn cả StackOverflowError lẫn query ngầm khi log.
  • 4. Ưu tiên @MapsId: dùng Shared Primary Key cho những thực thể sống chết theo thực thể cha.
  • 5. Báo cáo thì dùng DTO Projection: khi hiển thị bảng dữ liệu ghép từ 2 bảng, dùng Constructor Expression hoặc Interface Projection của Spring Data JPA, đừng load cả 2 Entity graph vào bộ nhớ.

# lời kết

@OneToOne trong JPA nhìn qua tưởng là quan hệ đơn giản nhất, nhưng bên dưới là cả một mớ chuyện về Proxy, vòng đời Persistence Context và cách thiết kế khoá ngoại trong RDBMS.

Việc nên làm ngay: bật SQL log (hoặc dùng thư viện đếm query như datasource-proxy trong test), load thử danh sách entity cha và đếm số câu SQL. Muốn đọc sâu hơn thì các bài của Vlad Mihalcea về @OneToOne và @MapsId, cùng mục "Bytecode Enhancement" trong Hibernate User Guide, là nguồn đáng tin nhất.


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 😎 👍🏻 🚀 🔥.