Nối nhiều bảng
Bạn sẽ học được
- Nối ba bảng trở lên trong một câu lệnh.
- Đọc kết quả nối nhiều bảng và xác định hạt (mỗi dòng đại diện cho cái gì).
- Tính doanh thu theo danh mục từ dữ liệu trải trên ba bảng.
- Nhận ra nguy cơ nhân đôi khi nối hai quan hệ một-nhiều cùng lúc.
- Chọn thứ tự nối và điều kiện nối dễ đọc.
Khái niệm cốt lõi
Câu hỏi thật thường cần ba bảng: khách mua gì, sản phẩm thuộc nhóm nào, đơn hàng khi nào. Mỗi bảng giữ một phần sự thật, và JOIN nối chúng lại từng bước một:
SELECT c.name, o.id AS don, p.name AS san_pham, oi.quantity
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
ORDER BY o.id, p.name;Ba lần JOIN, bốn bảng. Đọc từ trên xuống: bắt đầu từ khách, nối đơn của khách, nối các món của đơn, nối thông tin sản phẩm của món. Kết quả 29 dòng — đúng bằng số dòng trong order_items, vì đó là hạt cuối cùng của chuỗi nối. Nếu đọc kết quả này như "danh sách khách", bạn sẽ thấy mỗi khách lặp lại nhiều lần; thực ra mỗi dòng là một món hàng.
Hạt (grain) là khái niệm quan trọng nhất khi nối nhiều bảng. Trước khi viết SUM hay COUNT, hãy trả lời: một dòng kết quả đại diện cho gì? Trong câu trên là một dòng hàng. Vì vậy:
COUNT(*)đếm số dòng hàng (29).COUNT(DISTINCT o.id)đếm số đơn (16).COUNT(DISTINCT c.id)đếm số khách có mua (11).SUM(oi.quantity * oi.unit_price)là tổng doanh thu (31.230.000), vì mỗi dòng hàng đóng góp đúng một lần.
Cả bốn con số đều đúng — cho bốn câu hỏi khác nhau. Không có con số nào "sai" nếu bạn biết mình đang đếm gì.
Thứ tự JOIN không ảnh hưởng kết quả với INNER JOIN (optimizer tự sắp xếp lại), nhưng ảnh hưởng khả năng đọc. Quy ước dễ theo: đi từ bảng "một" đến bảng "nhiều" theo khóa ngoại — customers → orders → order_items → products. Mỗi bước thêm một cấp chi tiết.
Điều kiện nối phải bám đúng khóa ngoại:
| Từ | Đến | Điều kiện |
|---|---|---|
orders | customers | o.customer_id = c.id |
order_items | orders | oi.order_id = o.id |
order_items | products | oi.product_id = p.id |
Sai một dòng trong bảng này là nguồn gốc của những kết quả vô lý: ví dụ nối order_items với customers qua oi.product_id = c.id vẫn chạy (vì cả hai là số), nhưng ghép món hàng với khách có id trùng số sản phẩm. PostgreSQL không biết ý định của bạn; nó chỉ kiểm tra kiểu dữ liệu.
Nguy cơ lớn nhất của nối nhiều bảng: hai quan hệ một-nhiều cùng lúc. Giả sử bạn nối orders với order_items (một đơn nhiều món) và cũng nối orders với một bảng payments (một đơn nhiều lần thanh toán). Mỗi đơn có 3 món và 2 lần thanh toán sẽ tạo 3 × 2 = 6 dòng. Khi đó SUM(oi.quantity * oi.unit_price) bị nhân đôi (mỗi món tính hai lần) và SUM(payment.amount) cũng bị nhân ba. Đây là lỗi âm thầm nguy hiểm nhất trong phân tích dữ liệu.
Cách xử lý: tính tổng ở từng nhánh rồi mới nối kết quả đã gom nhóm, hoặc dùng truy vấn con. Ví dụ:
SELECT o.id, tong_mon.tong_tien
FROM orders o
JOIN (
SELECT order_id, SUM(quantity * unit_price) AS tong_tien
FROM order_items
GROUP BY order_id
) tong_mon ON tong_mon.order_id = o.id;Trong khóa học này, dữ liệu retail chỉ có một nhánh một-nhiều (order_items), nên nối ba bảng vẫn an toàn. Nhưng khi gặp mô hình dữ liệu thật với nhiều bảng chi tiết, hãy nhớ quy tắc: nối xong rồi cộng là công thức của lỗi nhân bản.
Ví dụ chạy được
Đơn 13 dưới dạng hoá đơn đầy đủ:
SELECT c.name AS khach, p.name AS san_pham,
oi.quantity AS so_luong, oi.unit_price AS don_gia,
oi.quantity * oi.unit_price AS thanh_tien
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.id = 13;Ba dòng: Bàn phím cơ 1 × 1.250.000; Tai nghe Bluetooth 2 × 890.000; Áo thun cotton 4 × 250.000. Thành tiền lần lượt 1.250.000, 1.780.000, 1.000.000 — tổng 4.030.000. Đây là kết quả của nhiều năm kinh nghiệm đọc SQL: nhìn một dòng, biết nó đến từ bảng nào và đại diện cho cái gì.
Doanh thu theo danh mục:
SELECT p.category,
COUNT(DISTINCT o.id) AS so_don,
SUM(oi.quantity * oi.unit_price) AS doanh_thu
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY p.category
ORDER BY doanh_thu DESC;Bốn nhóm: Điện tử 15.180.000; Gia dụng 11.820.000; Thời trang 2.250.000; Sách 1.980.000. Tổng 31.230.000. Cột so_don dùng COUNT(DISTINCT o.id) vì cùng một đơn có thể chứa nhiều sản phẩm trong cùng danh mục — nếu dùng COUNT(*), bạn đếm dòng hàng chứ không phải đơn.
Top 5 khách theo chi tiêu:
SELECT c.name, SUM(oi.quantity * oi.unit_price) AS chi_tieu
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.id, c.name
ORDER BY chi_tieu DESC
LIMIT 5;Năm dòng: Bình Trần 6.750.000; Giang Đặng 5.490.000; Chi Lê 4.270.000; Minh Dương 4.030.000; An Nguyễn 3.530.000. Lưu ý GROUP BY c.id, c.name — gom theo cả id để hai khách trùng tên không bị gộp. Đây là thói quen tốt, dù dữ liệu hiện tại chưa có tên trùng.
Sản phẩm bán chạy theo số lượng:
SELECT p.name,
SUM(oi.quantity) AS tong_so_luong,
COUNT(DISTINCT o.id) AS so_don
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN orders o ON o.id = oi.order_id
GROUP BY p.id, p.name
ORDER BY tong_so_luong DESC, p.name
LIMIT 3;Ba dòng đầu: Sách SQL 11 cuốn; Áo thun 9; Tai nghe 7. Hai chỉ số khác nhau: một sản phẩm bán 11 cuốn có thể nằm trong 4 đơn, trong khi sản phẩm khác bán 7 chiếc trong 5 đơn. Cả hai đều hữu ích, tùy câu hỏi.
Doanh thu theo tháng:
SELECT EXTRACT(MONTH FROM o.order_date) AS thang,
SUM(oi.quantity * oi.unit_price) AS doanh_thu
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY EXTRACT(MONTH FROM o.order_date)
ORDER BY thang;Mười hai tháng, nhưng vài tháng không có đơn nên không xuất hiện. Nếu cần đủ 12 tháng kể cả tháng trống, phải dùng bảng lịch và LEFT JOIN — chủ đề của các khóa nâng cao.
Một câu sai để thấy lỗi nhân bản. Giả sử nối thêm bảng khách hai lần không cần thiết:
SELECT SUM(oi.quantity * oi.unit_price) AS doanh_thu
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN order_items oi2 ON oi2.order_id = o.id;Kết quả lớn hơn 31.230.000 nhiều lần vì mỗi cặp món trong cùng đơn tạo một dòng. Câu này vô nghĩa về nghiệp vụ, nhưng minh họa chính xác điều gì xảy ra khi bạn nối hai bảng một-nhiều cùng gốc: số dòng bị nhân, và mọi phép tổng theo sau đều sai.
Thực hành
- Viết hoá đơn đầy đủ cho đơn 1. Hai dòng: Tai nghe 2 × 890.000 và Bình giữ nhiệt 1 × 320.000; tổng 2.100.000.
- Tính doanh thu theo thành phố của khách. Kết quả: Cần Thơ 9.520.000; TP. Hồ Chí Minh 8.930.000; Hà Nội 7.320.000; Đà Nẵng 5.460.000; Hải Phòng 0. Tổng phải bằng 31.230.000.
- Tính số lượng bán theo danh mục. Kết quả: Điện tử 18, Gia dụng 16, Sách 11, Thời trang 9. So sánh Sách và Thời trang: Sách bán nhiều hơn (11 so với 9) nhưng doanh thu thấp hơn (1.980.000 so với 2.250.000) — "bán nhiều" và "thu nhiều" là hai câu hỏi khác nhau.
- Thêm
COUNT(DISTINCT c.id)vào truy vấn doanh thu theo danh mục và giải thích vì sao con số này nhỏ hơnCOUNT(DISTINCT o.id).
Lỗi thường gặp
| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
| Tổng doanh thu lớn hơn thực tế | Nối hai bảng một-nhiều cùng lúc | Tính tổng theo nhánh trước khi nối |
| Đếm đơn ra số dòng hàng | COUNT(*) sau khi nối order_items | COUNT(DISTINCT o.id) |
| Kết quả không có dòng nào | Một ON sai khóa | Kiểm tra từng điều kiện theo bảng khóa ngoại |
| Hai khách trùng tên bị gộp | GROUP BY name | GROUP BY c.id, c.name |
| Câu lệnh dài khó đọc | Không xuống dòng, không bí danh | Mỗi JOIN một dòng, bí danh ngắn |
Lỗi "đếm đơn ra số dòng hàng" xuất hiện ở hầu hết báo cáo viết vội. Cách phòng: trước khi viết COUNT, đọc lại danh sách JOIN và tự hỏi mỗi dòng kết quả đại diện cho gì. Nếu câu trả lời là "một món hàng" thì đếm đơn phải dùng DISTINCT.
Tóm tắt
- Nối nhiều bảng bằng cách đi theo khóa ngoại từ bảng một đến bảng nhiều.
- Hạt của kết quả là bảng chi tiết nhất trong chuỗi nối; mọi phép đếm phụ thuộc vào nó.
COUNT(DISTINCT id)khi cần đếm thực thể ở bảng cha.- Nối hai quan hệ một-nhiều cùng lúc sẽ nhân dòng; tính tổng theo nhánh trước.
- Gom nhóm theo cả
idvà tên để tránh gộp nhầm thực thể trùng tên.
Dùng ← → để chuyển bài