Quan hệ bảng: 1-1, 1-N, N-N và cách map sang JPA

4 câu hỏi~20 phútTừ khóa: foreign key, index trên FK, join table, ON DELETE, owning side, mappedBy, @ManyToOne LAZY

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:

2. Vấn đề

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

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 DBJPAIndex cần
1-1FK + UNIQUE / shared PK@OneToOne (+ @MapsId)UNIQUE đã là index
1-NFK ở bảng "nhiều"@ManyToOne + @OneToMany(mappedBy)Index trên cột FK
N-N thuầnJoin table, PK kép@ManyToMany + @JoinTableIndex trên cột thứ hai của PK
N-N có dữ liệuBảng trung gian thậtEntity riêng + hai @ManyToOneNhư trên

7. Khi nào nên dùng / không nên dùng

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.

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


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.