Quan hệ bảng: 1-1, 1-N, N-N và cách map sang JPA
Tiếp đi: quan hệ giữa các bảng và map sang JPA.
Bài trước ta đã tách shop-app thành users, products, orders, order_items. Bài này nhìn từ góc database: quan hệ được lưu bằng gì, cần index gì, rồi mới map sang JPA. Chi tiết annotation đã có ở bài Entity relationships, ở đây chỉ nối hai phía lại.
1. Bài toán
shop-app cần thêm:
- Mỗi user có đúng một hồ sơ giao hàng mặc định (1-1).
- Mỗi user có nhiều đơn hàng (1-N).
- Mỗi sản phẩm thuộc nhiều danh mục, mỗi danh mục có nhiều sản phẩm (N-N).
2. Vấn đề
- Dev mới thường nghĩ quan hệ nằm ở Java (
List<Order>). Thật ra DB chỉ có một công cụ: cột foreign key. - PostgreSQL không tự tạo index cho cột FK (chỉ tạo cho PK và UNIQUE). Khi có 5 triệu đơn,
WHERE user_id = ?thành Seq Scan, và xóa một user phải quét cả bảngordersđể kiểm tra FK. - Map sai owning side trong JPA → Hibernate sinh UPDATE thừa hoặc không lưu quan hệ.
3. Giải pháp: ba kiểu quan hệ trong bảng
1-1 users (id) ←── user_profiles (user_id PK + FK) FK + UNIQUE
1-N users (id) ←── orders (user_id FK) FK ở phía "nhiều"
N-N products ←── product_categories ──→ categories join table, PK kép
- 1-1: FK ở bảng phụ, thêm
UNIQUE(hoặc dùng luôn FK làm PK: shared primary key). - 1-N: FK luôn nằm ở bảng phía "nhiều".
- N-N: không thể đặt FK ở bảng nào, nên cần join table với hai FK. Nếu quan hệ có thêm dữ liệu (số lượng, giá) thì join table thành entity thật, như
order_itemsgiữaordersvàproducts.
4. Code từng bước
Bước 1: Migration
-- V2__relations.sql
CREATE TABLE user_profiles (
user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
shipping_address text NOT NULL,
phone text
);
CREATE TABLE categories (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE
);
CREATE TABLE product_categories (
product_id bigint NOT NULL REFERENCES products(id) ON DELETE CASCADE,
category_id bigint NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (product_id, category_id)
);
-- Index cho FK: PostgreSQL KHÔNG tự tạo
CREATE INDEX idx_orders_user_id ON orders (user_id);
CREATE INDEX idx_order_items_product_id ON order_items (product_id);
CREATE INDEX idx_product_categories_category ON product_categories (category_id);
-- order_items(order_id) và product_categories(product_id) đã được PK kép bao (cột đầu)
ON DELETE: CASCADE cho dữ liệu "thuộc về" (profile của user); mặc định NO ACTION cho dữ liệu nghiệp vụ (không cho xóa user khi còn đơn hàng).
Bước 2: Entity
@Entity @Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false) // owning side: giữ cột user_id
@JoinColumn(name = "user_id")
private User user;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
public void addItem(OrderItem item) { // giữ hai phía đồng bộ
items.add(item);
item.setOrder(this);
}
}
@Entity @Table(name = "products")
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany
@JoinTable(name = "product_categories",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "category_id"))
private Set<Category> categories = new HashSet<>();
}
OrderItem có thêm quantity, unit_price nên map thành entity riêng với @EmbeddedId hoặc id riêng, không dùng @ManyToMany.
Bước 3: Kiểm tra index FK còn thiếu
SELECT c.conrelid::regclass AS table_name, a.attname AS fk_column
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND i.indkey[0] = c.conkey[1]);
Bước 4: Test
EXPLAIN SELECT id FROM orders WHERE user_id = 42;
-- trước: Seq Scan on orders
-- sau: Index Scan using idx_orders_user_id
curl http://localhost:8080/users/42/orders
5. Cách hoạt động
DELETE FROM users WHERE id = 42;
│
├─ FK orders.user_id (NO ACTION)
│ DB tìm: SELECT 1 FROM orders WHERE user_id = 42
│ không index → quét 5 triệu dòng, giữ lock lâu
│ có index → vài lần đọc page
│ còn đơn → ERROR violates foreign key constraint
│
└─ FK user_profiles.user_id (CASCADE) → xóa profile theo
JPA: Order.user (@ManyToOne) ── owning side ──→ cột orders.user_id
User.orders (mappedBy) ── inverse side ──→ chỉ để đọc, không ghi cột
6. So sánh
| Quan hệ | Trong DB | JPA | Index cần |
|---|---|---|---|
| 1-1 | FK + UNIQUE / shared PK | @OneToOne (+ @MapsId) | UNIQUE đã là index |
| 1-N | FK ở bảng "nhiều" | @ManyToOne + @OneToMany(mappedBy) | Index trên cột FK |
| N-N thuần | Join table, PK kép | @ManyToMany + @JoinTable | Index trên cột thứ hai của PK |
| N-N có dữ liệu | Bảng trung gian thật | Entity riêng + hai @ManyToOne | Như trên |
7. Khi nào nên dùng / không nên dùng
- Luôn tạo index cho cột FK nếu có truy vấn theo nó hoặc có xóa ở bảng cha.
@ManyToOneluôn đểLAZY(mặc định là EAGER).- Không map
@OneToManycho collection có thể rất lớn (user có 100k đơn): query bằng repository có phân trang. CascadeType.REMOVE/ON DELETE CASCADEchỉ cho dữ liệu con thật sự thuộc về cha.
Với quan hệ 1-1, mình đặt cột profile_id ở bảng users cũng được, đúng không?
Được về mặt kỹ thuật, nhưng thường không nên. Quy tắc: FK đặt ở bên phụ thuộc, bên có thể không tồn tại.
- User có thể chưa có profile →
users.profile_idphải nullable, và vẫn phải thêmUNIQUEmới thật sự là 1-1. - Profile không thể tồn tại thiếu user → đặt
user_profiles.user_idlàm PK + FK là tự nhiên nhất. JPA dùng@OneToOne+@MapsId.
Thêm một điểm JPA: phía inverse @OneToOne(mappedBy) thường không lazy được vì Hibernate phải query để biết null hay không. Map một chiều từ profile sang user giúp tránh query thừa này.
Mapping đúng hết rồi, có index FK rồi, nhưng trang danh sách đơn vẫn chậm. Vậy còn gì nữa?
Đúng rồi, index chỉ giải quyết từng query. Vấn đề hay gặp nhất là số lượng query: lấy 20 đơn, mỗi đơn lazy-load user và items → 1 + 20 + 20 query. Đó là N+1, chi tiết và cách sửa (JOIN FETCH, @EntityGraph, batch size, DTO projection) ở bài N+1.
Cách phát hiện nhanh: bật spring.jpa.properties.hibernate.generate_statistics=true hoặc đếm query trong test, nhìn số câu SQL cho một request.
Phỏng vấn hỏi về quan hệ bảng và mapping JPA thì trả lời sao?
Q1. Biểu diễn quan hệ N-N trong database thế nào?
🇻🇳 Tiếng Việt:
Dùng join table chứa hai foreign key tới hai bảng, khóa chính là cặp hai cột đó. Nếu quan hệ có thêm thuộc tính như số lượng hay giá, join table trở thành một entity thật, ví dụ order_items giữa orders và products, và trong JPA em map bằng hai quan hệ ManyToOne thay vì ManyToMany.
🇬🇧 English:
With a join table that holds two foreign keys, and the pair of columns is the primary key. If the relationship has its own attributes, such as quantity or price, the join table becomes a real entity, like order_items between orders and products, and in JPA I map it with two ManyToOne relationships instead of ManyToMany.
Q2. PostgreSQL có tự tạo index cho foreign key không?
🇻🇳 Tiếng Việt:
Không. PostgreSQL chỉ tự tạo index cho primary key và unique. Cột FK không có index thì join và lọc theo FK bị seq scan, và khi xóa hay update khóa ở bảng cha, database phải quét bảng con để kiểm tra ràng buộc. Nên em luôn tạo index cho cột FK.
🇬🇧 English:
No. PostgreSQL only creates indexes for primary keys and unique constraints. Without an index on the FK column, joins and filters on it become sequential scans, and deleting or updating a parent row forces a scan of the child table to check the constraint. So I always index foreign key columns.
Q3. Owning side trong JPA là gì?
🇻🇳 Tiếng Việt:
Owning side là phía giữ cột foreign key, Hibernate chỉ đọc phía này để ghi quan hệ xuống DB. Trong 1-N đó là phía ManyToOne. Phía có mappedBy là inverse side, chỉ để điều hướng. Vì vậy em viết helper method cập nhật cả hai phía để object trong bộ nhớ khớp với DB.
🇬🇧 English:
The owning side is the side that holds the foreign key column, and Hibernate only looks at it when writing the relationship. In a one-to-many it is the ManyToOne side. The side with mappedBy is the inverse side and is only for navigation. That is why I write helper methods that update both sides so in-memory objects match the database.
Cách nhớ ngắn gọn
- DB chỉ có FK: 1-N đặt FK bên "nhiều", N-N cần join table.
- PostgreSQL không tự index FK: tự tạo.
- Owning side = phía có cột FK =
@ManyToOne;mappedBylà phía đọc. - Index giải quyết từng query, N+1 là bài toán số lượng query.
Tự kiểm tra
Trong 1-N, FK nằm ở bảng nào?
Bảng phía "nhiều", ví dụ orders.user_id.
Thiếu index trên orders.user_id ảnh hưởng gì khi xóa user?
DB phải quét toàn bộ orders để kiểm tra FK, chậm và giữ lock lâu.
Khi nào join table thành entity riêng?
Khi quan hệ có thêm dữ liệu riêng như quantity, unit_price.
Fetch mặc định của @ManyToOne là gì, nên đổi thành gì?
EAGER; nên đổi thành LAZY.
ON DELETE CASCADE phù hợp với quan hệ nào?
Dữ liệu con thật sự thuộc về cha, như profile của user, item trong danh mục join table. Không dùng cho dữ liệu nghiệp vụ như đơn hàng.