Bài 4 tổng hợp dữ liệu bằng GROUP BY. Nhưng nhiều
câu hỏi kinh doanh thật cần
1 câu lệnh SQL tham chiếu tới kết quả của 1 câu lệnh SQL khác — "đơn nào có giá trị
lớn hơn trung bình?", "khách nào chưa từng mua Electronics?". Đây là lúc cần
subquery (truy vấn con) hoặc CTE (Common Table Expression, khai báo
bằng WITH). Bài này cũng có 1 trong những cạm bẫy SQL nổi tiếng nhất —
NOT IN kết hợp với subquery chứa NULL — âm thầm trả về sai mà không hề báo
lỗi.
1. Subquery Vô Hướng (Scalar Subquery): Trả Về Đúng 1 Giá Trị
Subquery vô hướng là 1 câu SELECT đặt lồng bên trong câu lệnh khác, được đảm bảo trả về
đúng 1 hàng, 1 cột (nếu trả nhiều hơn, SQLite báo lỗi runtime). Nó có thể xuất hiện ở
bất kỳ đâu 1 giá trị đơn được chấp nhận — trong WHERE, hay ngay trong
SELECT.
Tình huống 1 — So sánh với trung bình toàn cục: tìm đơn hàng "trên trung bình".
SELECT order_id, total_amount
FROM orders
WHERE total_amount > (SELECT AVG(total_amount) FROM orders)
ORDER BY total_amount DESC;
Kết quả có 10/28 đơn vượt mức trung bình ~128.27 đã tính ở Bài 4. Đây chính là điểm khác biệt subquery mang lại so với hardcode số 128.27 trực tiếp: nếu dữ liệu thay đổi (thêm đơn mới), ngưỡng so sánh tự động cập nhật theo, không cần sửa câu lệnh.
Tình huống 2 — Đưa subquery vào ngay trong SELECT: tính % đóng góp của
từng đơn so với tổng doanh thu.
SELECT order_id, total_amount,
ROUND(total_amount * 100.0 / (SELECT SUM(total_amount) FROM orders), 2) AS pct_of_total
FROM orders
ORDER BY total_amount DESC
LIMIT 5;
Đơn giá trị cao nhất (349.99, xuất hiện 2 lần trong dataset) chiếm 9.74% tổng doanh thu. Subquery ở đây chạy đúng 1 lần (không phụ thuộc hàng ngoài), rồi giá trị đó được dùng lại cho mọi hàng — khác hẳn với subquery tương quan sẽ học ở mục 3.
Tình huống 3 — Tìm sản phẩm đắt nhất bằng subquery thay vì ORDER BY LIMIT 1:
SELECT product_name, unit_price
FROM products
WHERE unit_price = (SELECT MAX(unit_price) FROM products);
Kết quả: Standing Desk, 349.99. Cách viết này khác
ORDER BY unit_price DESC LIMIT 1 ở 1 điểm quan trọng: nếu có
2 sản phẩm đồng giá cao nhất, subquery WHERE unit_price = (...) trả về
cả 2, trong khi LIMIT 1 chỉ trả về 1 (bỏ sót sản phẩm còn lại). Chọn
cách nào tuỳ ý định: "top 1 hàng" hay "mọi hàng đạt giá trị cao nhất".
2. Subquery Trong WHERE: IN, EXISTS, và Cạm Bẫy
NOT IN
IN (subquery) kiểm tra 1 giá trị có nằm trong tập kết quả subquery hay không.
EXISTS (subquery) chỉ kiểm tra subquery có trả về hàng nào không
(không quan tâm giá trị cụ thể) — thường hiệu quả hơn với subquery tương quan lớn vì engine có thể
dừng ngay khi tìm thấy 1 hàng khớp đầu tiên.
Tình huống 1 — Khách đã từng mua Electronics (IN):
SELECT DISTINCT full_name
FROM customers
WHERE customer_id IN (
SELECT o.customer_id
FROM orders o
INNER JOIN order_items oi ON o.order_id = oi.order_id
INNER JOIN products p ON oi.product_id = p.product_id
WHERE p.category = 'Electronics'
);
Kết quả: đúng 7 khách hàng — khớp chính xác với con số "7 khách khác nhau mua
Electronics" đã tính bằng COUNT(DISTINCT customer_id) + HAVING ở
Bài 4. Đây là 2 cách viết khác nhau cho cùng 1 câu hỏi kinh doanh
— subquery IN trả về danh sách tên, còn GROUP BY/HAVING trả về
con số đếm.
Tình huống 2 — Sản phẩm chưa từng được bán (NOT EXISTS):
SELECT product_name
FROM products p
WHERE NOT EXISTS (
SELECT 1 FROM order_items oi WHERE oi.product_id = p.product_id
);
Với dataset TechMart hiện tại, kết quả là 0 hàng — đúng như đã xác nhận ở Bài 3, mọi sản phẩm đều đã bán được ít nhất 1 lần. Cấu trúc truy vấn vẫn hoàn toàn đúng và cần thiết cho dữ liệu thật (sẽ thay đổi khi có sản phẩm mới).
NOT IN + subquery chứa NULL luôn trả về rỗng
-- Ý định: tìm khách CHƯA từng đặt đơn nào
SELECT full_name FROM customers c
WHERE c.customer_id NOT IN (SELECT customer_id FROM orders);
Với dataset hiện tại, cả NOT IN lẫn cách viết đúng NOT EXISTS bên dưới đều
trả về 0 hàng — nhưng là vì 2 lý do hoàn toàn khác nhau.
NOT EXISTS trả 0 hàng đúng vì mọi khách đều đã đặt đơn. Còn NOT IN ở trên
trả 0 hàng vì 1 lý do khác hẳn: subquery SELECT customer_id FROM orders có chứa
NULL (4 đơn khách vãng lai, xem lại Bài 3). Theo logic 3 trị đã học ở Bài 1,
x NOT IN (a, b, NULL) triển khai thành x != a AND x != b AND x != NULL —
và x != NULL luôn là UNKNOWN, khiến toàn bộ phép AND thành
UNKNOWN, bị WHERE loại bỏ. Kết quả:
NOT IN sẽ luôn trả về 0 hàng cho MỌI khách hàng, bất kể họ có thực sự chưa đặt đơn
hay không
— một khi subquery chứa dù chỉ 1 NULL. Tự kiểm chứng ngay trong sân chơi bên dưới: chèn
1 khách hàng mới chưa từng mua gì (INSERT INTO customers (customer_id, full_name, email, country, signup_date, is_active) VALUES
(99, 'Khách Test Chưa Mua', '[email protected]', 'Vietnam', '2026-04-01', 1);) rồi chạy lại cả 2 câu — NOT EXISTS sẽ đúng đắn hiện tên khách mới, còn
NOT IN vẫn ngoan cố trả về 0 hàng, im lặng bỏ sót. Cách sửa NOT IN: lọc
NULL ra khỏi subquery trước —
NOT IN (SELECT customer_id FROM orders WHERE customer_id IS NOT NULL). Nhưng quy tắc an
toàn nhất: luôn ưu tiên NOT EXISTS hơn NOT IN khi
subquery có khả năng chứa NULL.
3. Subquery Tương Quan (Correlated Subquery): Chạy Lại Cho Từng Hàng Ngoài
Khác với subquery vô hướng ở mục 1 (chạy đúng 1 lần), subquery tương quan tham chiếu tới cột của bảng ngoài — engine phải chạy lại nó cho mỗi hàng của bảng ngoài, giống 1 vòng lặp lồng nhau.
Tình huống 1 — Mỗi khách hàng kèm ngày đặt đơn gần nhất:
SELECT c.full_name,
(SELECT MAX(o.order_date) FROM orders o WHERE o.customer_id = c.customer_id) AS last_order_date
FROM customers c
ORDER BY last_order_date DESC;
Subquery WHERE o.customer_id = c.customer_id tham chiếu c.customer_id từ
bảng ngoài — với 12 khách hàng, subquery chạy 12 lần, mỗi lần lọc theo 1 khách khác
nhau. Kết quả: David Kim có đơn gần nhất (2026-03-28), Phạm Quốc Việt có đơn
gần nhất xa nhất trong quá khứ (2025-12-12 — khách này chỉ có 1 đơn duy nhất, và đã
cancelled, đúng với is_active = 0 đã thấy ở Bài 4).
JOIN
tương đương trước khi thực thi, nếu có thể). Hiểu bản chất "chạy lại cho từng hàng" giúp nhận ra
ngay khi 1 subquery tương quan có nguy cơ chậm trên dữ liệu lớn, trước khi optimizer kịp tối ưu —
hoặc trong trường hợp optimizer không tối ưu được (subquery quá phức tạp).
Tình huống 2 — % doanh thu mỗi sản phẩm đóng góp trong chính danh mục của nó: kết hợp
subquery tương quan với subquery trong FROM (mục 4).
SELECT p.product_name, p.category, rev.product_revenue,
ROUND(rev.product_revenue * 100.0 / (
SELECT SUM(oi2.quantity * oi2.unit_price)
FROM order_items oi2
INNER JOIN products p2 ON oi2.product_id = p2.product_id
WHERE p2.category = p.category -- tương quan: tham chiếu p.category ở bảng ngoài
), 2) AS pct_of_category
FROM products p
INNER JOIN (
SELECT product_id, SUM(quantity * unit_price) AS product_revenue
FROM order_items
GROUP BY product_id
) rev ON p.product_id = rev.product_id
ORDER BY p.category, pct_of_category DESC;
Kết quả cho thấy Standing Desk chiếm 62.61% doanh thu danh mục
Office — 1 sản phẩm gánh phần lớn doanh thu cả danh mục, thông tin cực kỳ hữu ích cho
quyết định nhập hàng/marketing mà số liệu doanh thu tổng (Bài 4) không thể hiện được.
4. Subquery Trong FROM (Derived Table)
Query con ở mục 3 vừa dùng 1 derived table — subquery đặt ngay trong
FROM, được đối xử như 1 bảng tạm thời chỉ tồn tại trong câu lệnh đó (tương tự kỹ thuật
(SELECT DISTINCT category FROM products) đã dùng cho CROSS JOIN ở
Bài 3). Đây là công cụ hữu ích khi cần
tổng hợp trước rồi mới nối/lọc tiếp — tính product_revenue theo từng sản
phẩm trước (bên trong), rồi mới JOIN với products để lấy tên và tính phần
trăm (bên ngoài).
5. CTE (WITH): Đặt Tên Cho Subquery, Đọc Dễ Hơn
WITH tên_cte AS (subquery) khai báo 1 hoặc nhiều "bảng tạm có tên" trước câu lệnh chính —
về mặt kết quả, hoàn toàn tương đương derived table ở mục 4, nhưng
dễ đọc hơn nhiều khi subquery phức tạp hoặc được dùng lại nhiều lần. Viết lại đúng ví
dụ ở mục 3 bằng CTE:
WITH product_revenue AS (
SELECT product_id, SUM(quantity * unit_price) AS revenue
FROM order_items
GROUP BY product_id
),
category_revenue AS (
SELECT p.category, SUM(pr.revenue) AS total_revenue
FROM product_revenue pr
INNER JOIN products p ON pr.product_id = p.product_id
GROUP BY p.category
)
SELECT p.product_name, p.category, pr.revenue,
ROUND(pr.revenue * 100.0 / cr.total_revenue, 2) AS pct_of_category
FROM product_revenue pr
INNER JOIN products p ON pr.product_id = p.product_id
INNER JOIN category_revenue cr ON p.category = cr.category
ORDER BY p.category, pct_of_category DESC;
Kết quả giống hệt truy vấn ở mục 3, nhưng cấu trúc rõ ràng hơn hẳn: product_revenue tính
doanh thu từng sản phẩm, category_revenue tính tổng theo danh mục (tái sử dụng
product_revenue thay vì viết lại subquery tương quan lồng nhau), câu lệnh chính chỉ còn
việc JOIN và tính phần trăm — không còn subquery tương quan nào cả. Đây
chính là điểm CTE mạnh hơn subquery tương quan: tách từng bước tính toán thành 1 khối có tên riêng,
đọc theo thứ tự tự nhiên từ trên xuống thay vì phải "đọc từ trong ra ngoài" như subquery lồng nhau.
6. CTE Đệ Quy: Duyệt Cây Phân Cấp
TechMart hiện dùng danh mục phẳng (Electronics, Accessories...), không có
cấu trúc cây. Để minh hoạ CTE đệ quy — công cụ SQL duy nhất xử lý được dữ liệu phân cấp có độ sâu bất
kỳ — bài học tạo thêm 1 bảng category_tree ngay trong phiên làm việc (chỉ tồn tại cho bài
học này, không phải 1 phần cố định của dataset TechMart):
CREATE TABLE category_tree (
category_id INTEGER PRIMARY KEY,
category_name TEXT NOT NULL,
parent_id INTEGER
);
INSERT INTO category_tree VALUES
(1, 'Electronics', NULL), (2, 'Computers', 1), (3, 'Laptops', 2),
(4, 'Desktops', 2), (5, 'Audio', 1), (6, 'Headphones', 5),
(7, 'Office', NULL), (8, 'Furniture', 7), (9, 'Chairs', 8);
parent_id NULL đánh dấu danh mục gốc (Electronics, Office). Mỗi
danh mục con trỏ tới category_id của danh mục cha — đúng mô hình
adjacency list kinh điển cho dữ liệu phân cấp.
WITH RECURSIVE category_path(category_id, category_name, depth, path) AS (
-- Anchor: điểm khởi đầu — mọi danh mục GỐC (không có cha)
SELECT category_id, category_name, 0, category_name
FROM category_tree
WHERE parent_id IS NULL
UNION ALL
-- Recursive: nối tiếp con của những gì category_path ĐÃ có
SELECT ct.category_id, ct.category_name, cp.depth + 1, cp.path || ' > ' || ct.category_name
FROM category_tree ct
INNER JOIN category_path cp ON ct.parent_id = cp.category_id
)
SELECT category_id, category_name, depth, path
FROM category_path
ORDER BY path;
Kết quả liệt kê đủ 9 danh mục kèm đường dẫn đầy đủ, ví dụ
Electronics > Audio > Headphones (depth 2). Cơ chế hoạt động:
-
Anchor member (phần trước
UNION ALL) chạy đúng 1 lần, tạo tập khởi điểm — ở đây là 2 danh mục gốc,depth = 0. -
Recursive member (phần sau
UNION ALL) chạy lặp lại, mỗi lầnJOINbảng gốc với kết quả của lần chạy ngay trước đó (không phải toàn bộcategory_pathtích luỹ, mà chỉ các hàng mới sinh ra ở vòng lặp liền trước) — tìm con của những danh mục vừa được thêm vào. - Vòng lặp dừng khi 1 lượt chạy recursive member không sinh ra hàng mới nào.
PRAGMA recursive_triggers và giới hạn bộ nhớ), nhưng cách phòng tránh chủ động và đáng
tin cậy hơn là thêm LIMIT tổng số hàng cho câu lệnh, hoặc thêm điều kiện chặn
depth vượt quá 1 ngưỡng hợp lý (ví dụ WHERE cp.depth < 20 trong
recursive member) khi dữ liệu phân cấp đến từ nguồn không đáng tin cậy hoàn toàn.
Bảng So Sánh: Subquery vs CTE
| Tiêu chí | Subquery (lồng trực tiếp) | CTE (WITH) |
|---|---|---|
| Độ dễ đọc khi logic phức tạp | Giảm dần khi lồng sâu — phải đọc từ trong ra ngoài | Cao — đọc tuần tự từ trên xuống, mỗi bước có tên riêng |
| Tái sử dụng trong cùng 1 câu lệnh | Phải viết lại toàn bộ subquery mỗi lần dùng | Khai báo 1 lần, tham chiếu tên nhiều lần |
| Xử lý dữ liệu phân cấp/đồ thị | Không thể — không có cú pháp đệ quy cho subquery thường | Có — WITH RECURSIVE |
| Hiệu năng | Tương đương — optimizer thường xử lý cả 2 giống nhau | Tương đương (SQLite không "materialize" CTE mặc định như PostgreSQL cũ) |
Sân chơi tương tác: SQL Workbench Trên Dataset TechMart
Thử ngay ví dụ NOT IN/NOT EXISTS ở mục 2 — chèn thêm 1 khách hàng test rồi
chạy lại cả 2 câu để tự mắt thấy cạm bẫy. Ví dụ CTE đệ quy cũng có sẵn trong danh sách tình huống bên
dưới (bao gồm cả bước tạo bảng category_tree).
customers
| Cột | Kiểu |
|---|---|
| customer_id | INTEGER PRIMARY KEY |
| full_name | TEXT NOT NULL |
| TEXT | |
| country | TEXT |
| signup_date | TEXT |
| is_active | INTEGER |
products
| Cột | Kiểu |
|---|---|
| product_id | INTEGER PRIMARY KEY |
| product_name | TEXT NOT NULL |
| category | TEXT |
| unit_price | REAL |
| stock_quantity | INTEGER |
orders
| Cột | Kiểu |
|---|---|
| order_id | INTEGER PRIMARY KEY |
| customer_id | INTEGER (NULL = khách vãng lai) |
| order_date | TEXT |
| status | TEXT |
| total_amount | REAL |
order_items
| Cột | Kiểu |
|---|---|
| order_item_id | INTEGER PRIMARY KEY |
| order_id | INTEGER |
| product_id | INTEGER |
| quantity | INTEGER |
| unit_price | REAL |
category_tree (bảng bonus cho CTE đệ quy, chưa tạo)
| Cột | Kiểu |
|---|---|
| category_id | INTEGER PRIMARY KEY |
| category_name | TEXT NOT NULL |
| parent_id | INTEGER (NULL = danh mục gốc) |
Đang tải...
⬇ Tải file schema + dữ liệu mẫu TechMart (.sql) — chạy
được trực tiếp bằng sqlite3 CLI hoặc DB Browser for SQLite trên máy bạn.
Trắc nghiệm ôn tập
Câu 1: Khác biệt cốt lõi giữa subquery vô hướng (mục 1) và subquery tương quan (mục 3) là gì?
Trắc nghiệm ôn tập
Câu 2: Vì sao customer_id NOT IN (SELECT customer_id FROM orders) có thể âm thầm trả về
sai (0 hàng) ngay cả khi thực sự có khách chưa từng đặt đơn?
Trắc nghiệm ôn tập
Câu 3: Cách sửa an toàn nhất cho cạm bẫy ở Câu 2 là gì?
Trắc nghiệm ôn tập
Câu 4: Lợi ích chính của việc viết lại 1 chuỗi subquery lồng nhau phức tạp thành CTE
(WITH) là gì?
Trắc nghiệm ôn tập
Câu 5: Trong CTE đệ quy category_path, phần "anchor member" (trước
UNION ALL) và "recursive member" (sau UNION ALL) khác nhau thế nào?