Kết hợp nhiều bảng (JOIN)

← Lọc dữ liệu - WHERE, LIMIT, IN, BETWEEN, LIKE, IS NULL | → Nhóm dữ liệu - GROUP BY, HAVING, hàm tổng hợp

Bạn sẽ học được

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_abasket_b
idfruitidfruit
1Apple1Orange
2Orange2Apple
3Banana3Watermelon
4Cucumber4Pear

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.amount
FROM customer AS c
INNER 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:

  1. Hai bảng có cột trùng tên và bạn phải phân biệt.
  2. 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_b
FROM basket_a a
INNER JOIN basket_b b ON a.fruit = b.fruit;
 id_a | fruit_a | id_b | fruit_b
------+---------+------+---------
    1 | Apple   |    2 | Apple
    2 | Orange  |    1 | Orange
(2 rows)
  basket_a          basket_b
  ┌──────┐         ┌──────┐
  │      │╱╲╱╲╱╲╱╲ │      │
  │      │ ██████  │      │      ← chỉ phần giao
  │      │╲╱╲╱╲╱╲╱ │      │
  └──────┘         └──────┘

3.1. Ví dụ thực tế

SELECT c.customer_id,
       c.first_name || ' ' || c.last_name AS khach_hang,
       p.amount,
       p.payment_date
FROM customer c
INNER JOIN payment p ON p.customer_id = c.customer_id
WHERE c.customer_id = 2
ORDER BY p.payment_date
LIMIT 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_ngu
FROM film f
INNER JOIN film_category fc ON fc.film_id = f.film_id
INNER JOIN category cat     ON cat.category_id = fc.category_id
INNER JOIN language l       ON l.language_id = f.language_id
WHERE cat.name = 'Comedy'
ORDER BY f.title
LIMIT 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 ON
FROM film f
INNER JOIN film_category fc ON fc.film_id = f.film_id
 
-- Dạng USING — tương đương
FROM film f
INNER JOIN film_category fc USING (film_id)

Khác biệt: USING gộ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_b
FROM basket_a a
LEFT JOIN basket_b b ON a.fruit = b.fruit;
 id_a | fruit_a  | id_b | fruit_b
------+----------+------+---------
    1 | Apple    |    2 | Apple
    2 | Orange   |    1 | Orange
    3 | Banana   |      |
    4 | Cucumber |      |
(4 rows)

Banana và Cucumber không có trong basket_b → cột bên phải là NULL.

  basket_a          basket_b
  ┌──────┐         ┌──────┐
  │██████│╱╲╱╲╱╲╱╲ │      │
  │██████│ ██████  │      │      ← toàn bộ trái + phần giao
  │██████│╲╱╲╱╲╱╲╱ │      │
  └──────┘         └──────┘

4.1. Tìm dòng không khớp (anti-join)

Đây là ứng dụng quan trọng nhất của LEFT JOIN: thêm WHERE cot_phai IS NULL.

SELECT a.id, a.fruit
FROM basket_a a
LEFT JOIN basket_b b ON a.fruit = b.fruit
WHERE b.id IS NULL;
 id |  fruit
----+----------
  3 | Banana
  4 | Cucumber
(2 rows)
  basket_a          basket_b
  ┌──────┐         ┌──────┐
  │██████│╱╲╱╲╱╲╱╲ │      │
  │██████│         │      │      ← chỉ phần riêng của trái
  │██████│╲╱╲╱╲╱╲╱ │      │
  └──────┘         └──────┘

Ví dụ thực tế — tìm phim chưa từng được nhập kho:

SELECT f.film_id, f.title
FROM film f
LEFT JOIN inventory i ON i.film_id = f.film_id
WHERE i.film_id IS NULL
ORDER BY f.title;

Trả về 42 phim. Đây là cách viết tương đương với NOT EXISTS ở Lọc dữ liệu - WHERE, LIMIT, IN, BETWEEN, LIKE, IS NULL.

Chọn cột nào để kiểm tra IS NULL?

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.amount
FROM customer c
LEFT JOIN payment p ON p.customer_id = c.customer_id
WHERE 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.amount
FROM customer c
LEFT 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_b
FROM basket_a a
RIGHT JOIN basket_b b ON a.fruit = b.fruit;
 id_a | fruit_a | id_b |  fruit_b
------+---------+------+------------
    2 | Orange  |    1 | Orange
    1 | Apple   |    2 | Apple
      |         |    3 | Watermelon
      |         |    4 | Pear
(4 rows)

Thực tế: hầu như không ai dùng RIGHT JOIN

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_b
FROM basket_a a
FULL OUTER JOIN basket_b b ON a.fruit = b.fruit;
 id_a | fruit_a  | id_b |  fruit_b
------+----------+------+------------
    1 | Apple    |    2 | Apple
    2 | Orange   |    1 | Orange
    3 | Banana   |      |
    4 | Cucumber |      |
      |          |    3 | Watermelon
      |          |    4 | Pear
(6 rows)

6.1. Tìm dòng chỉ có ở một bên (full anti-join)

SELECT a.fruit AS chi_co_o_a, b.fruit AS chi_co_o_b
FROM basket_a a
FULL OUTER JOIN basket_b b ON a.fruit = b.fruit
WHERE a.id IS NULL OR b.id IS NULL;
 chi_co_o_a | chi_co_o_b
------------+------------
 Banana     |
 Cucumber   |
            | Watermelon
            | Pear
(4 rows)

Đây chính là kỹ thuật dùng để so sánh hai bảng xem chúng khác nhau ở đâu. → Công thức thực dụng - EXPLAIN, xóa trùng, so sánh bảng


7. CROSS JOIN

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 JOIN
SELECT * 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_thu
FROM store s
CROSS 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.thang
GROUP BY s.store_id, m.thang
ORDER 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.length
FROM category c
CROSS 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 top
ORDER 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;
 category_id | product_id |  product_name   | category_name
-------------+------------+-----------------+---------------
           1 |          1 | iPhone          | Smart Phone
           1 |          2 | Samsung Galaxy  | Smart Phone
           2 |          3 | HP Elite        | Laptop
...

Tương đương với:

SELECT * FROM products
INNER 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_ly
FROM employee e
LEFT JOIN employee m ON m.employee_id = e.manager_id
ORDER 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.length
FROM film f1
INNER 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.length
ORDER BY f1.length DESC, f1.title
LIMIT 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ạiDòng bảng tráiDòng bảng phảiCách nhớ
INNER JOINChỉ khi khớpChỉ khi khớpPhần giao
LEFT JOINTất cảChỉ khi khớpToàn bộ trái
RIGHT JOINChỉ khi khớpTất cảToàn bộ phải
FULL OUTER JOINTất cảTất cảHợp của cả hai
CROSS JOINMọi tổ hợpMọi tổ hợpNhân m × n
NATURAL JOINTheo cột trùng tênTheo 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:

  1. 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).
  2. Đế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.
  3. Tìm các diễn viên chưa từng đóng phim nào trong CSDL.
  4. Liệt kê tên khách hàng cùng thành phố và quốc gia của họ.
  5. Tìm các cặp diễn viên cùng họ (last_name), không lặp cặp.
  6. Với mỗi thể loại, lấy phim có giá thuê cao nhất (dùng LATERAL).
  7. 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.amount
    FROM customer c
    LEFT JOIN payment p ON p.customer_id = c.customer_id
    WHERE p.payment_date >= '2007-01-01';

12. Tóm tắt

  • INNER JOIN giữ phần giao; LEFT JOIN giữ trọn bảng trái; FULL OUTER JOIN giữ cả hai.
  • Anti-join = LEFT JOIN + WHERE khoa_chinh_phai IS NULL — cách tìm dòng không khớp.
  • Với OUTER JOIN, điều kiện về bảng phải phải nằm trong ON, không phải WHERE.
  • CROSS JOIN nhân m × n dòng; dùng có chủ đích với generate_series hoặc LATERAL.
  • Tránh NATURAL JOIN — nó nối theo mọi cột trùng tên, kể cả last_update.
  • Self-join cần bí danh; điều kiện id1 > id2 để tránh cặp trùng lặp.
  • Self-join chỉ đi một cấp; cây phân cấp cần WITH RECURSIVE → CTE và truy vấn đệ quy.

← Lọc dữ liệu - WHERE, LIMIT, IN, BETWEEN, LIKE, IS NULL | → Nhóm dữ liệu - GROUP BY, HAVING, hàm tổng hợp