Skip to content

JOIN Bằng Trực Giác 🔗

Data trong production database luôn bị tách ra nhiều bảng (normalization). Khách hàng 1 bảng, đơn hàng 1 bảng, sản phẩm 1 bảng. JOIN là cách duy nhất để "lắp ghép" chúng lại — giống như ghép các mảnh puzzle thành bức tranh hoàn chỉnh.


🧠 Mental Model: JOIN = Matching

Cách đơn giản nhất để hiểu JOIN: với mỗi dòng ở bảng A, tìm dòng khớp ở bảng B dựa trên điều kiện ON.

Tưởng tượng chấm bài thi: Bảng A = danh sách sinh viên, Bảng B = bài thi. Dò từng SV xem có bài thi khớp không:

Danh sách SV          Bài thi
┌──────┬─────────┐    ┌──────┬───────┐
│ mã   │ tên     │    │ mã   │ điểm  │
├──────┼─────────┤    ├──────┼───────┤
│ SV01 │ Minh    │───→│ SV01 │ 8.5   │  ✅ Khớp!
│ SV02 │ Lan     │───→│ SV02 │ 9.0   │  ✅ Khớp!
│ SV03 │ Hùng    │╌╌╌→│  ?   │  ?    │  ❌ Không có bài
│      │         │    │ SV99 │ 7.0   │  ❌ Không có SV
└──────┴─────────┘    └──────┴───────┘

Câu hỏi cốt lõi: khi không khớp thì xử lý sao? Đó là sự khác biệt giữa các loại JOIN:

Tình huốngINNERLEFTRIGHTFULL
SV có bài (SV01, SV02)
SV không có bài (SV03)
Bài không có SV (SV99)

🔵 INNER JOIN — Chỉ lấy phần giao

Chỉ trả về dòng có match ở CẢ HAI bảng. Không match = biến mất.

         ┌───────────┐  ┌───────────┐
         │  Users     │  │  Orders   │
         │           ┌┼──┼┐          │
         │   User A  ││  ││ Order 1  │
         │           ││✅││ Order 2  │
         │   User B  │├──┤│          │
         │   (no     ││  ││ Order 3  │
         │   orders) ├┼──┼┤ (no user)│
         └───────────┘│  │└──────────┘
                      └──┘
                    KẾT QUẢ
sql
-- INNER JOIN: Chỉ user CÓ đơn hàng
SELECT u.name, o.id AS order_id, o.total
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- User B (no orders) → BIẾN MẤT
-- Order 3 (no user) → BIẾN MẤT
users                       orders
┌────┬────────┐             ┌────┬─────────┬─────────┐
│ id │ name   │             │ id │ user_id │ total   │
├────┼────────┤             ├────┼─────────┼─────────┤
│  1 │ Minh   │             │ 10 │    1    │ 250000  │
│  2 │ Lan    │             │ 11 │    1    │ 180000  │
│  3 │ Hùng   │             │ 12 │    3    │ 500000  │
│  4 │ Trang  │ ← no order  │ 13 │   99    │  50000  │ ← no user
└────┴────────┘             └────┴─────────┴─────────┘

INNER JOIN → 
┌────────┬──────────┬─────────┐
│ name   │ order_id │ total   │
├────────┼──────────┼─────────┤
│ Minh   │    10    │ 250000  │
│ Minh   │    11    │ 180000  │
│ Hùng   │    12    │ 500000  │
└────────┴──────────┴─────────┘
Trang biến mất, Order 13 biến mất.

💡 Mẹo

JOININNER JOIN là như nhau — keyword INNER optional. Nhưng viết rõ INNER JOIN giúp code dễ đọc hơn khi query có cả LEFT JOIN lẫn INNER JOIN.


🟢 LEFT JOIN — Giữ tất cả bên trái

Giữ tất cả dòng bảng trái. Không match ở bảng phải → NULL.

         ┌────────────┐  ┌───────────┐
         │  Users      │  │  Orders   │
         │  ┌─────────┐│  │           │
         │  │ Minh    ├┼──┼→ Order 10 │
         │  │ Minh    ├┼──┼→ Order 11 │
         │  │ Hùng    ├┼──┼→ Order 12 │
         │  │ Trang   ├┼╌╌┼→ NULL     │  ← Vẫn giữ Trang!
         │  │ Lan     ├┼╌╌┼→ NULL     │  ← Vẫn giữ Lan!
         │  └─────────┘│  │  Order 13 │  ← Bị loại (no user match)
         └─────────────┘  └───────────┘
sql
-- LEFT JOIN: TẤT CẢ users, có đơn hàng hay không
SELECT u.name, o.id AS order_id, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
-- Trang → name='Trang', order_id=NULL, total=NULL

Use case phổ biến — Tìm dữ liệu "chưa có":

sql
-- Tìm users CHƯA BAO GIỜ mua hàng
SELECT u.id, u.name, u.email
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

Lưu ý

Khi dùng LEFT JOIN ... WHERE IS NULL, check cột từ bảng phải — thường là PK hoặc FK. Đừng check cột có thể NULL sẵn.


🟠 RIGHT JOIN — Ít dùng, nhưng phải biết

Hình ảnh phản chiếu của LEFT JOIN: giữ tất cả bảng phải, trái không match → NULL.

sql
-- RIGHT JOIN
SELECT u.name, o.id AS order_id
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;

-- Tương đương 100% — chỉ đổi thứ tự bảng:
SELECT u.name, o.id AS order_id
FROM orders o
LEFT JOIN users u ON u.id = o.user_id;

💡 Quy ước trong team

Hầu hết team chỉ dùng LEFT JOIN và hoán đổi thứ tự bảng thay vì dùng RIGHT JOIN. Bảng "chính" luôn ở FROM → dễ đọc hơn.


🟣 FULL OUTER JOIN — Lấy tất cả

Giữ tất cả dòng từ cả hai bảng. Không match bên nào → NULL bên đó.

         ┌────────────┐  ┌────────────┐
         │  Users      │  │  Orders    │
         │ ┌──────────┐│  │┌──────────┐│
         │ │ Minh     ├┼──┼┤ Order 10 ││  ← match
         │ │ Hùng     ├┼──┼┤ Order 12 ││  ← match
         │ │ Trang    ├┼╌╌┼┤          ││  ← NULL bên phải
         │ │          ├┼╌╌┼┤ Order 13 ││  ← NULL bên trái
         │ └──────────┘│  │└──────────┘│
         └─────────────┘  └────────────┘
           GIỮ TẤT CẢ      GIỮ TẤT CẢ
sql
SELECT u.name, o.id AS order_id, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;

Lưu ý

MySQL không hỗ trợ FULL OUTER JOIN. Workaround: LEFT JOIN UNION RIGHT JOIN.


✖️ CROSS JOIN — Tích Descartes

Mỗi dòng bảng A ghép với tất cả dòng bảng B. Không cần ON.

sizes       colors         KẾT QUẢ
┌─────┐    ┌────────┐     ┌─────┬────────┐
│  S  │───→│  Đỏ    │     │  S  │  Đỏ    │
│     │───→│  Xanh  │     │  S  │  Xanh  │
│     │───→│  Trắng │     │  S  │  Trắng │
├─────┤    └────────┘     │  M  │  Đỏ    │
│  M  │───→  ...          │  M  │  Xanh  │
├─────┤                   │  M  │  Trắng │
│  L  │───→  ...          │  L  │  Đỏ    │
└─────┘                   │  L  │  Xanh  │
3 × 3 = 9 dòng           │  L  │  Trắng │
                          └─────┴────────┘
sql
-- Tạo bảng giá cho mọi combo size × color
SELECT s.size_name, c.color_name
FROM sizes s
CROSS JOIN colors c;

🚨 Cảnh báo

1,000 × 5,000 = 5,000,000 dòng! Chỉ dùng CROSS JOIN khi bạn cố ý muốn tạo mọi tổ hợp (combo sản phẩm, lịch báo cáo).


🔄 Self JOIN — Bảng tự kết hợp chính mình

Ví dụ kinh điển: sơ đồ tổ chức — manager_id trỏ về id cùng bảng.

employees (e)                     employees (m)
┌────┬────────┬────────────┐      ┌────┬────────┐
│ id │ name   │ manager_id │      │ id │ name   │
├────┼────────┼────────────┤      ├────┼────────┤
│  1 │ Sếp An │    NULL    │      │  1 │ Sếp An │
│  2 │ Bình   │      1     │─────→│    │        │
│  3 │ Châu   │      1     │─────→│    │        │
│  4 │ Dũng   │      2     │      │    │        │
└────┴────────┴────────────┘      └────┴────────┘
sql
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- LEFT JOIN để giữ CEO (manager_id = NULL)

🏗️ Multi-Table JOIN — 3+ bảng

Query e-commerce điển hình JOIN 4-5 bảng: orders → users + order_items → products → categories.

sql
SELECT 
    o.id AS order_id,
    u.name AS customer,
    p.name AS product,
    c.name AS category,
    oi.quantity,
    oi.unit_price
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
JOIN categories c ON p.category_id = c.id
WHERE o.created_at >= '2024-01-01';

💡 Đọc multi-table JOIN

Đọc từ FROM xuống: bắt đầu từ orders, ghép users (ai đặt?), ghép order_items (mua gì?), ghép products (sản phẩm nào?), ghép categories (loại nào?). Mỗi JOIN = thêm một lớp thông tin.


🎯 ON vs WHERE trong JOIN

Điểm hay nhầm nhất — đặc biệt với LEFT JOIN:

sql
-- Lọc trong ON: ảnh hưởng JOIN matching
SELECT u.name, o.id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid';
-- Users without paid orders → vẫn xuất hiện với NULL

-- Lọc trong WHERE: lọc SAU JOIN
SELECT u.name, o.id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'paid';
-- Users without paid orders → BIẾN MẤT (NULL ≠ 'paid')
┌──────────────────────────────────────────────────────┐
│  ON  → Quyết định dòng nào được MATCH               │
│  WHERE → Quyết định dòng nào GIỮ trong kết quả      │
│                                                      │
│  INNER JOIN: ON và WHERE → kết quả GIỐNG nhau        │
│  LEFT JOIN:  ON và WHERE → kết quả KHÁC nhau!        │
└──────────────────────────────────────────────────────┘

🚨 Lỗi kinh điển

Đặt filter bảng phải vào WHERE khi dùng LEFT JOIN → biến LEFT JOIN thành INNER JOIN. Query vẫn chạy, nhưng sai kết quả.


🏋️ Bài tập nhanh

Tìm tất cả khách hàng chưa bao giờ đặt hàng:

💡 Gợi ý

Dùng LEFT JOIN giữ tất cả users, rồi WHERE lọc dòng mà cột orders là NULL.

✅ Đáp án
sql
SELECT u.id, u.name, u.email
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

LEFT JOIN giữ tất cả users → users không có orders có o.id = NULLWHERE o.id IS NULL lọc đúng.

Đếm đơn hàng mỗi khách (kể cả 0 đơn):

✅ Đáp án
sql
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;
-- COUNT(o.id) → bỏ qua NULL = 0 đơn ✅
-- COUNT(*)    → đếm cả NULL = 1 đơn ❌

⚠️ Gotcha: Accidental CROSS JOIN

sql
-- ❌ Quên điều kiện ON → CROSS JOIN ngầm!
SELECT u.name, o.total
FROM users u, orders o;  -- 1000 users × 5000 orders = 5M rows!

-- ✅ Luôn dùng explicit JOIN syntax
SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON u.id = o.user_id;

Cú pháp cũ FROM a, b WHERE ... dễ vô tình tạo CROSS JOIN nếu quên WHERE. Luôn dùng explicit JOIN.


Ghi chú hiệu năng

  • Index JOIN columns: FK columns PHẢI có index — không có thì scan toàn bộ bảng
    sql
    CREATE INDEX idx_orders_user_id ON orders(user_id);
  • JOIN order: Optimizer thường tự chọn, nhưng lọc sớm (WHERE trên bảng đầu) giảm dòng phải JOIN
  • Tránh JOIN trên computed expressions: ON LOWER(a.email) = LOWER(b.email) không dùng được index
  • Chỉ SELECT cột cần thiết: SELECT * từ 4 bảng = lãng phí memory và bandwidth

🚫 Anti-pattern: Correlated Subquery thay JOIN

sql
-- 🚫 O(n²): Chạy subquery cho MỖI dòng
SELECT name,
    (SELECT COUNT(*) FROM orders WHERE orders.user_id = users.id) AS order_count
FROM users;

-- ✅ O(n): Một lần JOIN + GROUP BY
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

10,000 users × 50,000 orders: correlated subquery ~5-10s, JOIN ~50-100ms. Chênh lệch 50-100x.


🎮 Playground

sql
-- ============================================
-- PLAYGROUND: JOIN Trực Giác
-- ============================================

CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    email VARCHAR(100),
    city VARCHAR(50)
);

CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    user_id INTEGER,
    total DECIMAL(12, 0),
    status VARCHAR(20),
    created_at DATE
);

CREATE TABLE products (
    id INTEGER PRIMARY KEY,
    name VARCHAR(100),
    price DECIMAL(12, 0),
    category VARCHAR(50)
);

CREATE TABLE order_items (
    id INTEGER PRIMARY KEY,
    order_id INTEGER,
    product_id INTEGER,
    quantity INTEGER,
    unit_price DECIMAL(12, 0)
);

INSERT INTO users (id, name, email, city) VALUES
(1, 'Nguyễn Minh',  'minh@email.com',  'Hà Nội'),
(2, 'Trần Lan',     'lan@email.com',   'TP.HCM'),
(3, 'Lê Hùng',      'hung@email.com',  'Đà Nẵng'),
(4, 'Phạm Trang',   'trang@email.com', 'Hà Nội'),
(5, 'Hoàng Dũng',   'dung@email.com',  'Cần Thơ');

INSERT INTO orders (id, user_id, total, status, created_at) VALUES
(10, 1,  750000,  'paid',    '2024-03-15'),
(11, 1,  890000,  'pending', '2024-04-02'),
(12, 3,  1200000, 'paid',    '2024-04-10'),
(13, 3,  350000,  'pending', '2024-05-01'),
(14, 99, 50000,   'paid',    '2024-05-15');  -- orphan order

INSERT INTO products (id, name, price, category) VALUES
(101, 'Áo thun basic',  150000,  'Thời trang'),
(102, 'Quần jeans',     450000,  'Thời trang'),
(103, 'Tai nghe BT',    890000,  'Điện tử'),
(104, 'Bàn phím cơ',    1200000, 'Điện tử'),
(105, 'Sổ tay A5',      35000,   'Văn phòng phẩm');

INSERT INTO order_items (id, order_id, product_id, quantity, unit_price) VALUES
(1, 10, 101, 2, 150000),
(2, 10, 102, 1, 450000),
(3, 11, 103, 1, 890000),
(4, 12, 104, 1, 1200000),
(5, 13, 105, 10, 35000);

-- 1️⃣ INNER JOIN: Chỉ users CÓ đơn hàng
SELECT u.name, u.city, o.id AS order_id, o.total, o.status
FROM users u
INNER JOIN orders o ON u.id = o.user_id
ORDER BY u.name, o.id;

-- 2️⃣ LEFT JOIN: Tất cả users, kể cả chưa mua
SELECT u.name, u.city, o.id AS order_id, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
ORDER BY u.name, o.id;

-- 3️⃣ Tìm users CHƯA mua hàng
SELECT u.id, u.name, u.email
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

-- 4️⃣ Multi-table JOIN: Chi tiết đơn hàng
SELECT 
    o.id AS order_id,
    u.name AS customer,
    p.name AS product,
    p.category,
    oi.quantity,
    oi.unit_price,
    (oi.quantity * oi.unit_price) AS line_total
FROM orders o
JOIN users u        ON o.user_id = u.id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p     ON oi.product_id = p.id
ORDER BY o.id, p.name;

-- 5️⃣ Đếm đơn hàng + tổng chi (kể cả 0)
SELECT u.name, COUNT(o.id) AS order_count, COALESCE(SUM(o.total), 0) AS total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name
ORDER BY total_spent DESC;

-- 6️⃣ ON vs WHERE: so sánh kết quả
-- a) Lọc trong ON (giữ tất cả users)
SELECT u.name, o.id AS order_id, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid';

-- b) Lọc trong WHERE (loại users không có paid)
SELECT u.name, o.id AS order_id, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'paid';

🧩 Parsons Problem

Sắp xếp lại để tạo query "Tìm sản phẩm chưa từng được đặt hàng":

🧩 Parsons Problem

LEFT JOIN order_items oi ON p.id = oi.product_id
SELECT p.id, p.name, p.price
FROM products p
WHERE oi.id IS NULL
ORDER BY p.name;

Đáp án:

sql
SELECT p.id, p.name, p.price
FROM products p
LEFT JOIN order_items oi ON p.id = oi.product_id
WHERE oi.id IS NULL
ORDER BY p.name;

Quiz

🧠 Quiz

Câu 1: INNER JOIN khi không match?

Dòng ở bảng A không có match ở bảng B, INNER JOIN sẽ:

  • [ ] A. Xuất hiện với NULL ở cột bảng B
  • [x] B. Hoàn toàn biến mất khỏi kết quả
  • [ ] C. Database báo lỗi
  • [ ] D. Xuất hiện với giá trị mặc định

Giải thích: INNER JOIN chỉ trả dòng match cả hai bảng. Muốn giữ → dùng LEFT JOIN.

🧠 Quiz

Câu 2: LEFT JOIN + WHERE bảng phải

sql
SELECT u.name, o.id FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'paid';

Query này tương đương:

  • [x] A. INNER JOIN (chỉ users có paid orders)
  • [ ] B. LEFT JOIN thông thường
  • [ ] C. CROSS JOIN
  • [ ] D. FULL OUTER JOIN

Giải thích: WHERE o.status = 'paid' loại dòng NULL → users không có orders bị loại → giống INNER JOIN.

🧠 Quiz

Câu 3: COUNT(*) vs COUNT(column)

sql
SELECT u.name, COUNT(*) AS cnt FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

User "Lan" không có đơn hàng. cnt của Lan = ?

  • [ ] A. 0
  • [x] B. 1
  • [ ] C. NULL

Giải thích: COUNT(*) đếm số dòng kể cả NULL. LEFT JOIN tạo 1 dòng cho Lan → COUNT(*) = 1. Dùng COUNT(o.id) để được 0.


📊 Cheat Sheet

Bạn cần gì?                       → JOIN nào?
────────────────────────────────────────────────
Chỉ dữ liệu khớp cả 2 bảng       → INNER JOIN
Tất cả bảng trái + match          → LEFT JOIN
Tìm "chưa có" (no match)          → LEFT JOIN + WHERE IS NULL
Tất cả từ cả 2 bảng               → FULL OUTER JOIN
Mọi tổ hợp (n × m)                → CROSS JOIN
Bảng tự tham chiếu                → Self JOIN

📍 Trang tiếp theo

TrangNội dung
05 - CTE + Window FunctionsChia query phức tạp thành từng bước
🏋️ Practice: JOINsBài tập thực hành JOIN
← 03 - GROUP BYQuay lại bài trước