Entity Relationships: owning side, mappedBy, cascade, orphanRemoval
Tiếp tục giải thích về quan hệ Entity, Cascade và Orphan Removal đi.
1. Bài toán: Order và OrderItem
Mô hình shop-app:
users
| 1 - N
v
orders
| 1 - N
v
order_items
| N - 1
v
products
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
status VARCHAR(20) NOT NULL
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL
);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
Khóa ngoại order_id nằm ở bảng order_items. Muốn: tạo Order kèm 3 item bằng một lần save(); bỏ item khỏi Order thì item bị xóa.
2. Vấn đề
- Trong Java, quan hệ có thể khai báo ở cả hai phía (
order.itemsvàitem.order). Nhưng DB chỉ có một cộtorder_id. Hibernate phải biết phía nào quyết định cột đó. - Chỉ thêm item vào
order.getItems()mà quênitem.setOrder(order)→order_id= NULL (hoặc lỗi NOT NULL). - Không có cascade → phải tự
save()từng OrderItem. - Cascade sai chỗ (REMOVE từ Product sang Category) → xóa nhầm dữ liệu dùng chung.
3. Giải pháp: owning side + mappedBy + cascade
- Owning side: phía có
@JoinColumn, giữ khóa ngoại. Với 1-N luôn là phía@ManyToOne(OrderItem.order). Hibernate chỉ đọc phía này để ghi cộtorder_id. - Inverse side:
@OneToMany(mappedBy = "order").mappedBy = "order"nghĩa là "quan hệ này được sở hữu bởi fieldordertrongOrderItem". Phía này chỉ để điều hướng, thay đổi nó không tự đổi khóa ngoại. - Cascade: lan truyền thao tác (persist, merge, remove...) từ cha xuống con.
- orphanRemoval: con bị gỡ khỏi collection của cha thì DELETE.
4. Code từng bước
Bước 1: Dependency. Như bài 4.3.1.
Bước 2: Config
spring:
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
logging:
level:
org.hibernate.SQL: debug
Bước 3: Code Java. Entity Order (inverse side + aggregate root):
@Entity
@Table(name = "orders") // "order" là từ khóa SQL
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.NEW;
@OneToMany(mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
protected Order() {}
public Order(User user) { this.user = user; }
// Helper: luôn cập nhật CẢ HAI phía
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
public List<OrderItem> getItems() {
return Collections.unmodifiableList(items); // buộc đi qua helper
}
}
Entity OrderItem (owning side):
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id")
private Product product; // KHÔNG cascade: Product là dữ liệu dùng chung
private Integer quantity;
protected OrderItem() {}
public OrderItem(Product product, int quantity) {
this.product = product;
this.quantity = quantity;
}
void setOrder(Order order) { this.order = order; }
}
Service:
@Transactional
public Long placeOrder(Long userId, Map<Long, Integer> lines) {
User user = userRepository.getReferenceById(userId); // proxy, không SELECT
Order order = new Order(user);
lines.forEach((productId, qty) ->
order.addItem(new OrderItem(productRepository.getReferenceById(productId), qty)));
return orderRepository.save(order).getId(); // cascade PERSIST → INSERT cả items
}
@Transactional
public void removeFirstItem(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
OrderItem item = order.getItems().get(0);
order.removeItem(item); // orphanRemoval → DELETE khi flush
}
Bước 4: Test bằng curl
curl -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"userId":10,"items":[{"productId":1,"quantity":2},{"productId":2,"quantity":3}]}'
# log:
# insert into orders (status,user_id) values (?,?) returning id
# insert into order_items (order_id,product_id,quantity) values (?,?,?)
# insert into order_items (order_id,product_id,quantity) values (?,?,?)
curl -X DELETE http://localhost:8080/orders/1/items/first
# log: delete from order_items where id=?
5. Cách hoạt động
Java (2 phía) DB (1 cột)
+---------------------------+
| Order |
| items (mappedBy="order")| -- inverse: Hibernate BỎ QUA khi ghi FK
+---------------------------+
^ |
| v
+---------------------------+ order_items
| OrderItem | +----------+
| order @JoinColumn | ---------> | order_id | owning: quyết định FK
+---------------------------+ +----------+
save(order)
└─ cascade PERSIST → persist(item1), persist(item2)
└─ FK lấy từ item.order (nhờ addItem đã set)
order.removeItem(item)
└─ item không còn trong items → orphan → DELETE khi flush
6. So sánh
| Cascade | Ý nghĩa |
|---|---|
| PERSIST | persist cha thì persist con |
| MERGE | merge cha thì merge con |
| REMOVE | xóa cha thì xóa con |
| REFRESH / DETACH | refresh / detach lan xuống con |
| ALL | Tất cả ở trên |
| CascadeType.REMOVE | orphanRemoval = true | |
|---|---|---|
| Kích hoạt khi | Xóa Entity cha | Gỡ con khỏi collection (hoặc xóa cha) |
| Ví dụ | orderRepository.delete(order) → xóa items | order.removeItem(item) → xóa item |
| Phù hợp | Con thuộc sở hữu hoàn toàn của cha | Con không thể tồn tại một mình (OrderItem) |
| Quan hệ | Ví dụ shop-app | Ghi chú |
|---|---|---|
| @ManyToOne | OrderItem → Product | Mặc định EAGER, nên đổi LAZY |
| @OneToMany | Order → items | Mặc định LAZY; dùng mappedBy |
| @OneToOne | User → UserProfile | Phía inverse khó lazy; cân nhắc @MapsId |
| @ManyToMany | Product ↔ Category | Dùng Set; bảng trung gian có thêm cột thì tách Entity riêng |
7. Khi nào nên dùng / không nên dùng
- Nên luôn khai báo
@ManyToOne(fetch = LAZY). - Nên dùng
cascade = ALL+orphanRemovalcho quan hệ cha-con thật sự (Order-OrderItem: aggregate). - Không nên cascade sang Entity dùng chung: OrderItem → Product, Product → Category. Xóa một Product không được xóa Category.
- Không nên tạo
@OneToManyhai chiều cho mọi thứ. User → orders có thể hàng chục nghìn dòng; truy vấn bằng repository (findByUserId, có phân trang) tốt hơn collection. - Không nên dùng
@ManyToManykhi bảng nối có thêm dữ liệu (created_at,priority); tách thành Entity riêng với hai@ManyToOne.
Nếu chỉ dùng @OneToMany một chiều, không có ManyToOne ở OrderItem thì sao?
Chạy được, nhưng cần cẩn thận. Có hai kiểu:
// 1. Không @JoinColumn: Hibernate tạo BẢNG NỐI orders_items (order_id, items_id)
@OneToMany
private List<OrderItem> items;
// 2. Có @JoinColumn: dùng cột order_id trong order_items
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "order_id")
private List<OrderItem> items;
Kiểu 1 thừa một bảng. Kiểu 2 hợp lý hơn nhưng Hibernate có thể INSERT item trước với order_id rồi mới UPDATE (tùy phiên bản và nullable), tức là thêm câu SQL. Cách phổ biến và hiệu quả nhất vẫn là @ManyToOne ở con, còn @OneToMany(mappedBy) chỉ thêm khi thật sự cần điều hướng từ cha.
Dùng Lombok @Data cho Entity có vấn đề gì không?
Có, đây là lỗi rất thường gặp với quan hệ hai chiều:
toString()sinh tự động: Order in items, item in order, order in items... →StackOverflowError, và còn kích hoạt lazy loading ngoài ý muốn.equals()/hashCode()dựa trên mọi field: thay đổi sau khi đưa vàoHashSetlàm mất phần tử; so sánh còn load cả collection.
Dùng @Getter/@Setter có chọn lọc. equals/hashCode dựa trên id (với điều kiện id null thì không bằng nhau, hashCode trả hằng số theo class) hoặc business key tự nhiên.
Phỏng vấn hỏi về relationship, mappedBy, cascade thì trả lời sao?
Q1. Owning side là gì? mappedBy dùng để làm gì?
🇻🇳 Tiếng Việt:
Trong quan hệ hai chiều, owning side là phía giữ khóa ngoại, có @JoinColumn; với 1-N là phía @ManyToOne. mappedBy đặt ở phía inverse, chỉ ra field nào ở phía kia sở hữu quan hệ. Hibernate chỉ dùng owning side để ghi khóa ngoại, nên phải luôn set phía đó, tốt nhất qua helper method cập nhật cả hai phía.
🇬🇧 English:
In a bidirectional relationship, the owning side is the one that holds the foreign key and has @JoinColumn; for one-to-many it is the @ManyToOne side. mappedBy is placed on the inverse side and names the field that owns the relationship. Hibernate only uses the owning side to write the foreign key, so we must always set it, ideally through a helper method that updates both sides.
Q2. CascadeType.REMOVE khác orphanRemoval thế nào?
🇻🇳 Tiếng Việt:
REMOVE lan truyền thao tác xóa từ cha xuống con khi xóa cha. orphanRemoval xóa con khi nó bị gỡ khỏi quan hệ với cha, kể cả khi cha vẫn còn. Không nên dùng REMOVE cho dữ liệu dùng chung như Product hay Category.
🇬🇧 English:
CascadeType.REMOVE cascades entity removal from the parent to its children when the parent is deleted. orphanRemoval deletes a child when it is removed from the parent's relationship, even if the parent still exists. REMOVE should not be used for shared data such as Product or Category.
Q3. Vì sao nên để @ManyToOne là LAZY?
🇻🇳 Tiếng Việt:
@ManyToOne và @OneToOne mặc định EAGER, nên mỗi lần load Entity sẽ kéo theo quan hệ, dễ gây N+1 và load dữ liệu thừa. Em để LAZY hết rồi load khi cần bằng JOIN FETCH, EntityGraph hoặc DTO projection.
🇬🇧 English:
@ManyToOne and @OneToOne are EAGER by default, so every load drags the association along, which causes N+1 queries and over-fetching. I make them LAZY and fetch what each use case needs with JOIN FETCH, an EntityGraph or a DTO projection.
Cách nhớ ngắn gọn
- Khóa ngoại ở đâu, owning side ở đó (
@ManyToOne+@JoinColumn). mappedBy= "tôi chỉ là tấm gương, chủ nhân ở bên kia".- Luôn có
addItem/removeItemcập nhật hai phía. - Cascade ALL + orphanRemoval cho cha-con; không cascade sang dữ liệu dùng chung.
- Mọi
@ManyToOne→ LAZY.
Tự kiểm tra
Trong Order-OrderItem, phía nào là owning side?
OrderItem.order (@ManyToOne + @JoinColumn(name = "order_id")).
Chỉ gọi order.getItems().add(item) mà không set item.order thì sao?
Hibernate bỏ qua phía inverse, order_id = NULL hoặc lỗi NOT NULL.
Gỡ item khỏi order.items có orphanRemoval = true, SQL nào chạy?
DELETE FROM order_items WHERE id = ? khi flush.
Vì sao không đặt cascade trên OrderItem.product?
Product là dữ liệu dùng chung; cascade REMOVE/MERGE có thể xóa hoặc ghi đè Product ngoài ý muốn.
Khi nào thay @ManyToMany bằng Entity trung gian?
Khi bảng nối có thêm cột (created_at, priority, status) hoặc cần truy vấn/quản lý riêng.