Chương 04 · Schema Design

Normalization & Schema Design

1NF → BCNF với ví dụ thực tế, các anti-pattern thường gặp, ERD, và walkthrough thiết kế schema cho 1 mạng xã hội.

1. Vì sao normalize?

Hãy xem xét một bảng không normalize:

orders (chưa normalize):
┌────┬─────────┬──────────────┬────────────┬─────────────────────┐
│ id │ user_id │ user_name    │ user_email │ products            │
├────┼─────────┼──────────────┼────────────┼─────────────────────┤
│  1 │     42  │ Alice        │ a@x.com    │ "iPhone, Charger"   │
│  2 │     42  │ Alice        │ a@x.com    │ "Cable"             │
│  3 │     43  │ Bob          │ b@x.com    │ "iPhone"            │
│  4 │     42  │ Alice Smith  │ a@x.com    │ "Adapter"           │
└────┴─────────┴──────────────┴────────────┴─────────────────────┘

Hai vấn đề lớn:

  1. Trùng lặp dữ liệu — Alice xuất hiện 3 lần với 3 thông tin user. Khi cô ấy đổi email → phải update 3 dòng. Quên 1 dòng → dữ liệu không nhất quán.
  2. Cột "products" chứa nhiều giá trị — không thể query "đếm số lần bán iPhone" mà không parse string.

Normalization là quá trình tách bảng thành các bảng nhỏ hơn để: (1) loại bỏ trùng lặp, (2) đảm bảo mỗi cột phụ thuộc vào toàn bộ PK, (3) tránh các anomaly khi insert/update/delete.

2. Ba loại Anomaly

2.1. Update anomaly

Khi cùng một dữ liệu lặp ở nhiều dòng, update phải đụng nhiều nơi. Quên 1 nơi = bug nhất quán.

Trong ví dụ trên: Alice đổi tên thành "Alice Smith" ở order #4, nhưng order #1, #2 vẫn ghi "Alice". → Hệ thống có 2 sự thật.

2.2. Insert anomaly

Không thể thêm dữ liệu mà không có dữ liệu khác đi kèm. Ví dụ: muốn thêm 1 user mới chưa có order — bảng trên không cho phép (vì PK là id của order).

2.3. Delete anomaly

Xóa dữ liệu lại làm mất dữ liệu khác không liên quan. Xóa order cuối cùng của Alice → mất luôn thông tin Alice (email, name).

Normalization ra đời để giải quyết 3 vấn đề này.

3. 1NF — First Normal Form: nguyên tử hóa

Một bảng thuộc 1NF khi:

  • Mỗi cột chứa giá trị nguyên tử (không phải list, không phải nested object).
  • Mỗi dòng có giá trị unique (có PK).
  • Thứ tự dòng và cột không quan trọng.

Bảng vi phạm 1NF:

orders:
| id | products             |
|----|----------------------|
|  1 | ["iPhone","Charger"] |   ← list trong cột!
|  2 | "Cable, Cable"       |   ← string nhiều giá trị

Tuân thủ 1NF — tách bảng:

CREATE TABLE orders (
  id BIGSERIAL PRIMARY KEY,
  user_id BIGINT
);

CREATE TABLE order_items (
  order_id   BIGINT REFERENCES orders(id),
  product_id BIGINT REFERENCES products(id),
  qty        INT,
  PRIMARY KEY (order_id, product_id)
);
JSON/ARRAY trong Postgres — vi phạm 1NF? Về lý thuyết: có. Nhưng thực tế: nếu giá trị JSON là opaque blob mà bạn không cần query (vd: payload webhook), lưu JSONB là OK. Nếu cần index/JOIN/aggregate trên các thuộc tính bên trong JSON → tách thành cột/bảng. Đây là quyết định kỹ thuật, không phải tôn giáo.

4. Functional Dependency (FD)

Định nghĩa: A → B (đọc: "A xác định B") nếu mọi tuple có cùng giá trị A đều có cùng giá trị B.

Ví dụ:

  • user_id → email — biết user_id biết email (mỗi user có 1 email).
  • order_id → user_id — mỗi order có 1 user.
  • {order_id, product_id} → qty — composite determinant.

FD là nền tảng để định nghĩa các normal form. Khi bạn nói "cột X phụ thuộc vào PK", thực ra bạn đang nói "PK → X".

4.1. Loại FD đặc biệt

  • Trivial FD — A → A (luôn đúng, vô nghĩa).
  • Partial FD — chỉ một phần của composite key xác định attribute. Vi phạm 2NF.
  • Transitive FD — A → B → C (vi phạm 3NF).

5. 2NF — Second Normal Form: full FD on PK

Bảng 2NF khi:

  • Đã ở 1NF.
  • Mọi cột không thuộc PK đều phụ thuộc vào TOÀN BỘ PK, không phụ thuộc một phần.

2NF chỉ quan trọng khi PK là composite. Nếu PK là 1 cột (id) thì 2NF tự động thỏa.

Ví dụ vi phạm 2NF:

order_items (PK = order_id + product_id):
| order_id | product_id | qty | product_name | product_price |
|----------|------------|-----|--------------|---------------|
|     1    |    99      |  2  | iPhone       |     999       |
|     2    |    99      |  1  | iPhone       |     999       |
|     3    |    50      |  5  | Cable        |      10       |

product_name, product_price phụ thuộc vào product_id (chỉ 1 phần PK), không phụ thuộc order_id. → vi phạm 2NF.

Tuân thủ 2NF — tách products ra bảng riêng:

CREATE TABLE products (
  id BIGSERIAL PRIMARY KEY,
  name VARCHAR(255),
  price DECIMAL(10,2)
);
CREATE TABLE order_items (
  order_id BIGINT, product_id BIGINT,
  qty INT,
  PRIMARY KEY (order_id, product_id),
  FOREIGN KEY (product_id) REFERENCES products(id)
);

6. 3NF — Third Normal Form: no transitive

Bảng 3NF khi:

  • Đã ở 2NF.
  • Không có transitive dependency: nếu PK → A → B thì B phải phụ thuộc trực tiếp vào PK, không gián tiếp qua A.

Ví dụ vi phạm 3NF:

employees:
| id (PK) | name  | dept_id | dept_name | dept_location |
|---------|-------|---------|-----------|---------------|
|    1    | Anh   |    10   | HR        | HCM           |
|    2    | Bình  |    10   | HR        | HCM           |
|    3    | Cường |    20   | IT        | HN            |

Phụ thuộc: id → dept_id → dept_name, dept_location. dept_name, dept_location phụ thuộc vào id qua trung gian dept_id → vi phạm 3NF.

Tuân thủ 3NF:

CREATE TABLE departments (
  id BIGSERIAL PRIMARY KEY,
  name VARCHAR(100),
  location VARCHAR(100)
);
CREATE TABLE employees (
  id BIGSERIAL PRIMARY KEY,
  name VARCHAR(100),
  dept_id BIGINT REFERENCES departments(id)
);
Mantra Boyce Bill Kent: "Mỗi non-key attribute phải cung cấp một fact về the key, the whole key, and nothing but the key". 1NF — atomicity → 2NF — the whole key → 3NF — nothing but the key. So help me Codd.

7. BCNF — Boyce-Codd Normal Form

BCNF strict hơn 3NF. Yêu cầu: mọi FD nontrivial X → Y, X phải là superkey.

Ví dụ thường gặp khi 3NF nhưng KHÔNG BCNF:

course_teaches (PK = student_id + course):
| student_id | course | teacher |
|------------|--------|---------|
|    1       | DB     | Smith   |
|    2       | DB     | Smith   |
|    3       | OS     | Jones   |

Giả sử ràng buộc: mỗi teacher dạy đúng 1 course → có FD: teacher → course. Nhưng teacher không phải superkey → vi phạm BCNF.

Tuân thủ BCNF:

CREATE TABLE teachers_courses (
  teacher VARCHAR(100) PRIMARY KEY,
  course  VARCHAR(100)
);
CREATE TABLE student_teachers (
  student_id BIGINT,
  teacher VARCHAR(100),
  PRIMARY KEY (student_id, teacher)
);

Trong thực hành: hầu hết DB tốt chỉ cần đến 3NF. BCNF strict, đôi khi đánh đổi tính đơn giản — chỉ cần thiết cho domain phức tạp hoặc khi 3NF rõ ràng còn anomaly.

8. 4NF & 5NF — nói qua

4NF giải quyết Multi-valued Dependency (MVD) — khi 1 thuộc tính có thể có nhiều giá trị độc lập với 1 thuộc tính khác. Ví dụ: 1 sinh viên có nhiều môn học và nhiều câu lạc bộ — không nên gộp 2 list vào 1 bảng.

5NF (Project-join NF) liên quan join dependency — rất hiếm gặp trong thực hành.

99% projects không cần đụng đến 4NF/5NF. Hiểu 1NF–3NF + biết khi nào denormalize là đủ.

9. Khi nào DENORMALIZE?

Normalize đầy đủ → schema đẹp nhưng có thể chậm. Khi đó denormalize có chọn lọc. Quy tắc:

  1. Bắt đầu bằng normalize. Đẹp, dễ bảo trì, ít bug.
  2. Đo lường trước khi optimize. Đừng denormalize vì cảm tính.
  3. Khi read-heavy + JOIN nóng quá nhiều → denormalize cụ thể.
  4. Đảm bảo cách đồng bộ dữ liệu trùng (trigger, app, queue).

9.1. Các pattern denormalize phổ biến

Cached counters

-- Thay vì JOIN + COUNT mỗi lần render:
ALTER TABLE posts ADD COLUMN comment_count INT DEFAULT 0;

-- Update qua trigger hoặc app:
CREATE TRIGGER ... AFTER INSERT ON comments ...
  UPDATE posts SET comment_count = comment_count + 1 WHERE id = NEW.post_id;

Materialized aggregate

CREATE MATERIALIZED VIEW user_stats AS
SELECT user_id, COUNT(*) AS orders, SUM(total) AS spent
FROM orders GROUP BY user_id;

-- Refresh định kỳ (cron) hoặc concurrent:
REFRESH MATERIALIZED VIEW CONCURRENTLY user_stats;

Wide table cho read

-- Thay vì JOIN 5 bảng cho dashboard, copy snapshot:
CREATE TABLE order_summary (
  order_id BIGINT PRIMARY KEY,
  user_email VARCHAR,    -- denormalized
  user_country VARCHAR,  -- denormalized
  product_names TEXT[],
  total DECIMAL,
  created_at TIMESTAMPTZ
);

Tiền-tính sort key

-- Để dùng index cho ORDER BY phức tạp:
ALTER TABLE posts ADD COLUMN hot_score DECIMAL;
-- Update mỗi vote/comment:
UPDATE posts SET hot_score = (votes - 1) / EXTRACT(EPOCH FROM (NOW() - created_at)) ...;
CREATE INDEX idx_hot ON posts(hot_score DESC);
Bẫy denormalize Mỗi lần copy dữ liệu = một nguồn lệch. Phải có cơ chế đồng bộ (trigger / event sourcing / scheduled job). Nếu không có, sau 6 tháng bạn sẽ có "user_email" cũ ở 5 bảng và 1 user thật.

10. ERD — Entity Relationship Diagram

10.1. Các thành phần

  • Entity — đối tượng nghiệp vụ → một bảng (User, Order, Product).
  • Attribute — thuộc tính → cột.
  • Relationship — quan hệ giữa entity (User places Order).
  • Cardinality — số lượng đối ứng (1-1, 1-N, N-N).

10.2. Cardinality

LoạiMô tảVí dụCách triển khai
1-1Mỗi A có đúng 1 B, mỗi B có đúng 1 AUser ↔ UserProfileFK + UNIQUE constraint
1-NMỗi A có nhiều B, mỗi B thuộc về 1 AUser → OrdersFK ở phía N (orders.user_id)
N-NMỗi A có nhiều B và ngược lạiStudents ↔ CoursesBảng nối (junction): enrollments

10.3. Quan hệ tham gia (participation)

  • Total (mandatory) — mọi instance bên A bắt buộc có B → FK NOT NULL.
  • Partial (optional) — không bắt buộc → FK nullable.

10.4. Ví dụ ERD ASCII

┌────────────┐ ┌────────────┐ ┌─────────────┐ │ USERS │ 1 ─── N │ ORDERS │ 1 ─── N │ ORDER_ITEMS │ ├────────────┤ ├────────────┤ ├─────────────┤ │ id (PK) │◀────────│ user_id │◀────────│ order_id │ │ email │ │ id (PK) │ │ product_id │──┐ │ name │ │ total │ │ qty │ │ └────────────┘ │ status │ └─────────────┘ │ └────────────┘ │ ▼ ┌─────────────┐ │ PRODUCTS │ ├─────────────┤ │ id (PK) │ │ name │ │ category_id │──→ categories │ price │ └─────────────┘

11. Naming conventions & Anti-patterns

11.1. Naming

  • snake_case cho table và column. SQL không phân biệt hoa thường mặc định, snake tránh nhầm lẫn.
  • Tên bảng số nhiều: users, orders, order_items. (Hoặc số ít — nhất quán cả project.)
  • Tên cột số ít: email, created_at.
  • FK: user_id, category_id — luôn có suffix _id.
  • Boolean: is_active, has_avatar, can_edit.
  • Timestamp: created_at, updated_at, deleted_at.
  • Index: idx_users_email (idx_table_col).
  • Constraint: users_email_uniq, orders_user_fk.

11.2. Anti-patterns

EAV (Entity-Attribute-Value)

attributes:
| entity_id | key       | value       |
| 1         | "name"    | "iPhone"    |
| 1         | "price"   | "999"       |
| 1         | "color"   | "black"     |

"Linh hoạt vô hạn" nhưng cực chậm, không có type safety, JOIN dày đặc. Chỉ dùng khi schema thực sự dynamic (form builder, custom field). Cân nhắc JSONB thay thế.

Magic strings

status VARCHAR(20)  -- 'paid' | 'pending' | ?? typo 'pendng' không ai catch

Sửa: dùng CHECK (status IN (...)), ENUM, hoặc bảng status riêng có FK.

Chiếc bảng "kitchen sink"

1 bảng 50+ cột vì "tất cả thông tin về user". Khi vài cột chỉ liên quan với nhau (preferences, billing, profile) — tách bảng theo concern.

Polymorphic FK

comments(commentable_id, commentable_type)  -- 'post' | 'video' | 'product'

FK constraint không enforce được, JOIN khó. Cách thay thế: bảng riêng cho từng (post_comments, video_comments) hoặc bảng polymorphic + UNION view.

UUID v4 làm clustered PK

UUID v4 random → INSERT chèn random vào B-tree → fragmentation, page split, ghi chậm. Dùng UUID v7 (time-ordered) hoặc BIGSERIAL nếu OK.

Soft delete bừa bãi

Thêm deleted_at vào mọi bảng → mọi query phải WHERE deleted_at IS NULL. Quên 1 chỗ = bug bảo mật. Cân nhắc archive table riêng.

12. Walkthrough: thiết kế schema cho Twitter mini

Bước 1 — Liệt kê entity nghiệp vụ:

  • User
  • Tweet (post)
  • Follow (quan hệ N-N giữa user)
  • Like
  • Retweet
  • Reply (thread)
  • Hashtag
  • Direct Message

Bước 2 — Top truy vấn cần phục vụ:

  1. Timeline của 1 user (tweets từ những người họ follow, sắp theo created_at).
  2. Trang user X — tweets của X + thông tin profile.
  3. Search hashtag #abc.
  4. Đếm follower / following.
  5. Like + retweet count cho mỗi tweet.

Bước 3 — Schema:

-- Users
CREATE TABLE users (
  id            BIGSERIAL PRIMARY KEY,
  username      VARCHAR(50) NOT NULL UNIQUE,
  email         VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  display_name  VARCHAR(100),
  bio           TEXT,
  avatar_url    VARCHAR(500),
  follower_count  INT NOT NULL DEFAULT 0,  -- denormalized
  following_count INT NOT NULL DEFAULT 0,  -- denormalized
  tweet_count     INT NOT NULL DEFAULT 0,  -- denormalized
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Tweets
CREATE TABLE tweets (
  id           BIGSERIAL PRIMARY KEY,
  author_id    BIGINT NOT NULL REFERENCES users(id),
  content      VARCHAR(280) NOT NULL,
  reply_to_id  BIGINT REFERENCES tweets(id),    -- thread
  retweet_of_id BIGINT REFERENCES tweets(id),   -- retweet
  like_count   INT NOT NULL DEFAULT 0,           -- denormalized
  retweet_count INT NOT NULL DEFAULT 0,          -- denormalized
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tweets_author_created ON tweets(author_id, created_at DESC);

-- Follows (N-N self join)
CREATE TABLE follows (
  follower_id BIGINT NOT NULL REFERENCES users(id),
  followee_id BIGINT NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (follower_id, followee_id),
  CHECK (follower_id != followee_id)
);
CREATE INDEX idx_follows_followee ON follows(followee_id);   -- "ai follow X?"

-- Likes
CREATE TABLE likes (
  user_id BIGINT NOT NULL REFERENCES users(id),
  tweet_id BIGINT NOT NULL REFERENCES tweets(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (user_id, tweet_id)
);

-- Hashtags (N-N)
CREATE TABLE hashtags (
  id BIGSERIAL PRIMARY KEY,
  tag VARCHAR(100) UNIQUE NOT NULL
);
CREATE TABLE tweet_hashtags (
  tweet_id BIGINT NOT NULL REFERENCES tweets(id),
  hashtag_id BIGINT NOT NULL REFERENCES hashtags(id),
  PRIMARY KEY (tweet_id, hashtag_id)
);
CREATE INDEX idx_th_hashtag ON tweet_hashtags(hashtag_id);

-- Direct Messages (1-N giữa users; phòng chat đơn giản hóa)
CREATE TABLE conversations (
  id BIGSERIAL PRIMARY KEY,
  user_a_id BIGINT NOT NULL REFERENCES users(id),
  user_b_id BIGINT NOT NULL REFERENCES users(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CHECK (user_a_id < user_b_id),
  UNIQUE (user_a_id, user_b_id)
);
CREATE TABLE messages (
  id BIGSERIAL PRIMARY KEY,
  conversation_id BIGINT NOT NULL REFERENCES conversations(id),
  sender_id BIGINT NOT NULL REFERENCES users(id),
  body TEXT NOT NULL,
  read_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_messages_conv_created ON messages(conversation_id, created_at DESC);

Phân tích:

  • Counter denormalize (follower_count, like_count) — read-heavy, cập nhật qua trigger hoặc app.
  • Bảng nối follows, likes, tweet_hashtags — composite PK = (a_id, b_id).
  • Index theo top query: timeline → idx tweets(author_id, created_at DESC).
  • CHECK trên follows ngăn user follow chính mình.
  • conversations dùng user_a_id < user_b_id để chuẩn hóa cặp, tránh 2 hàng (A,B) và (B,A) cho cùng 1 cuộc.
Khi schema này hết tải Khi user count vượt 10M+: timeline query "tweets từ người tôi follow" bắt đầu chậm. Twitter giải bằng fan-out on write — khi user post, ghi tweet_id vào "home_timeline" của từng follower (tốn ghi nhưng đọc cực nhanh). Sẽ học pattern này ở Ch10–11.

13. Bài tập

Bài 1. Bảng sau vi phạm normal form nào? Sửa lại đúng 3NF:

course_offering:
| id | course_code | course_name | semester | teacher_id | teacher_name | teacher_dept |
|----|-------------|-------------|----------|------------|--------------|--------------|
|  1 | CS101       | DB Intro    | Fall26   |    7       | Smith        | CS           |
|  2 | CS101       | DB Intro    | Spring27 |    7       | Smith        | CS           |

Bài 2. Thiết kế schema cho 1 hệ E-commerce mini với: user, product, category (cây phân cấp), cart, order, order_item, payment, address. Vẽ ERD và viết CREATE TABLE.

Bài 3. Bạn cần lưu "preferences" của user — tập key-value động (theme, language, notifications…). Cách nào tốt hơn:

  • (a) bảng user_preferences(user_id, key, value) EAV
  • (b) cột preferences JSONB trên users
  • (c) các cột boolean cố định: theme, lang, ...

Đưa ra phân tích trade-off và chọn.

Bài 4. Xác định cardinality giữa các cặp:

  • User ↔ Email (1 user có nhiều email được lưu)
  • Order ↔ Address (mỗi order có 1 shipping + 1 billing)
  • Movie ↔ Actor
  • Book ↔ Author
  • User ↔ Profile

Bài 5. Khi nào denormalize follower_count là hợp lý? Khi nào thì NÊN tính lại từ SELECT COUNT(*) FROM follows?

14. Quiz

Quiz cuối Chương 4

Bảng orders(id PK, user_id, user_email, total) vi phạm:

  • 1NF
  • 2NF
  • 3NF (transitive: id → user_id → user_email)
  • BCNF nhưng tuân thủ 3NF
id → user_id → user_email là transitive dependency. user_email không phụ thuộc trực tiếp vào id mà qua trung gian user_id → vi phạm 3NF. Sửa: bỏ user_email khỏi orders, JOIN với bảng users khi cần.

2NF chỉ liên quan đến:

  • Bảng có index
  • Bảng có FK
  • Bảng có nhiều cột
  • Bảng có composite primary key
2NF nói về partial dependency — chỉ có nghĩa khi PK có nhiều cột. PK 1 cột tự động thỏa 2NF nếu đã 1NF.

Câu mantra Codd về normal forms:

  • Each column must be in alphabetical order
  • Mỗi non-key attribute must depend on the key, the whole key, and nothing but the key
  • The fewer indexes the better
  • Always use natural keys
"the key" = 1NF (atomic, có key), "the whole key" = 2NF (full FD), "nothing but the key" = 3NF (no transitive). Nhớ câu này thay vì học định nghĩa khô.

Khi nào denormalize HỢP LÝ?

  • Khi đã đo lường thấy JOIN/aggregate là bottleneck cho query nóng
  • Mặc định ngay từ đầu để "phòng xa"
  • Khi schema có hơn 5 bảng
  • Khi DBA bảo
Bắt đầu normalize. Đo lường. Chỉ denormalize khi có proof là JOIN/aggregate là bottleneck thực sự cho query thường chạy. Mỗi denormalize đẻ 1 nguồn lệch dữ liệu — không miễn phí.

EAV (Entity-Attribute-Value) là pattern:

  • Khuyến nghị mặc định
  • Tăng performance
  • Anti-pattern trong hầu hết trường hợp — JSONB hoặc bảng cột cố định thường tốt hơn
  • Thay thế cho FK
EAV mất type safety, slow query, JOIN dày. Postgres JSONB hoặc bảng cột rõ ràng thường là lựa chọn tốt hơn. EAV chỉ dùng khi schema thực sự động (form builder).

Quan hệ N-N giữa Students và Courses thường được triển khai bằng:

  • Cột array trong students
  • JSONB trong courses
  • FK 2 chiều
  • Bảng nối enrollments(student_id, course_id) với composite PK
N-N cần bảng trung gian (junction/associative table) với composite PK gồm 2 FK. Có thể thêm cột phụ (enrolled_at, grade) nếu cần.

UUID v4 (random) làm clustered PK trên B-tree:

  • Tốt vì giúp shard
  • Gây fragmentation và page split do random insertion
  • Không có khác biệt với BIGSERIAL
  • Tự động sort theo created_at
B-tree thích insert đầu/cuối (sequential). UUID v4 random → mỗi insert đến page khác → page split, fragmentation, IO không liên tục. Dùng UUID v7 (time-ordered) hoặc BIGSERIAL.

Khi cần query "tất cả con cháu của node X" trong cây phân cấp, lựa chọn tốt là:

  • Recursive CTE; hoặc lưu thêm cột "path"/closure table nếu cần truy vấn rất thường xuyên
  • JOIN parent_id 100 lần
  • Lưu mọi node trong 1 cột JSON
  • Phải dùng Graph DB
Recursive CTE đủ tốt cho đa số. Nếu query rất nhiều và cây sâu/lớn, các pattern khác như "materialized path" (path = '/1/5/12') hoặc "closure table" (bảng (ancestor, descendant, depth)) cho phép query phẳng nhanh hơn.

Hoàn thành Chương 4. Tiếp theo: Chương 5 — Indexes (B-tree, Hash, GIN, Composite) →