INNER JOIN — set intersection + nested loop teaser
JOIN cơ bản nhất: keep row match cả 2 bảng. Set theory view, explicit JOIN ON vs implicit WHERE legacy, multi-table JOIN, Cartesian pitfall.
Dashboard TaskFlow ở M02.7 để trống project_name vì tasks chỉ lưu project_id. Muốn hiển thị "tên user + title task họ được assign", bạn cần dữ liệu từ hai bảng cùng lúc — không thể SELECT từ một bảng duy nhất.
JOIN là cơ chế SQL kết hợp nhiều bảng theo một predicate. INNER JOIN — dạng JOIN phổ biến nhất — chỉ giữ row match ở cả hai bảng theo điều kiện join. Bài này map cú pháp, cơ chế, và một anti-pattern thực chiến hay gặp nhất.
1. Analogy — Ghép 2 cọc card
Hình dung bạn có hai cọc card:
- Cọc users: mỗi card ghi
user_idvàname(Alice, Bob, Carol, Dave, Eve) - Cọc tasks: mỗi card ghi
task_id,title, vàassignee_id(khóa ngoại trỏ vềuser_id)
INNER JOIN là thao tác ghép từng card trong cọc users với từng card trong cọc tasks khi user_id = assignee_id. Card không có cặp khớp bị bỏ qua — user chưa có task nào, và task chưa có assignee, đều bị loại ra khỏi kết quả.
| Cọc card | Bảng SQL |
|---|---|
| Card trong cọc users | Row trong bảng users |
| Card trong cọc tasks | Row trong bảng tasks |
Ghép theo user_id = assignee_id | JOIN predicate trong ON clause |
| Card không có cặp khớp bị bỏ | INNER JOIN exclude row không match |
| Chỉ card ghép được đi vào kết quả | Result set của INNER JOIN |
INNER JOIN = chỉ giữ row match cả 2 bảng. Muốn giữ thêm row không match (user chưa có task, task chưa có assignee), đó là LEFT/RIGHT JOIN — Module 3 bài tiếp theo của khoá này.
2. Set theory view — INNER JOIN = intersection
Xét hai tập: tập các user_id trong bảng users, và tập các assignee_id trong bảng tasks. INNER JOIN trả về row tương ứng với phần giao (intersection) của hai tập đó.
users tasks
┌──────────┐ ┌──────────┐
│ user chưa│ │ │
│ assign │ ┌──────┼──────┐ │
│ task nào │ │ INNER│JOIN │ │
│ │ │ (match│ both)│ │
│ │ └──────┼──────┘ │
└──────────┘ │ │ task chưa│
│ │ có │
│ │ assignee │
│ └──────────┘
└── result set
Trên TaskFlow:
- User không có task nào (chưa được assign bất kỳ task nào): bị loại.
- Task có
assignee_id IS NULL(task chưa assign): bị loại. - Chỉ cặp
(user, task)match theo predicate mới xuất hiện trong kết quả.
Đây là behavior đúng khi business query là "danh sách task kèm thông tin assignee" — task chưa có người nhận không cần xuất hiện. Khi bạn cần cả task chưa có assignee, chuyển sang LEFT JOIN (Module 3 bài 02 của khoá này).
3. Cú pháp — explicit JOIN ON vs implicit WHERE
-- EXPLICIT (recommended - SQL-92 standard)
SELECT u.name, t.title
FROM users u
INNER JOIN tasks t ON u.id = t.assignee_id;
-- Hoac: JOIN tasks t ON ... (INNER la default, co the bo)
-- IMPLICIT (legacy SQL-89, KHONG dung trong code moi)
SELECT u.name, t.title
FROM users u, tasks t
WHERE u.id = t.assignee_id;
Bốn lý do explicit JOIN ON win:
- Readability — điều kiện join tách biệt khỏi điều kiện filter trong WHERE, người đọc phân biệt được "ghép bảng thế nào" vs "lọc row nào".
- Tránh Cartesian accident — nếu bạn quên viết
WHERE u.id = t.assignee_idở dạng implicit, query vẫn chạy và trả về Cartesian product (xem mục 7). - OUTER JOIN bắt buộc explicit —
LEFT JOIN,RIGHT JOIN,FULL JOINkhông có dạng implicit; viết nhất quán explicit từ đầu tránh phải đổi style sau. - Maintenance — thêm filter vào WHERE clause không ảnh hưởng nhầm join condition.
Planner xử lý hai dạng identically — không có performance khác biệt. Đây thuần túy là readability và safety.
4. Nested loop teaser — cơ chế executor
PostgreSQL có 3 thuật toán JOIN. Module 7 của khoá này sẽ đi sâu; đây là teaser để bạn biết query của mình đang chạy gì bên dưới:
- Nested Loop: duyệt từng row của bảng A (outer), với mỗi row tìm row match trong bảng B (inner). Hiệu quả khi bảng outer nhỏ và có index trên join column của bảng inner.
- Hash Join: build hash table từ bảng nhỏ hơn, probe bằng bảng lớn hơn. Hiệu quả khi cả hai bảng lớn và không có index phù hợp.
- Merge Join: sort cả hai bảng theo join key rồi merge tuần tự. Hiệu quả khi dữ liệu đã có sẵn thứ tự (index scan).
Xem planner chọn thuật toán nào với EXPLAIN:
EXPLAIN SELECT u.name, t.title
FROM users u
JOIN tasks t ON u.id = t.assignee_id;
-- Plan: Hash Join (Hash u, scan t, probe)
-- HOAC: Nested Loop with Index Scan on tasks.assignee_id
Khi tasks.assignee_id có index, planner thường chọn Nested Loop. Khi bảng lớn và không có index, planner chuyển sang Hash Join. Module 5 của khoá này phân tích composite index cho JOIN; Module 7 đi sâu vào cost model và khi nào force hint.
5. Multi-table INNER JOIN
JOIN nhiều bảng bằng cách chain nhiều INNER JOIN clause:
-- 3-table: tasks + projects + users
SELECT
t.title,
p.name AS project_name,
u.name AS assignee
FROM tasks t
INNER JOIN projects p ON t.project_id = p.id
INNER JOIN users u ON t.assignee_id = u.id
WHERE t.status = 'doing';
Thứ tự viết JOIN trong SQL text không ảnh hưởng kết quả — planner reorder dựa trên cost ước tính (statistics + index). Convention để code dễ đọc:
- Bắt đầu từ bảng "trung tâm" của query — trên TaskFlow thường là
tasksvì đó là entity chính. - JOIN theo quan hệ phóng ra:
tasks→projects,tasks→users.
6. Alias bắt buộc cho multi-table
Khi nhiều bảng có column trùng tên (vd id, name, created_at), PostgreSQL ném ERROR: column reference "..." is ambiguous:
-- ERROR: id va name ambiguous
SELECT id, name
FROM users
JOIN projects ON users.id = projects.owner_id;
-- ERROR: column reference "id" is ambiguous
Fix bằng alias ngắn và qualify tất cả column:
-- FIX: alias + qualify every column
SELECT
u.id AS user_id,
u.name AS user_name,
p.name AS project_name
FROM users u
JOIN projects p ON u.id = p.owner_id;
Convention alias trên TaskFlow: u (users), t (tasks), p (projects), c (comments), pm (project_members). Ngắn, nhất quán, tránh đặt alias trùng nhau trong cùng query.
7. Pitfall — Cartesian product
Ba dạng Cartesian product hay gặp, theo thứ tự nguy hiểm tăng dần:
-- BUG 1: implicit JOIN quen WHERE condition -> Cartesian product
SELECT u.name, t.title
FROM users u, tasks t;
-- 10k user x 100k task = 1 ti row -> OOM hoac hang gio chay
-- BUG 2: ON clause luon true -> Cartesian product nguy trang
SELECT u.name, t.title
FROM users u
JOIN tasks t ON 1 = 1;
-- Tuong duong Cartesian, query syntax hop le, error khong bao
-- BUG 3: typo column -> sai semantic, khong co error
SELECT u.name, t.title
FROM users u
JOIN tasks t ON u.id = t.id;
-- t.id la task ID, khong phai assignee_id
-- Ghep nhung cap (user_1, task_1), (user_2, task_2)... - data sai logic
-- Query chay binh thuong, result co ve hop ly, rat kho phat hien
BUG 3 là nguy hiểm nhất vì không gây error và result trông hợp lý. u.id = t.id match user 1 với task 1, user 2 với task 2 — trùng hợp có thể cho kết quả nhỏ và "đúng vẻ". Chỉ phát hiện khi so sánh output với expectation hoặc khi dataset đủ lớn để thấy anomaly.
Cách phòng ngừa: luôn verify join column bằng cách đọc schema trước khi viết ON clause. Trên TaskFlow, tasks.assignee_id là FK trỏ về users.id — không phải tasks.id.
8. Applied — TaskFlow scenarios
-- "Liet ke task + ai assign + project nao"
SELECT
t.id,
t.title,
t.status,
u.name AS assignee,
p.name AS project
FROM tasks t
INNER JOIN users u ON t.assignee_id = u.id
INNER JOIN projects p ON t.project_id = p.id
WHERE t.status IN ('todo', 'doing')
ORDER BY t.due_at NULLS LAST
LIMIT 50;
-- "Comment thread cua task X kem ten author"
SELECT
c.body,
c.created_at,
u.name AS author
FROM comments c
INNER JOIN users u ON c.user_id = u.id
WHERE c.task_id = $1
ORDER BY c.created_at;
Cross-link: M02.7 mini-challenge dashboard có placeholder project_name vì chưa học JOIN. Bây giờ bạn có thể fix — nhưng lưu ý rủi ro: nếu task có project_id IS NULL (task không thuộc project nào), INNER JOIN sẽ loại task đó ra khỏi kết quả. Khi spec yêu cầu giữ cả task không thuộc project, cần LEFT JOIN thay vì INNER JOIN — bài tiếp theo của khoá này.
9. Deep Dive — JOIN fundamentals
- PostgreSQL Documentation 7.2.1.1 "Joined Tables" — cú pháp chính thức cho tất cả dạng JOIN trong PostgreSQL: CROSS, INNER, LEFT/RIGHT/FULL OUTER, NATURAL, JOIN USING. Đọc phần INNER trước, sau đó OUTER khi học bài tiếp theo.
- Database Management Systems — Ramakrishnan & Gehrke, 3rd ed, Ch.14 "Evaluation of Relational Operators" — lý thuyết formal cho Nested Loop, Hash Join, Merge Join: cost model, khi nào planner chọn thuật toán nào. Đọc khi học Module 7 của khoá này.
Ghi chú: PG docs cho cú pháp và behavior edge case (NATURAL JOIN pitfall, JOIN USING vs ON). Ramakrishnan Ch.14 cho nền tảng lý thuyết về join algorithm — không cần đọc ngay, bookmark để dùng khi đến Module 7.
Liên kết khoá học khác
- Khoá Spring — bài 4.4 Relationships —
@OneToMany@ManyToOne@JoinColumntrong JPA dịch sang SQL JOIN thế nào, vấn đề N+1 query.
10. Tóm tắt
- INNER JOIN = set intersection trên join predicate — chỉ giữ row match cả 2 bảng.
- Explicit
JOIN ONwin implicitWHERE: readability, tránh Cartesian accident, OUTER JOIN bắt buộc explicit, maintenance an toàn hơn. - Multi-table JOIN: planner reorder dựa cost, convention viết
FROMbảng trung tâm rồi JOIN phóng ra. - Alias bắt buộc cho multi-table — qualify mọi column để tránh
ambiguouserror. - 3 dạng Cartesian bug: implicit quên condition,
ON 1=1, typo column — loại 3 nguy hiểm nhất vì silent. - INNER JOIN loại row không match — task có
project_id IS NULLsẽ bị loại; dùng LEFT JOIN nếu muốn giữ. - Forward: Module 5 của khoá này (composite index cho JOIN), Module 7 của khoá này (join algorithm deep dive).
11. Tự kiểm tra
Q1Vì sao INNER JOIN bỏ qua user chưa assign task nào? Giải thích theo set theory view.▸
INNER JOIN trả về intersection của hai tập: tập user_id trong users và tập assignee_id trong tasks. User chưa assign task nào có user_id không xuất hiện trong cột assignee_id của bảng tasks — nghĩa là user đó không nằm trong phần giao của hai tập.
Vì không có row nào trong tasks match predicate u.id = t.assignee_id cho user đó, không có cặp (user, task) nào được tạo ra — user bị loại khỏi result set hoàn toàn. Đây là behavior đúng khi query mục đích là "task kèm assignee"; sai khi query cần "tất cả user kể cả chưa có task" — lúc đó dùng LEFT JOIN.
Q2Phân biệt explicit `JOIN ON` vs implicit `WHERE` (SQL-89). Ba lý do explicit win — không tính performance vì planner xử lý giống nhau.▸
- Readability: điều kiện join nằm trong
ONclause, điều kiện filter nằm trongWHERE— người đọc phân biệt ngay "ghép bảng thế nào" vs "lọc row nào". Implicit trộn lẫn cả hai vàoWHERE, khó đọc khi nhiều bảng. - Tránh Cartesian accident: dạng implicit
FROM a, bmà quênWHERE a.id = b.fkvẫn là syntax hợp lệ — PostgreSQL chạy Cartesian product mà không cảnh báo. Explicit bắt buộc viếtONclause, không thể "quên". - Consistency với OUTER JOIN:
LEFT JOIN,RIGHT JOIN,FULL JOINkhông tồn tại dạng implicit — bắt buộc explicit. Viết nhất quán explicit từ đầu tránh phải đổi style khi cần OUTER JOIN.
Q3Bạn viết JOIN tasks t ON u.id = t.id — query không error nhưng data sai. Vì sao? Cách phòng ngừa?▸
JOIN tasks t ON u.id = t.id — query không error nhưng data sai. Vì sao? Cách phòng ngừa?Lỗi sai: t.id là primary key của bảng tasks (task ID), không phải foreign key trỏ về user. Đúng phải là t.assignee_id. Query vẫn chạy vì u.id = t.id là predicate hợp lệ về mặt type — nó match user 1 với task 1, user 2 với task 2, vô tình tạo ra cặp "đúng kiểu" nhưng sai logic nghiệp vụ.
Bug này đặc biệt nguy hiểm khi dataset nhỏ — một số cặp ngẫu nhiên match và result trông hợp lý, không ai phát hiện cho đến khi data lớn hơn hoặc business logic rõ hơn.
Cách phòng ngừa: trước khi viết ON clause, luôn đọc schema để xác định foreign key column. Trên TaskFlow: tasks.assignee_id là FK trỏ về users.id, không phải tasks.id.
Q43-table JOIN, SQL text viết theo thứ tự A→B→C. Planner có bắt buộc join A với B trước không? Khi nào thứ tự text matter?▸
Không — planner tự do reorder join theo cost model. PostgreSQL dùng statistics (pg_statistic) và index information để ước tính cost của từng thứ tự join, rồi chọn thứ tự tối ưu bất kể thứ tự bạn viết trong SQL text. Với 3 bảng, planner xét tối đa 6 hoán vị.
Thứ tự text matter trong hai tình huống:
- Khi planner bị giới hạn: query cực phức tạp với nhiều bảng vượt
join_collapse_limit(mặc định 8) — planner giữ thứ tự bạn viết. Lúc đó convention "bảng nhỏ nhất làm outer" có thể ảnh hưởng plan. - Khi dùng
SET join_collapse_limit = 1hoặc force hint: ép planner tôn trọng thứ tự SQL text. Đây là tuning nâng cao — Module 7 của khoá này.
Trong thực tế hầu hết query, thứ tự text chỉ ảnh hưởng readability, không ảnh hưởng performance.
Q5M02.7 dashboard có placeholder `project_id` thay vì `project_name`. Bây giờ fix bằng INNER JOIN với bảng `projects`. Rủi ro nào cần cân nhắc? (gợi ý: task không có project_id)▸
Rủi ro: nếu có task với project_id IS NULL (task không thuộc project nào, hoặc project đã bị xóa nhưng FK không cascade), INNER JOIN sẽ loại task đó ra khỏi kết quả. Dashboard user sẽ thấy ít task hơn thực tế — silent data loss về mặt UX.
Cách handle tùy spec:
- Spec: chỉ hiển thị task thuộc project — INNER JOIN đúng, task không có project bị ẩn là intentional.
- Spec: hiển thị mọi task, project_name NULL nếu không có — dùng LEFT JOIN và
COALESCE(p.name, '(No project)').
SELECT t.id, t.title, t.status,
COALESCE(p.name, '(No project)') AS project_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
WHERE t.assignee_id = $1 ...LEFT JOIN + COALESCE là pattern phổ biến hơn cho dashboard — tránh "mất" row khi FK nullable.
Bài tiếp theo: LEFT/RIGHT/FULL OUTER JOIN — NULL trap với WHERE-after-JOIN
Bài này có giúp bạn hiểu bản chất không?