Ghép dữ liệu từ nhiều bảng bằng INNER, LEFT, RIGHT, FULL, CROSS và NATURAL JOIN; nối bảng với chính nó (self-join); và kỹ thuật anti-join để tìm dòng không khớp.
Ghép dữ liệu từ nhiều bảng bằng INNER, LEFT, RIGHT, FULL, CROSS và NATURAL JOIN; nối bảng với chính nó (self-join); và kỹ thuật anti-join để tìm dòng không khớp.
1. Bảng ví dụ tự chứa
Để hiểu rõ từng loại JOIN, hãy dùng hai bảng nhỏ có thể kiểm chứng bằng mắt:
DROP TABLE IF EXISTS basket_a, basket_b;CREATE TABLE basket_a ( id INT PRIMARY KEY, fruit VARCHAR(100) NOT NULL);CREATE TABLE basket_b ( id INT PRIMARY KEY, fruit VARCHAR(100) NOT NULL);INSERT INTO basket_a (id, fruit) VALUES (1, 'Apple'), (2, 'Orange'), (3, 'Banana'), (4, 'Cucumber');INSERT INTO basket_b (id, fruit) VALUES (1, 'Orange'), (2, 'Apple'), (3, 'Watermelon'), (4, 'Pear');
basket_a
basket_b
id
fruit
id
fruit
1
Apple
1
Orange
2
Orange
2
Apple
3
Banana
3
Watermelon
4
Cucumber
4
Pear
Chúng ta sẽ nối theo fruit (không phải id). Quả chung: Apple, Orange.
2. Bí danh bảng (table alias)
Trước khi vào JOIN, cần nắm bí danh bảng — nếu không truy vấn sẽ rất dài dòng.
SELECT c.first_name, c.last_name, p.amountFROM customer AS cINNER JOIN payment AS p ON p.customer_id = c.customer_id;
Từ khóa AS có thể lược bỏ: FROM customer c.
Bí danh là bắt buộc khi:
Hai bảng có cột trùng tên và bạn phải phân biệt.
Nối một bảng với chính nó (self-join).
Quy ước đặt bí danh
Dùng chữ viết tắt có nghĩa (c cho customer, p cho payment), không dùng t1, t2. Truy vấn 6 bảng với t1..t6 là ác mộng khi đọc lại sau 3 tháng.
3. INNER JOIN
Chỉ giữ những dòng có mặt ở cả hai bảng.
SELECT a.id AS id_a, a.fruit AS fruit_a, b.id AS id_b, b.fruit AS fruit_bFROM basket_a aINNER JOIN basket_b b ON a.fruit = b.fruit;
SELECT c.customer_id, c.first_name || ' ' || c.last_name AS khach_hang, p.amount, p.payment_dateFROM customer cINNER JOIN payment p ON p.customer_id = c.customer_idWHERE c.customer_id = 2ORDER BY p.payment_dateLIMIT 5;
3.2. Nối nhiều bảng
Chuỗi JOIN được thực hiện từ trái sang phải:
SELECT f.title, cat.name AS the_loai, l.name AS ngon_nguFROM film fINNER JOIN film_category fc ON fc.film_id = f.film_idINNER JOIN category cat ON cat.category_id = fc.category_idINNER JOIN language l ON l.language_id = f.language_idWHERE cat.name = 'Comedy'ORDER BY f.titleLIMIT 5;
title | the_loai | ngon_ngu----------------------+----------+---------- Airplane Sierra | Comedy | English Anthem Luke | Comedy | English Bringing Hysterical | Comedy | English Caper Motions | Comedy | English Cat Coneheads | Comedy | English
3.3. ON vs USING
Khi hai bảng dùng cùng tên cột khóa, USING viết gọn hơn:
-- Dạng ONFROM film fINNER JOIN film_category fc ON fc.film_id = f.film_id-- Dạng USING — tương đươngFROM film fINNER JOIN film_category fc USING (film_id)
Khác biệt: USINGgộp cột chung thành một cột duy nhất trong kết quả, nên SELECT * sẽ không hiện film_id hai lần.
4. LEFT JOIN (LEFT OUTER JOIN)
Giữ toàn bộ dòng của bảng trái; những dòng không tìm được cặp bên phải sẽ được điền NULL.
SELECT a.id AS id_a, a.fruit AS fruit_a, b.id AS id_b, b.fruit AS fruit_bFROM basket_a aLEFT JOIN basket_b b ON a.fruit = b.fruit;
Phải chọn cột không thể là NULL trong bảng phải — thường là khóa chính. Nếu bạn viết WHERE i.some_nullable_column IS NULL, bạn sẽ vô tình gộp cả những dòng có khớp nhưng cột đó rỗng.
4.2. Cạm bẫy: điều kiện trong WHERE biến LEFT JOIN thành INNER JOIN
-- ⚠️ Không còn là LEFT JOIN nữa!SELECT c.customer_id, c.first_name, p.amountFROM customer cLEFT JOIN payment p ON p.customer_id = c.customer_idWHERE p.amount > 5;
LEFT JOIN sinh ra dòng có p.amount = NULL cho khách chưa thanh toán. Nhưng NULL > 5 cho ra NULL, không phải true → những dòng đó bị WHERE loại bỏ. Kết quả giống hệt INNER JOIN.
Cách sửa — đặt điều kiện vào ON:
SELECT c.customer_id, c.first_name, p.amountFROM customer cLEFT JOIN payment p ON p.customer_id = c.customer_id AND p.amount > 5; -- ← lọc TRƯỚC khi join
Quy tắc vàng
Với OUTER JOIN:
Điều kiện về bảng bên phải → đặt trong ON.
Điều kiện về bảng bên trái → đặt trong WHERE.
Với INNER JOIN, đặt ở đâu cũng cho kết quả như nhau.
5. RIGHT JOIN
Ngược lại của LEFT JOIN — giữ toàn bộ bảng phải.
SELECT a.id AS id_a, a.fruit AS fruit_a, b.id AS id_b, b.fruit AS fruit_bFROM basket_a aRIGHT JOIN basket_b b ON a.fruit = b.fruit;
A RIGHT JOIN B luôn viết lại được thành B LEFT JOIN A. Vì đọc từ trái sang phải tự nhiên hơn, quy ước phổ biến là chỉ dùng LEFT JOIN và đảo thứ tự bảng khi cần.
6. FULL OUTER JOIN
Giữ toàn bộ dòng của cả hai bảng.
SELECT a.id AS id_a, a.fruit AS fruit_a, b.id AS id_b, b.fruit AS fruit_bFROM basket_a aFULL OUTER JOIN basket_b b ON a.fruit = b.fruit;
Tích Descartes — mỗi dòng bảng trái ghép với mọi dòng bảng phải. Không có mệnh đề ON.
DROP TABLE IF EXISTS T1, T2;CREATE TABLE T1 (label CHAR(1) PRIMARY KEY);CREATE TABLE T2 (score INT PRIMARY KEY);INSERT INTO T1 VALUES ('A'), ('B');INSERT INTO T2 VALUES (1), (2), (3);SELECT * FROM T1 CROSS JOIN T2 ORDER BY label, score;
label | score-------+------- A | 1 A | 2 A | 3 B | 1 B | 2 B | 3(6 rows)
Số dòng kết quả = 2 × 3 = 6. Với bảng 10.000 dòng × 10.000 dòng → 100 triệu dòng.
Cú pháp tương đương:
SELECT * FROM T1, T2; -- dấu phẩy = CROSS JOINSELECT * FROM T1 INNER JOIN T2 ON true; -- điều kiện luôn đúng
CROSS JOIN vô tình
Viết FROM a, b mà quên điều kiện trong WHERE sẽ tạo tích Descartes. Truy vấn treo hoặc làm đầy đĩa. Đây là lý do nên dùng cú pháp JOIN ... ON tường minh thay vì dấu phẩy — quên ON sẽ báo lỗi cú pháp ngay.
7.1. Khi nào CROSS JOIN hữu ích
Sinh lịch đầy đủ — mọi kết hợp cửa hàng × tháng, kể cả tháng không có doanh thu:
SELECT s.store_id, m.thang, COALESCE(sum(p.amount), 0) AS doanh_thuFROM store sCROSS JOIN generate_series(1, 12) AS m(thang)LEFT JOIN payment p ON p.staff_id IN (SELECT staff_id FROM staff WHERE store_id = s.store_id) AND EXTRACT(MONTH FROM p.payment_date) = m.thangGROUP BY s.store_id, m.thangORDER BY s.store_id, m.thang;
LATERAL — biến thể mạnh hơn: lấy 3 phim dài nhất của mỗi thể loại:
SELECT c.name AS the_loai, top.title, top.lengthFROM category cCROSS JOIN LATERAL ( SELECT f.title, f.length FROM film f JOIN film_category fc ON fc.film_id = f.film_id WHERE fc.category_id = c.category_id ORDER BY f.length DESC, f.film_id LIMIT 3) AS topORDER BY c.name, top.length DESC;
LATERAL cho phép subquery bên phải tham chiếu cột của bảng bên trái — điều mà subquery thường không làm được.
8. NATURAL JOIN
Tự động nối theo tất cả các cột trùng tên.
DROP TABLE IF EXISTS products, categories;CREATE TABLE categories ( category_id SERIAL PRIMARY KEY, category_name VARCHAR(255) NOT NULL);CREATE TABLE products ( product_id SERIAL PRIMARY KEY, product_name VARCHAR(255) NOT NULL, category_id INT NOT NULL REFERENCES categories(category_id));INSERT INTO categories (category_name)VALUES ('Smart Phone'), ('Laptop'), ('Tablet');INSERT INTO products (product_name, category_id)VALUES ('iPhone', 1), ('Samsung Galaxy', 1), ('HP Elite', 2), ('Lenovo Thinkpad', 2), ('iPad', 3), ('Kindle Fire', 3);SELECT * FROM products NATURAL JOIN categories;
SELECT * FROM productsINNER JOIN categories USING (category_id);
Đừng dùng NATURAL JOIN trong code thật
Nó nối theo mọi cột trùng tên — bao gồm cả những cột bạn không ngờ tới.
Trong CSDL DVD Rental, hầu hết bảng đều có cột last_update. Viết film NATURAL JOIN film_category sẽ nối theo cả film_id lẫn last_update → trả về gần như 0 dòng.
Tệ hơn: khi ai đó thêm cột created_at vào cả hai bảng, truy vấn đang chạy tốt bỗng cho kết quả sai — không có cảnh báo nào.
Luôn dùng ON hoặc USING tường minh.
9. SELF JOIN
Nối một bảng với chính nó. Bắt buộc dùng bí danh để phân biệt.
9.1. Cấu trúc phân cấp
DROP TABLE IF EXISTS employee;CREATE TABLE employee ( employee_id INT PRIMARY KEY, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, manager_id INT, FOREIGN KEY (manager_id) REFERENCES employee (employee_id) ON DELETE CASCADE);INSERT INTO employee (employee_id, first_name, last_name, manager_id)VALUES (1, 'Windy', 'Hays', NULL), (2, 'Ava', 'Christensen', 1), (3, 'Hassan', 'Conner', 1), (4, 'Anna', 'Reeves', 2), (5, 'Sau', 'Norman', 2), (6, 'Kelsie', 'Hays', 3), (7, 'Tory', 'Goff', 3), (8, 'Salley', 'Lester', 3);
Ai quản lý ai?
SELECT e.first_name || ' ' || e.last_name AS nhan_vien, m.first_name || ' ' || m.last_name AS quan_lyFROM employee eLEFT JOIN employee m ON m.employee_id = e.manager_idORDER BY e.employee_id;
nhan_vien | quan_ly------------------+------------------ Windy Hays | [NULL] Ava Christensen | Windy Hays Hassan Conner | Windy Hays Anna Reeves | Ava Christensen Sau Norman | Ava Christensen Kelsie Hays | Hassan Conner Tory Goff | Hassan Conner Salley Lester | Hassan Conner(8 rows)
Dùng LEFT JOIN chứ không phải INNER JOIN — nếu không, Windy Hays (giám đốc, không có quản lý) sẽ biến mất.
Self-join chỉ đi được một cấp
Muốn duyệt toàn bộ cây phân cấp (quản lý của quản lý của quản lý…), cần truy vấn đệ quy. → CTE và truy vấn đệ quy
9.2. So sánh các dòng với nhau
Tìm các cặp phim có cùng độ dài:
SELECT f1.title AS phim_1, f2.title AS phim_2, f1.lengthFROM film f1INNER JOIN film f2 ON f1.film_id > f2.film_id -- ← tránh trùng cặp và tự ghép chính mình AND f1.length = f2.lengthORDER BY f1.length DESC, f1.titleLIMIT 10;
Điều kiện f1.film_id > f2.film_id rất quan trọng:
Nếu dùng <>: mỗi cặp xuất hiện 2 lần (A-B và B-A).
Nếu bỏ hẳn: mỗi phim còn tự ghép với chính nó.
10. Bảng tổng hợp các loại JOIN
Loại
Dòng bảng trái
Dòng bảng phải
Cách nhớ
INNER JOIN
Chỉ khi khớp
Chỉ khi khớp
Phần giao
LEFT JOIN
Tất cả
Chỉ khi khớp
Toàn bộ trái
RIGHT JOIN
Chỉ khi khớp
Tất cả
Toàn bộ phải
FULL OUTER JOIN
Tất cả
Tất cả
Hợp của cả hai
CROSS JOIN
Mọi tổ hợp
Mọi tổ hợp
Nhân m × n
NATURAL JOIN
Theo cột trùng tên
Theo cột trùng tên
⚠️ Tránh dùng
Anti-join (tìm dòng không khớp): thêm WHERE bang_phai.khoa_chinh IS NULL.
11. Bài tập
Dùng CSDL dvdrental:
Liệt kê tên phim cùng tên ngôn ngữ của phim đó (10 dòng đầu, sắp theo tên phim).
Đếm số lượt thuê của từng khách hàng, bao gồm cả khách chưa thuê lần nào.
Tìm các diễn viên chưa từng đóng phim nào trong CSDL.
Liệt kê tên khách hàng cùng thành phố và quốc gia của họ.
Tìm các cặp diễn viên cùng họ (last_name), không lặp cặp.
Với mỗi thể loại, lấy phim có giá thuê cao nhất (dùng LATERAL).
Giải thích vì sao câu sau không trả về khách hàng chưa thanh toán, và sửa lại:
SELECT c.first_name, p.amountFROM customer cLEFT JOIN payment p ON p.customer_id = c.customer_idWHERE p.payment_date >= '2007-01-01';
Đáp án
1.
SELECT f.title, l.name AS ngon_nguFROM film fINNER JOIN language l ON l.language_id = f.language_idORDER BY f.titleLIMIT 10;
Tất cả phim trong CSDL mẫu đều là English.
2.
SELECT c.customer_id, c.first_name || ' ' || c.last_name AS khach_hang, count(r.rental_id) AS so_luot_thueFROM customer cLEFT JOIN rental r ON r.customer_id = c.customer_idGROUP BY c.customer_id, c.first_name, c.last_nameORDER BY so_luot_thue DESC;
SELECT a.actor_id, a.first_name, a.last_nameFROM actor aLEFT JOIN film_actor fa ON fa.actor_id = a.actor_idWHERE fa.actor_id IS NULL;
Kết quả: 0 dòng — mọi diễn viên trong CSDL mẫu đều có ít nhất một phim.
4.
SELECT c.first_name || ' ' || c.last_name AS khach_hang, ci.city AS thanh_pho, co.country AS quoc_giaFROM customer cJOIN address a ON a.address_id = c.address_idJOIN city ci ON ci.city_id = a.city_idJOIN country co ON co.country_id = ci.country_idORDER BY co.country, ci.city, c.last_name;
5.
SELECT a1.first_name || ' ' || a1.last_name AS dien_vien_1, a2.first_name || ' ' || a2.last_name AS dien_vien_2, a1.last_name AS ho_chungFROM actor a1JOIN actor a2 ON a1.last_name = a2.last_name AND a1.actor_id > a2.actor_idORDER BY a1.last_name;
6.
SELECT c.name AS the_loai, top.title, top.rental_rateFROM category cCROSS JOIN LATERAL ( SELECT f.title, f.rental_rate FROM film f JOIN film_category fc ON fc.film_id = f.film_id WHERE fc.category_id = c.category_id ORDER BY f.rental_rate DESC, f.film_id LIMIT 1) topORDER BY c.name;