@OneToOne trong JPA
Tung Bui Thanh (TungDaDev)11 mins read

Trong các kiểu quan hệ ORM của JPA,
@OneToOnelà đứa có nhiều bẫy hiệu năng nhất. Nhiều bạn khai báofetch = FetchType.LAZYcẩ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?
- 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). - Khi load bản ghi từ bảng
users, vì bảng này không có cộtuser_idhayprofile_id, Hibernate không thể biết user đó trong database đã có Profile hay chưa. - Để biết nên gán
user.setProfile(null)hayuser.setProfile(proxyInstance), Hibernate buộc phải query ngay sang bảnguser_profilesxem có dòng dữ liệu nào không. - → Kết quả là
FetchType.LAZYmấ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ì
- 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.
- Đỡ tốn đĩa và RAM: không phải tạo thêm B-Tree Index riêng cho cột Foreign Key.
- 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 fieldprofiletrongUserlà 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?
StackOverflowErrortrongtoString(): hai entity hai chiều gọi chéo nhau cho tới khi cạn Thread Stack.hashCode()sinh bởi@Datacũng đi theo quan hệ nên đệ quy y hệt.- Làm hỏng
SetvàHashSet:@EqualsAndHashCodecủa@Datadù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" trongHashSet! - 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émLazyInitializationExceptionnếu session đã đóng (ví dụ lúc log ở tầng controller).
# nên làm thế nào
- Chỉ dùng
@Gettervà@Setter. - Override
equals()vàhashCode()chỉ dựa trên một Business Key duy nhất, hoặc chỉ dựa trênid(khi không null). Với id sinh tự động thìhashCode()phải trả về hằng số, vì id đổi từnullsang có giá trị sau khi persist. Đừng dùnggetClass().hashCode(): proxy của Hibernate là một subclass sinh ra lúc chạy,getClass()của nó khácUser.class, thế làuser.equals(proxy)ratruemà haihashCodelạ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ểm | Nhược điểm | Nên dùng khi |
|---|---|---|---|
Bidirectional @OneToOne cơ bản | Dễ hiểu, lúc đầu viết nhanh | Dí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ành | Phả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 Constraint | Bản chất vẫn là 1-1, Lazy Loading lúc nào cũng chạy ổn | Khô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 Enhancement | Giữ được quan hệ 2 chiều mà vẫn Lazy Loading được | Build pipeline (Maven/Gradle) phức tạp hơn | Hệ 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
@Datacủa Lombok: rà lại và thay hết@Datatrên@Entitybằng@Getter,@Setter. - 3. Không để
@ToStringchạm vào quan hệ: đã dùng@ToStringthì luôn gắn@ToString.Excludelên mọi field quan hệ, để chặn cảStackOverflowErrorlẫ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 😎 👍🏻 🚀 🔥.