Bài 25 — JOIN: sáu cách ghép bảng¶
🎯 Học xong bài này, bạn sẽ
- Ghép được dữ liệu từ nhiều bảng bằng cả sáu loại
JOIN, và biết chọn loại nào cho tình huống nào - Nhìn thấy tận mắt sự khác nhau giữa
INNER JOINvàLEFT JOINtrên lớp9A3chưa có chủ nhiệm - Tìm được "dòng mồ côi" bằng khuôn
LEFT JOIN ... WHERE ... IS NULL - Ghép một bảng với chính nó bằng
SELF JOIN - Phân biệt
ONvớiUSING, hiểu vì saoNATURAL JOINnguy hiểm, và tránh được tích Descartes bùng nổ
🧠 Câu chuyện mở đầu¶
Cô hiệu phó đưa bạn hai tờ giấy.
Tờ thứ nhất là danh sách 40 học sinh, mỗi bạn kèm một mã lớp: HS001 — Nguyễn Văn An — L01.
Tờ thứ hai là danh sách 6 lớp, mỗi lớp kèm tên hiển thị và mã giáo viên chủ nhiệm: L01 — 8A1 — GV01.
Cô nói: "Em in cho cô một bảng gồm tên học sinh, tên lớp, và tên cô chủ nhiệm nhé."
Không tờ giấy nào có đủ cả ba thông tin. Bạn phải ghép chúng: lấy mã lớp ở tờ một, tìm nó ở tờ hai để biết tên lớp và mã giáo viên, rồi tìm mã giáo viên đó ở một tờ thứ ba nữa.
Bạn làm xong. Nhưng khi đối chiếu, cô phát hiện bảng của bạn thiếu lớp 9A3. Bạn kiểm lại: lớp 9A3 có thật, có 7 học sinh, chỉ là ô "giáo viên chủ nhiệm" đang để trống — trường chưa phân công ai.
Bạn đã vô tình làm biến mất cả một lớp học chỉ vì một ô trống. Vậy làm sao để ghép mà không đánh rơi ai?
📖 Khái niệm & thuật ngữ¶
Phép kết nối, nhắc lại từ Bài 21¶
Ở Bài 21 bạn đã gặp định nghĩa toán học: R ⋈_θ S = σ_θ(R × S). Kết nối là tích Descartes rồi lọc.
Tất cả sáu loại JOIN của SQL đều dựa trên ý đó, và chúng chỉ khác nhau ở một câu hỏi duy nhất:
"Khi một dòng bên này không tìm được dòng nào khớp bên kia, thì giữ nó lại hay bỏ nó đi?"
| Loại | Giữ dòng không khớp của bảng nào | Số dòng trên ví dụ lop ⋈ giao_vien |
|---|---|---|
INNER JOIN |
Không giữ của bên nào | 5 |
LEFT OUTER JOIN |
Giữ của bảng trái | 6 |
RIGHT OUTER JOIN |
Giữ của bảng phải | 6 |
FULL OUTER JOIN |
Giữ của cả hai | 9 |
CROSS JOIN |
Không có điều kiện — ghép tất cả với tất cả | 48 |
SELF JOIN |
Không phải loại riêng; là bất kỳ loại nào, nhưng ghép bảng với chính nó | — |
- Kết nối trong (INNER JOIN) chỉ giữ những cặp khớp nhau. Đây là loại chiếm phần lớn công việc, và cũng là loại làm biến mất lớp
9A3trong câu chuyện. - Kết nối ngoài bên trái (LEFT OUTER JOIN, viết ngắn là
LEFT JOIN) giữ mọi dòng của bảng trái. Dòng nào không có bạn khớp bên phải thì các cột bên phải được điềnNULL. - Kết nối ngoài bên phải (RIGHT OUTER JOIN) làm điều đối xứng: giữ mọi dòng bảng phải. Thực tế rất ít ai dùng, vì đổi chỗ hai bảng là được
LEFT JOIN— màLEFT JOINthì dễ đọc hơn. - Kết nối ngoài đầy đủ (FULL OUTER JOIN) giữ mọi dòng của cả hai bên. Dùng khi bạn cần đối chiếu hai danh sách và muốn thấy cả phần lệch ở hai phía.
- Kết nối chéo (CROSS JOIN) chính là tích Descartes ×:
mdòng nhânndòng ram × ndòng. Nó có ích khi bạn cần sinh ra mọi tổ hợp — ví dụ lập bảng điểm rỗng cho mọi cặp (học sinh, môn học). - Tự kết nối (SELF JOIN) không phải một từ khoá mới. Nó chỉ là tên gọi cho việc ghép một bảng với chính nó, và nó bắt buộc phải dùng bí danh — tức phép ρ của Bài 21 — để phân biệt hai bản sao.
ON hay USING?¶
Hai cách viết điều kiện ghép:
ON |
USING |
|
|---|---|---|
| Cú pháp | JOIN lop l ON h.ma_lop = l.ma_lop |
JOIN lop USING (ma_lop) |
| Điều kiện | Bất kỳ biểu thức nào, kể cả <, >, nhiều cột, nhiều phép |
Chỉ so sánh bằng, và hai bên phải cùng tên cột |
| Cột trong kết quả | Xuất hiện hai lần — h.ma_lop và l.ma_lop |
Xuất hiện một lần |
USING gọn hơn và tự dọn cột trùng, nhưng nó chỉ dùng được khi hai bảng đặt tên cột giống nhau. ON thì luôn dùng được, nên nó là lựa chọn mặc định.
NATURAL JOIN — tiện mà nguy hiểm¶
NATURAL JOIN tự động ghép theo mọi cặp cột trùng tên giữa hai bảng, không cần bạn viết điều kiện gì.
Nghe thì tuyệt. Nhưng hãy nghĩ tới điều này: nó dựa vào tên cột, mà tên cột có thể bị đổi bởi một câu ALTER TABLE mà bạn không hay biết. Thêm một cột ghi_chu vào cả hai bảng là NATURAL JOIN lặng lẽ ghép thêm theo cột đó, và kết quả đổi hoàn toàn — không có thông báo lỗi nào.
Vì thế quy tắc trong hầu hết đội phát triển là: không dùng NATURAL JOIN trong mã sản xuất. Nó chỉ tiện khi bạn gõ nhanh để xem thử.
Tích Descartes bùng nổ¶
Tích Descartes bùng nổ (Cartesian explosion) là hiện tượng: bạn quên viết điều kiện ghép, và số dòng kết quả nhân lên theo tích chứ không theo tổng.
Ghép 40 học sinh với 6 lớp không có điều kiện cho ra 240 dòng — vô hại. Nhưng ghép 50.000 dòng hoc_sinh_lon với 500.000 dòng diem_lon thì cho ra 25 tỉ dòng: máy chủ ăn hết bộ nhớ, ổ đĩa đầy tệp tạm, và truy vấn không bao giờ kết thúc.
Bẫy chết người là nó không phải lỗi cú pháp. Câu lệnh hợp lệ, nó cứ chạy.
Bảng thuật ngữ¶
| Tiếng Việt | English | Nghĩa dễ hiểu |
|---|---|---|
| Kết nối trong | INNER JOIN | Chỉ giữ những cặp dòng khớp nhau ở cả hai bảng |
| Kết nối ngoài bên trái | LEFT OUTER JOIN | Giữ mọi dòng bảng trái; cột bên phải điền NULL khi không khớp |
| Kết nối ngoài bên phải | RIGHT OUTER JOIN | Đối xứng với LEFT JOIN; ít dùng vì đổi chỗ hai bảng là xong |
| Kết nối ngoài đầy đủ | FULL OUTER JOIN | Giữ mọi dòng của cả hai bảng — dùng để đối chiếu hai danh sách |
| Kết nối chéo | CROSS JOIN | Tích Descartes: ghép mọi dòng bên này với mọi dòng bên kia |
| Tự kết nối | SELF JOIN | Ghép một bảng với chính nó, bắt buộc dùng bí danh |
| Kết nối chống | anti join | Khuôn LEFT JOIN ... WHERE cot_phai IS NULL để tìm những dòng không có bạn khớp |
| Tích Descartes bùng nổ | Cartesian explosion | Quên điều kiện ghép, số dòng nhân lên theo tích — không báo lỗi, chỉ treo máy |
🖼️ Sơ đồ¶
Sáu loại JOIN mô tả bằng tập hợp. Gọi A là bảng trái, B là bảng phải; vùng giữa là các dòng khớp nhau.
flowchart TB
subgraph I["1 · INNER JOIN — chỉ phần chung"]
direction LR
I1["A riêng<br/>❌ bỏ"]
I2["<b>A ∩ B</b>"]
I3["B riêng<br/>❌ bỏ"]
I2 --> IR["<b>KẾT QUẢ</b>"]
end
subgraph L["2 · LEFT OUTER JOIN — cả A"]
direction LR
L1["<b>A riêng</b><br/>cột B = NULL"]
L2["<b>A ∩ B</b>"]
L3["B riêng<br/>❌ bỏ"]
L1 --> LR2["<b>KẾT QUẢ</b>"]
L2 --> LR2
end
subgraph R["3 · RIGHT OUTER JOIN — cả B"]
direction LR
R1["A riêng<br/>❌ bỏ"]
R2["<b>A ∩ B</b>"]
R3["<b>B riêng</b><br/>cột A = NULL"]
R2 --> RR["<b>KẾT QUẢ</b>"]
R3 --> RR
end
subgraph F["4 · FULL OUTER JOIN — cả hai"]
direction LR
F1["<b>A riêng</b><br/>cột B = NULL"]
F2["<b>A ∩ B</b>"]
F3["<b>B riêng</b><br/>cột A = NULL"]
F1 --> FR["<b>KẾT QUẢ</b>"]
F2 --> FR
F3 --> FR
end
style I2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style L1 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style L2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style R2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style R3 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style F1 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style F2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style F3 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style I1 fill:#ffcdd2,stroke:#c62828
style I3 fill:#ffcdd2,stroke:#c62828
style L3 fill:#ffcdd2,stroke:#c62828
style R1 fill:#ffcdd2,stroke:#c62828
style IR fill:#e8eaf6,stroke:#3949ab,stroke-width:2px
style LR2 fill:#e8eaf6,stroke:#3949ab,stroke-width:2px
style RR fill:#e8eaf6,stroke:#3949ab,stroke-width:2px
style FR fill:#e8eaf6,stroke:#3949ab,stroke-width:2px
Ba vùng A riêng, A ∩ B, B riêng là ba phần rời nhau của dữ liệu — không có quan hệ nào giữa chúng, nên sơ đồ cố ý không nối chúng với nhau. Mũi tên duy nhất trong mỗi khung có một nghĩa rõ ràng: vùng nào đi được vào kết quả. Nói gọn hết bằng một bảng:
| Dòng chỉ có ở A | Dòng khớp A ∩ B | Dòng chỉ có ở B | |
|---|---|---|---|
INNER JOIN |
❌ bỏ | ✅ giữ | ❌ bỏ |
LEFT OUTER JOIN |
✅ giữ, cột B = NULL |
✅ giữ | ❌ bỏ |
RIGHT OUTER JOIN |
❌ bỏ | ✅ giữ | ✅ giữ, cột A = NULL |
FULL OUTER JOIN |
✅ giữ, cột B = NULL |
✅ giữ | ✅ giữ, cột A = NULL |
Bốn dòng của bảng này là toàn bộ nội dung cần nhớ về bốn loại JOIN đầu tiên.
Hai loại còn lại không nói bằng tập hợp được, vì chúng khác về bản chất:
flowchart TB
subgraph C["5 · CROSS JOIN — không có điều kiện"]
direction LR
CA["A<br/>6 dòng"]
CX["×"]
CB["B<br/>8 dòng"]
CR["<b>48 dòng</b><br/>mọi tổ hợp"]
CA --- CX --- CB --> CR
end
subgraph S["6 · SELF JOIN — một bảng, hai bí danh"]
direction LR
SA["hoc_sinh<br/><b>AS a</b>"]
SJ["ON a.x = b.x<br/>AND a.ma_hs < b.ma_hs"]
SB["hoc_sinh<br/><b>AS b</b>"]
SA --- SJ --- SB
end
style C fill:#fff8e1,stroke:#f9a825,stroke-width:2px
style S fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px
style CR fill:#ffe0b2,stroke:#ef6c00,stroke-width:2px
Và đây là điều đã xảy ra với lớp 9A3 trong câu chuyện mở đầu:
flowchart LR
LOP["<b>lop</b> · 6 dòng<br/>L01→GV01 · L02→GV02<br/>L03→GV03 · L04→GV04<br/>L05→GV05<br/><b>L06 → NULL</b>"]
IJ["INNER JOIN<br/>ON l.ma_gvcn = g.ma_gv"]
LJ["LEFT JOIN<br/>ON l.ma_gvcn = g.ma_gv"]
R1["<b>5 dòng</b><br/>❌ 9A3 biến mất"]
R2["<b>6 dòng</b><br/>✅ 9A3 còn · GVCN = NULL"]
LOP --> IJ --> R1
LOP --> LJ --> R2
style LOP fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style R1 fill:#ffebee,stroke:#c62828,stroke-width:2px
style R2 fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
💻 Thực hành¶
INNER JOIN — và cái lớp bị đánh rơi¶
Đây chính là câu lệnh mà "bạn" trong câu chuyện đã viết:
-- KỲ VỌNG: 5 dòng
SELECT l.ma_lop, l.ten_lop, g.ho_ten AS ten_gvcn
FROM lop l
INNER JOIN giao_vien g ON g.ma_gv = l.ma_gvcn
ORDER BY l.ma_lop;
Năm dòng, không phải sáu. Lớp L06 (9A3) biến mất vì ma_gvcn của nó là NULL, và NULL = 'GV01' cho UNKNOWN — mà INNER JOIN chỉ giữ những cặp cho TRUE. Đây đúng là logic ba giá trị ở Bài 24 đang hoạt động, lần này bên trong mệnh đề ON.
Từ khoá INNER có thể bỏ: viết JOIN không kèm gì thì PostgreSQL hiểu là INNER JOIN.
LEFT OUTER JOIN — giữ lại mọi lớp¶
-- KỲ VỌNG: 6 dòng
-- KỲ VỌNG: ten_gvcn = NULL
SELECT l.ma_lop, l.ten_lop, g.ho_ten AS ten_gvcn
FROM lop l
LEFT JOIN giao_vien g ON g.ma_gv = l.ma_gvcn
ORDER BY l.ma_gvcn NULLS FIRST, l.ma_lop;
Sáu dòng, đủ cả trường. Dòng đầu tiên là L06 — 9A3 với ten_gvcn rỗng — PostgreSQL tự điền NULL vào các cột bên phải khi không tìm được bạn khớp.
Hãy để ý một điều quan trọng: cái NULL đó không có trong bảng nào. Bảng giao_vien không có dòng nào rỗng. NULL này do chính phép LEFT JOIN sinh ra.
RIGHT OUTER JOIN — chỉ là LEFT JOIN soi gương¶
-- KỲ VỌNG: 6 dòng
SELECT g.ho_ten AS ten_gvcn, l.ma_lop, l.ten_lop
FROM giao_vien g
RIGHT JOIN lop l ON g.ma_gv = l.ma_gvcn
ORDER BY l.ma_lop;
Cùng 6 dòng, cùng nội dung như câu LEFT JOIN trên — chỉ khác thứ tự cột. Đó là lý do RIGHT JOIN ít được dùng: đổi chỗ hai bảng rồi viết LEFT JOIN thì mạch đọc tự nhiên hơn nhiều, vì mắt người đọc từ trái sang phải và "bảng chính" nên đứng trước.
FULL OUTER JOIN — đối chiếu hai danh sách¶
Câu hỏi: "Đối chiếu danh sách giáo viên và danh sách lớp. Ai chưa chủ nhiệm lớp nào, và lớp nào chưa có chủ nhiệm?"
-- KỲ VỌNG: 9 dòng
SELECT g.ma_gv, g.ho_ten, l.ma_lop, l.ten_lop
FROM giao_vien g
FULL OUTER JOIN lop l ON g.ma_gv = l.ma_gvcn
ORDER BY g.ma_gv NULLS LAST, l.ma_lop NULLS LAST;
Chín dòng, phân tích ra thành ba nhóm:
- 5 dòng khớp:
GV01–GV05↔L01–L05. - 3 dòng chỉ có bên trái:
GV06,GV07,GV08— chưa chủ nhiệm lớp nào, phầnloplàNULL. - 1 dòng chỉ có bên phải:
L06— chưa có chủ nhiệm, phầngiao_vienlàNULL.
5 + 3 + 1 = 9. Không có JOIN nào khác cho bạn nhìn thấy cả ba nhóm cùng lúc.
Tách riêng từng phần lệch bằng cách lọc trên NULL:
-- KỲ VỌNG: 3 dòng
SELECT g.ma_gv, g.ho_ten
FROM giao_vien g
FULL OUTER JOIN lop l ON g.ma_gv = l.ma_gvcn
WHERE l.ma_lop IS NULL
ORDER BY g.ma_gv;
Khuôn "kết nối chống" — tìm dòng mồ côi¶
Đây là khuôn câu lệnh bạn sẽ dùng nhiều nhất trong đời làm nghề, nên hãy nhớ nó như một công thức:
LEFT JOINbảng con, rồiWHERE <khoá chính bảng con> IS NULL.
Dịch ra tiếng Việt: "ghép sao cho không đánh rơi ai, rồi chỉ giữ những dòng mà phép ghép không tìm được bạn khớp." Người ta gọi khuôn này là kết nối chống (anti join).
Áp dụng: "Học sinh nào chưa có phụ huynh nào trong hệ thống?"
-- KỲ VỌNG: 1 dòng
-- KỲ VỌNG: ma_hs = HS040
SELECT h.ma_hs, h.ho_ten, h.ma_lop
FROM hoc_sinh h
LEFT JOIN phu_huynh p ON p.ma_hs = h.ma_hs
WHERE p.ma_ph IS NULL
ORDER BY h.ma_hs;
Đúng một bạn: HS040 — Đinh Thị Vân. Bốn mươi học sinh, 45 phụ huynh, nhưng có một bạn chưa ai đăng ký thông tin liên lạc.
Hãy xem cùng phép LEFT JOIN đó cho bao nhiêu dòng khi không lọc:
-- KỲ VỌNG: so_dong = 46
SELECT count(*) AS so_dong
FROM hoc_sinh h
LEFT JOIN phu_huynh p ON p.ma_hs = h.ma_hs;
46 chứ không phải 40. Vì sáu bạn có hai phụ huynh, nên mỗi bạn trong số đó sinh ra hai dòng. Cộng thêm HS040 với một dòng NULL: 39 + 6 + 1 = 46.
JOIN làm số dòng tăng lên, không chỉ giảm đi
Nhiều người mới tưởng JOIN luôn thu hẹp kết quả. Sai. Nếu quan hệ là một-nhiều, mỗi dòng cha sẽ nhân lên thành nhiều dòng.
Hậu quả cụ thể và rất hay gặp: bạn ghép hoc_sinh với diem rồi tính count(*) và tưởng mình đang đếm học sinh — trong khi thật ra bạn đang đếm số con điểm. Bài 26 sẽ dạy count(DISTINCT ...) để xử lý đúng chuyện này.
Ngược chiều: "Giáo viên nào chưa chủ nhiệm lớp nào?"
-- KỲ VỌNG: 3 dòng
SELECT g.ma_gv, g.ho_ten, g.mon_chuyen_mon
FROM giao_vien g
LEFT JOIN lop l ON l.ma_gvcn = g.ma_gv
WHERE l.ma_lop IS NULL
ORDER BY g.ma_gv;
Ba người: GV06, GV07, GV08. So sánh với câu EXCEPT ở Bài 21 — cùng câu trả lời, hai con đường khác nhau.
SELF JOIN — ghép bảng với chính nó¶
Câu hỏi: "Có cặp học sinh nào sinh cùng một ngày không?"
-- KỲ VỌNG: 0 dòng
SELECT a.ma_hs AS ma_1, a.ho_ten AS ten_1,
b.ma_hs AS ma_2, b.ho_ten AS ten_2,
a.ngay_sinh
FROM hoc_sinh a
JOIN hoc_sinh b
ON a.ngay_sinh = b.ngay_sinh
AND a.ma_hs < b.ma_hs
ORDER BY a.ma_hs, b.ma_hs;
Không cặp nào. Bốn mươi học sinh của trường có 40 ngày sinh khác nhau hoàn toàn. Đây là một kết quả rất đáng giá về mặt học tập: truy vấn của bạn đúng, dữ liệu chỉ đơn giản là không có trường hợp nào thoả.
Kết quả rỗng: làm sao biết mình viết sai hay dữ liệu không có?
Đây là tình huống bạn sẽ gặp hàng tuần khi đi làm, và có một kỹ thuật cụ thể để xử lý — nới dần từng điều kiện cho tới khi có dòng.
Cách làm: bỏ (hoặc làm yếu đi) một điều kiện, chạy lại, xem có dòng chưa. Lặp lại cho tới khi kết quả khác rỗng. Điều kiện cuối cùng bạn vừa bỏ chính là chỗ chặn mọi thứ — và lúc đó bạn đọc lại nó để quyết định: nó sai, hay dữ liệu thật sự không có.
Áp vào đúng truy vấn trên. Điều kiện gắt nhất là a.ngay_sinh = b.ngay_sinh; nới nó thành "cùng tháng sinh":
-- KỲ VỌNG: so_cap_cung_thang = 48
-- KỲ VỌNG: so_cap_cung_ngay = 0
SELECT count(*) AS so_cap_cung_thang,
count(*) FILTER (WHERE a.ngay_sinh = b.ngay_sinh) AS so_cap_cung_ngay
FROM hoc_sinh a
JOIN hoc_sinh b
ON EXTRACT(MONTH FROM a.ngay_sinh) = EXTRACT(MONTH FROM b.ngay_sinh)
AND a.ma_hs < b.ma_hs;
Nới một bậc thì có ngay 48 cặp. Vậy phần khung của truy vấn — JOIN, bí danh, điều kiện a.ma_hs < b.ma_hs — chạy đúng. Chỉ điều kiện "cùng đúng ngày" là không có dữ liệu nào thoả, và cột thứ hai khẳng định lại con số 0 đó trên cùng một lượt chạy.
Kết luận: truy vấn đúng, dữ liệu không có trường hợp nào. Nếu ngược lại — nới hết mọi điều kiện mà vẫn rỗng — thì lỗi nằm ở phần khung, thường là sai tên cột trong ON hoặc ghép sai cặp bảng.
Một mẹo nữa dùng được ngay: đếm riêng hai bảng đầu vào trước khi ghép. Nếu một bên đã rỗng thì mọi INNER JOIN với nó đều rỗng, và chuyện chẳng liên quan gì tới mệnh đề ON của bạn cả.
Hai chi tiết trong mệnh đề ON cần giải thích:
a.ngay_sinh = b.ngay_sinhlà điều kiện nghiệp vụ.a.ma_hs < b.ma_hslà mẹo bắt buộc. Không có nó, mỗi học sinh sẽ ghép với chính mình (vì hiển nhiêna.ngay_sinh = a.ngay_sinh), và mỗi cặp thật sẽ xuất hiện hai lần — một lần(An, Bình)và một lần(Bình, An). Dấu<giải quyết cả hai vấn đề bằng một điều kiện.
Nới lỏng thành cùng tháng sinh để thấy SELF JOIN hoạt động thật:
-- KỲ VỌNG: so_cap = 48
SELECT count(*) AS so_cap
FROM hoc_sinh a
JOIN hoc_sinh b
ON EXTRACT(MONTH FROM a.ngay_sinh) = EXTRACT(MONTH FROM b.ngay_sinh)
AND a.ma_hs < b.ma_hs;
48 cặp. Xem cụ thể các cặp sinh tháng 1:
-- KỲ VỌNG: 6 dòng
SELECT a.ma_hs AS ma_1, a.ho_ten AS ten_1,
b.ma_hs AS ma_2, b.ho_ten AS ten_2
FROM hoc_sinh a
JOIN hoc_sinh b
ON EXTRACT(MONTH FROM a.ngay_sinh) = EXTRACT(MONTH FROM b.ngay_sinh)
AND a.ma_hs < b.ma_hs
WHERE EXTRACT(MONTH FROM a.ngay_sinh) = 1
ORDER BY a.ma_hs, b.ma_hs;
Bốn bạn sinh tháng 1 (HS001, HS013, HS021, HS027) tạo ra 4 × 3 ÷ 2 = 6 cặp. Nếu bỏ điều kiện a.ma_hs < b.ma_hs, con số sẽ là 4 × 4 = 16 — gồm 4 cặp tự ghép và 12 cặp bị đếm hai lần.
CROSS JOIN và tích Descartes bùng nổ¶
CROSS JOIN có lúc rất hữu ích. Ví dụ: sinh một bảng điểm rỗng cho mọi cặp (học sinh, môn học) để giáo viên điền vào:
40 × 9 = 360 ô. Đây là dùng CROSS JOIN có ý thức.
Còn đây là dùng nó vô tình — chỉ vì liệt kê hai bảng trong FROM mà quên điều kiện ghép:
-- QUÊN mệnh đề WHERE h.ma_lop = l.ma_lop
-- KỲ VỌNG: so_dong = 240
SELECT count(*) AS so_dong
FROM hoc_sinh, lop;
240 dòng thay vì 40 — mỗi học sinh bị gán vào cả sáu lớp. Câu lệnh không báo lỗi, kết quả chỉ đơn giản là vô nghĩa.
Với hai bảng nhỏ thì chỉ là kết quả sai. Nhưng hãy làm phép nhân với hai bảng lớn của Cấp 4:
Hai mươi lăm tỉ dòng. Máy chủ sẽ ăn hết bộ nhớ, ghi đầy ổ đĩa bằng tệp tạm, rồi treo. Đó là tích Descartes bùng nổ.
Cách tự bảo vệ
- Luôn viết
JOIN ... ON ..., đừng dùng lối cũFROM a, b WHERE .... Với cú phápJOIN, quênONlà lỗi cú pháp — PostgreSQL chặn bạn lại ngay. Với dấu phẩy thì không. - Khi thấy số dòng lớn bất thường, hãy nhân số dòng các bảng với nhau. Nếu kết quả khớp với tích đó, bạn đã quên một điều kiện ghép.
- Khi thử một truy vấn ghép nhiều bảng lớn, gắn
LIMIT 100trước.
ON và USING¶
-- KỲ VỌNG: 40 dòng
SELECT h.ma_hs, h.ho_ten, l.ten_lop
FROM hoc_sinh h
JOIN lop l ON h.ma_lop = l.ma_lop
ORDER BY h.ma_hs;
Cùng kết quả, viết bằng USING:
-- KỲ VỌNG: 40 dòng
SELECT ma_hs, ho_ten, ten_lop, ma_lop
FROM hoc_sinh
JOIN lop USING (ma_lop)
ORDER BY ma_hs;
Chú ý ở câu thứ hai: ma_lop viết không có tiền tố bảng, và điều đó là bắt buộc — USING gộp hai cột ma_lop thành một, nên không còn h.ma_lop và l.ma_lop riêng biệt nữa.
NATURAL JOIN — và vì sao đừng dùng¶
Trên cặp hoc_sinh / lop, NATURAL JOIN làm đúng điều bạn mong: hai bảng chỉ trùng nhau ở cột ma_lop.
Bây giờ thử với cặp hoc_sinh / giao_vien:
Không dòng nào. Hai bảng này trùng nhau tới ba cột: ho_ten, ngay_sinh, gioi_tinh. NATURAL JOIN lặng lẽ ghép theo cả ba, tức là nó đang đi tìm "những người vừa là học sinh vừa là giáo viên, cùng tên, cùng ngày sinh, cùng giới tính". Đương nhiên không có ai.
Và đây mới là điều đáng sợ: không có thông báo lỗi nào. Bạn nhận về một bảng rỗng và phải tự đoán vì sao.
NATURAL JOIN còn có một kiểu hỏng ngược lại và phá hoại hơn nhiều: nếu hai bảng không có cột nào trùng tên, nó không báo lỗi mà thoái hoá thành CROSS JOIN — đúng cái tích Descartes bùng nổ ở mục trên.
-- KỲ VỌNG: natural_khong_cot_trung = 120
-- KỲ VỌNG: cross_join = 120
SELECT (SELECT count(*) FROM lop NATURAL JOIN sach) AS natural_khong_cot_trung,
(SELECT count(*) FROM lop CROSS JOIN sach) AS cross_join;
lop và sach không chia sẻ tên cột nào, nên NATURAL JOIN không có điều kiện nào để ghép và trả về 120 dòng — bằng đúng 6 × 20, y hệt CROSS JOIN.
Hãy ghép hai kiểu hỏng lại để thấy NATURAL JOIN tệ tới mức nào:
| Số cột trùng tên | NATURAL JOIN làm gì |
Hậu quả |
|---|---|---|
| 0 cột | Thoái hoá thành CROSS JOIN |
Tích Descartes bùng nổ — trên bảng lớn là treo máy |
| Đúng cột bạn muốn | Làm đúng ý | Chạy được, nhưng vẫn dễ vỡ khi ai đó đổi tên cột |
| Nhiều hơn bạn muốn | Ghép theo cả những cột bạn không nghĩ tới | Âm thầm 0 dòng, hoặc tệ hơn là thiếu dòng mà vẫn trông hợp lý |
Cả ba hàng đều không báo lỗi. Đó là lý do quy tắc ở đây dứt khoát: đừng dùng NATURAL JOIN trong mã thật.
Nếu viết bằng ON, ý định của bạn hiện rõ trên mặt câu lệnh và không có gì ngầm hiểu:
-- KỲ VỌNG: so_dong = 40
SELECT count(*) AS so_dong
FROM hoc_sinh h
JOIN lop l ON l.ma_lop = h.ma_lop
LEFT JOIN giao_vien g ON g.ma_gv = l.ma_gvcn;
Ghép nhiều bảng — lời hứa với cô hiệu phó¶
Cuối cùng, đây là bảng mà cô hiệu phó đã nhờ từ đầu bài: tên học sinh, tên lớp, tên giáo viên chủ nhiệm — và không đánh rơi lớp 9A3:
-- KỲ VỌNG: 1 dòng
-- KỲ VỌNG: so_dong = 40
-- KỲ VỌNG: so_khong_co_gvcn = 7
SELECT count(*) AS so_dong,
count(*) FILTER (WHERE g.ma_gv IS NULL) AS so_khong_co_gvcn
FROM hoc_sinh h
JOIN lop l ON l.ma_lop = h.ma_lop
LEFT JOIN giao_vien g ON g.ma_gv = l.ma_gvcn;
Đủ 40 học sinh, trong đó 7 bạn của lớp 9A3 có ô giáo viên chủ nhiệm để rỗng. Chú ý cách chọn loại JOIN cho từng bước:
hoc_sinh→lopdùngJOINthường, vìhoc_sinh.ma_lopđược khaiNOT NULL: mọi học sinh chắc chắn có lớp, không thể đánh rơi ai.lop→giao_viendùngLEFT JOIN, vìlop.ma_gvcncho phépNULL.
Quy tắc chọn loại JOIN
Nhìn vào khoá ngoại dùng để ghép:
- Khoá ngoại khai
NOT NULL→INNER JOINlà an toàn, không mất dòng nào. - Khoá ngoại cho phép
NULL→ phải tự hỏi "tôi có muốn giữ những dòng chưa có bạn khớp không?". Câu trả lời thường là có, và khi đó dùngLEFT JOIN.
Nói cách khác: thiết kế lược đồ ở Cấp 1 quyết định luôn loại JOIN bạn phải viết ở Cấp 3.
⚠️ Lỗi thường gặp¶
Lỗi 1: Dùng INNER JOIN khi khoá ngoại cho phép NULL
Đây là câu chuyện mở đầu, và là lỗi phổ biến nhất của cả bài.
Triệu chứng: báo cáo thiếu dòng, nhưng thiếu âm thầm — không lỗi, không cảnh báo, chỉ là tổng số nhỏ hơn thực tế.
Cách phát hiện: đếm số dòng của bảng chính trước khi ghép, rồi so với số dòng sau khi ghép. Nếu giảm mà bạn không cố ý lọc gì, bạn đã mất dòng:
-- KỲ VỌNG: truoc_khi_ghep = 6
-- KỲ VỌNG: inner_join = 5
-- KỲ VỌNG: left_join = 6
SELECT (SELECT count(*) FROM lop) AS truoc_khi_ghep,
(SELECT count(*) FROM lop l JOIN giao_vien g ON g.ma_gv = l.ma_gvcn) AS inner_join,
(SELECT count(*) FROM lop l LEFT JOIN giao_vien g ON g.ma_gv = l.ma_gvcn) AS left_join;
Lỗi 2: LEFT JOIN rồi lọc bảng phải trong WHERE
Lỗi này xoá sạch công dụng của LEFT JOIN, và cực kỳ khó nhìn ra khi đọc mã.
-- KỲ VỌNG: loc_trong_where = 3
-- KỲ VỌNG: loc_trong_on = 6
SELECT (SELECT count(*) FROM lop l
LEFT JOIN giao_vien g ON g.ma_gv = l.ma_gvcn
WHERE g.gioi_tinh = 'Nữ') AS loc_trong_where,
(SELECT count(*) FROM lop l
LEFT JOIN giao_vien g ON g.ma_gv = l.ma_gvcn
AND g.gioi_tinh = 'Nữ') AS loc_trong_on;
Cùng một điều kiện, đặt ở hai chỗ, ra hai kết quả:
- Đặt trong
WHERE:LEFT JOINsinh ra dòngL06vớig.gioi_tinh = NULL, rồiWHERE NULL = 'Nữ'choUNKNOWNvà loại dòng đó đi. Kết quả 3 dòng —LEFT JOINvừa bị biến thànhINNER JOIN. - Đặt trong
ON: điều kiện chỉ quyết định "dòng nào bên phải được coi là khớp". Mọi lớp vẫn được giữ. Kết quả 6 dòng.
Quy tắc: điều kiện về bảng phải của một LEFT JOIN thì đặt trong ON. Điều kiện về bảng trái thì đặt ở WHERE. Ngoại lệ duy nhất là khuôn kết nối chống WHERE cot_phai IS NULL — ở đó bạn cố ý muốn lọc sau khi ghép.
Lỗi 3: Quên điều kiện ghép — tích Descartes bùng nổ
-- KỲ VỌNG: dung_cach = 40
-- KỲ VỌNG: quen_dieu_kien = 240
SELECT (SELECT count(*) FROM hoc_sinh h JOIN lop l ON l.ma_lop = h.ma_lop) AS dung_cach,
(SELECT count(*) FROM hoc_sinh, lop) AS quen_dieu_kien;
240 = 40 × 6. Số dòng bằng đúng tích số dòng hai bảng là dấu hiệu chắc chắn của lỗi này.
Dùng cú pháp JOIN ... ON thay cho FROM a, b để PostgreSQL bắt lỗi giúp bạn.
Lỗi 4: Dùng NATURAL JOIN
Nó dựa vào tên cột, mà tên cột thay đổi được. Một câu ALTER TABLE ... ADD COLUMN ghi_chu trên hai bảng là đủ để mọi NATURAL JOIN giữa chúng đổi kết quả — im lặng.
Đã thấy ở phần thực hành: hoc_sinh NATURAL JOIN giao_vien trả về 0 dòng vì nó ghép theo cả ba cột ho_ten, ngay_sinh, gioi_tinh.
Luôn viết ON (hoặc USING nếu muốn gọn). Ý định phải hiện rõ trên mặt câu lệnh.
Lỗi 5: SELF JOIN thiếu điều kiện chống trùng
-- KỲ VỌNG: co_dieu_kien_nho_hon = 6
-- KỲ VỌNG: khong_co_dieu_kien = 16
SELECT (SELECT count(*) FROM hoc_sinh a JOIN hoc_sinh b
ON EXTRACT(MONTH FROM a.ngay_sinh) = EXTRACT(MONTH FROM b.ngay_sinh)
AND a.ma_hs < b.ma_hs
WHERE EXTRACT(MONTH FROM a.ngay_sinh) = 1) AS co_dieu_kien_nho_hon,
(SELECT count(*) FROM hoc_sinh a JOIN hoc_sinh b
ON EXTRACT(MONTH FROM a.ngay_sinh) = EXTRACT(MONTH FROM b.ngay_sinh)
WHERE EXTRACT(MONTH FROM a.ngay_sinh) = 1) AS khong_co_dieu_kien;
Bốn học sinh sinh tháng 1: có điều kiện < thì ra 6 cặp đúng; không có thì ra 16 — gồm 4 cặp một người ghép với chính mình, và 12 cặp bị đếm hai lần.
Dùng a.ma_hs < b.ma_hs khi muốn mỗi cặp đúng một lần; dùng a.ma_hs <> b.ma_hs khi bạn thật sự cần cả hai chiều.
✍️ Bài tập¶
-
Liệt kê mọi cuốn sách kèm mã học sinh đã mượn nó, sao cho những cuốn chưa ai mượn vẫn xuất hiện trong kết quả. Dùng loại
JOINnào, và kết quả có bao nhiêu dòng? -
Viết truy vấn tìm những môn học chưa từng được phân công cho giáo viên nào (bảng
phan_cong_day). -
Câu lệnh sau sai ở đâu? Sửa lại.
SELECT h.ho_ten, p.ho_ten AS ten_phu_huynh FROM hoc_sinh h LEFT JOIN phu_huynh p ON p.ma_hs = h.ma_hs WHERE p.quan_he = 'Mẹ';Ý định: "liệt kê mọi học sinh, kèm tên mẹ nếu có."
-
Bảng
hoc_sinhcó 40 dòng,diemcó 480 dòng. HỏiSELECT count(*) FROM hoc_sinh h JOIN diem d ON d.ma_hs = h.ma_hscho ra bao nhiêu? VàSELECT count(*) FROM hoc_sinh, diemcho ra bao nhiêu? Giải thích chênh lệch. -
Tìm mọi cặp giáo viên cùng dạy một lớp trong cùng học kỳ nhưng khác môn. Đây là
SELF JOINtrên bảng nào, và cần mấy điều kiện trongON?
Đáp án
Câu 1.
-- KỲ VỌNG: 1 dòng
-- KỲ VỌNG: so_dong = 50
-- KỲ VỌNG: so_sach_chua_ai_muon = 0
SELECT count(*) AS so_dong,
count(*) FILTER (WHERE m.ma_muon IS NULL) AS so_sach_chua_ai_muon
FROM sach s
LEFT JOIN muon_sach m ON m.ma_sach = s.ma_sach;
Dùng LEFT JOIN với sach ở vế trái, vì ta muốn giữ mọi cuốn sách kể cả cuốn chưa ai mượn.
Kết quả: 50 dòng, và so_sach_chua_ai_muon = 0 — hoá ra cả 20 cuốn đều đã có người mượn ít nhất một lần. Vì có 50 lượt mượn rải trên 20 cuốn, mỗi cuốn được mượn nhiều lần, nên số dòng sau khi ghép là 50 chứ không phải 20.
Đây lại là bài học "JOIN làm số dòng tăng": vế trái có 20 dòng, kết quả có 50.
Câu 2.
-- KỲ VỌNG: 1 dòng
-- KỲ VỌNG: ma_mon = MH08
SELECT m.ma_mon, m.ten_mon
FROM mon_hoc m
LEFT JOIN phan_cong_day pc ON pc.ma_mon = m.ma_mon
WHERE pc.ma_mon IS NULL
ORDER BY m.ma_mon;
Đúng một môn: MH08 — Địa lý. Trường có 8 giáo viên với 8 chuyên môn, nhưng bảng mon_hoc có 9 môn — không thầy cô nào chuyên Địa lý, nên môn đó chưa được phân công.
Đây chính là khuôn kết nối chống áp dụng lần thứ ba trong bài.
Câu 3.
Sai vì điều kiện p.quan_he = 'Mẹ' nằm trong WHERE, tức là được áp sau khi ghép. Với những học sinh không có mẹ trong hệ thống, LEFT JOIN sinh ra dòng có p.quan_he = NULL; rồi WHERE NULL = 'Mẹ' cho UNKNOWN và dòng đó bị loại.
Kết quả: truy vấn chỉ trả về những bạn có mẹ, tức là LEFT JOIN đã bị biến thành INNER JOIN. Trái hẳn với ý định "liệt kê mọi học sinh".
Sửa bằng cách chuyển điều kiện vào ON:
-- KỲ VỌNG: so_dong = 40
-- KỲ VỌNG: so_khong_co_me = 20
SELECT count(*) AS so_dong,
count(*) FILTER (WHERE p.ma_ph IS NULL) AS so_khong_co_me
FROM hoc_sinh h
LEFT JOIN phu_huynh p ON p.ma_hs = h.ma_hs
AND p.quan_he = 'Mẹ';
Bây giờ đủ 40 dòng — mỗi học sinh đúng một dòng, vì mỗi bạn có nhiều nhất một mẹ. Bảng phu_huynh có 20 dòng quan_he = 'Mẹ' thuộc 20 học sinh khác nhau, nên 20 bạn còn lại không có mẹ đăng ký (chỉ có bố, hoặc ông bà, hoặc — như HS040 — không có ai).
Câu 4.
-- KỲ VỌNG: co_dieu_kien = 480
-- KỲ VỌNG: khong_dieu_kien = 19200
SELECT (SELECT count(*) FROM hoc_sinh h JOIN diem d ON d.ma_hs = h.ma_hs) AS co_dieu_kien,
(SELECT count(*) FROM hoc_sinh, diem) AS khong_dieu_kien;
- Có điều kiện: 480 dòng. Mỗi con điểm khớp với đúng một học sinh (vì
diem.ma_hslà khoá ngoại trỏ tới khoá chính củahoc_sinh), nên số dòng bằng số dòng của bảng "nhiều", tức là 480. - Không điều kiện: 19.200 dòng = 40 × 480. Mỗi học sinh bị ghép với toàn bộ 480 con điểm của cả trường.
Chênh lệch 40 lần. Và nếu bạn tính AVG(diem_so) trên bảng 19.200 dòng đó, con số trả về vẫn "trông có vẻ hợp lý" — đó là điều làm lỗi này nguy hiểm.
Câu 5.
SELF JOIN trên bảng phan_cong_day, cần bốn điều kiện trong ON:
-- Bốn điều kiện: cùng lớp · cùng học kỳ · khác môn · mỗi cặp giáo viên đúng một lần
-- KỲ VỌNG: so_cap = 224
SELECT count(*) AS so_cap
FROM phan_cong_day a
JOIN phan_cong_day b
ON a.ma_lop = b.ma_lop
AND a.hoc_ky = b.hoc_ky
AND a.ma_mon <> b.ma_mon
AND a.ma_gv < b.ma_gv;
Kiểm lại con số bằng tay: bảng phan_cong_day có 64 dòng, phân bố thành 4 lớp × 2 học kỳ = 8 nhóm, mỗi nhóm đúng 8 dòng (8 giáo viên, mỗi người một môn). Trong mỗi nhóm, số cặp giáo viên khác nhau là 8 × 7 ÷ 2 = 28. Vậy tổng là 8 × 28 = 224.
Điều kiện a.ma_mon <> b.ma_mon ở đây thực ra dư thừa — vì trong dữ liệu mẫu mỗi giáo viên chỉ dạy đúng một môn, nên hai giáo viên khác nhau đã chắc chắn khác môn. Nhưng vẫn nên viết ra, vì nó diễn đạt đúng yêu cầu của đề bài và sẽ cần thiết khi dữ liệu thay đổi.
🔑 Tóm tắt¶
- Sáu loại
JOINchỉ khác nhau ở một câu hỏi: "dòng không tìm được bạn khớp thì giữ hay bỏ?" —INNERbỏ cả hai bên,LEFTgiữ bên trái,RIGHTgiữ bên phải,FULL OUTERgiữ cả hai,CROSSkhông có điều kiện,SELFlà ghép bảng với chính nó. - Khoá ngoại cho phép
NULLthìINNER JOINsẽ âm thầm đánh rơi dòng — lớp9A3biến mất là ví dụ. Quy tắc chọn: khoá ngoạiNOT NULL→INNER JOINan toàn; khoá ngoại choNULL→ cân nhắcLEFT JOIN. - Khuôn kết nối chống
LEFT JOIN ... WHERE <khoá bảng phải> IS NULLlà công cụ tìm "dòng mồ côi": học sinh chưa có phụ huynh, giáo viên chưa chủ nhiệm lớp, môn chưa được phân công. - Đặt điều kiện về bảng phải của
LEFT JOINvàoWHEREsẽ biến nó thànhINNER JOIN— vìNULL = 'giá trị'choUNKNOWN. Điều kiện bảng phải phải đặt trongON. JOINlàm số dòng tăng lên khi quan hệ là một-nhiều, và quên điều kiện ghép thì gây tích Descartes bùng nổ mà không báo lỗi gì. Luôn viếtJOIN ... ONthay choFROM a, b; tránhNATURAL JOIN; vàSELF JOINthì luôn thêma.khoa < b.khoađể mỗi cặp chỉ xuất hiện một lần.
⬅️ Bài 24 — SELECT, WHERE, ORDER BY, LIMIT · ➡️ Bài 26 — GROUP BY, HAVING và các hàm tổng hợp