Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn là BA tại một sàn thương mại điện tử như Tiki. Sếp hỏi: "Cho anh danh sách những khách hàng có giá trị đơn cao hơn mức trung bình của toàn hệ thống." Nghe thì đơn giản, nhưng bạn nhận ra ngay một vấn đề: để biết "khách nào cao hơn trung bình", trước hết bạn phải tính được "trung bình là bao nhiêu", rồi mới so sánh từng khách với con số đó. Đó là hai bước, hai phép tính lồng vào nhau.
Đây chính là lúc subquery (truy vấn con) tỏa sáng. Subquery cho phép bạn đặt một câu lệnh SELECT bên trong một câu lệnh SELECT khác — kết quả của truy vấn bên trong trở thành nguyên liệu đầu vào cho truy vấn bên ngoài. Thay vì chạy hai query riêng rồi copy số liệu thủ công (cách làm rất dễ sai và không tự động hóa được), bạn gói toàn bộ logic vào một câu lệnh duy nhất, chạy lúc nào cũng cho kết quả đúng theo dữ liệu mới nhất.
Với nghề BA, subquery là kỹ năng bản lề. Phần lớn các báo cáo nghiệp vụ thực tế — "sản phẩm bán chạy hơn mặt bằng chung", "chi nhánh có doanh thu vượt kế hoạch", "khách hàng chưa từng mua lại" — đều cần so sánh dữ liệu với một giá trị hoặc một tập hợp được tính ra từ chính dữ liệu đó. Nắm vững subquery, bạn chuyển từ chỗ chỉ "lấy được dữ liệu thô" sang chỗ "trả lời được câu hỏi nghiệp vụ" — đó là ranh giới giữa một người chạy query và một nhà phân tích thực thụ.
Khái niệm cốt lõi
Subquery là một câu lệnh SELECT được lồng bên trong một câu lệnh SQL khác. Truy vấn bên trong gọi là inner query, truy vấn bao ngoài gọi là outer query. SQL luôn xử lý inner query trước (trong các trường hợp đơn giản), lấy kết quả của nó để cung cấp cho outer query.
Tùy theo vị trí xuất hiện và kiểu giá trị trả về, subquery có vài dạng chính mà BA cần phân biệt rõ.
Subquery trong WHERE
Đây là dạng phổ biến nhất. Bạn dùng kết quả của truy vấn con làm điều kiện lọc cho truy vấn cha.
Quay lại bài toán của sếp Tiki ở trên:
-- Khách hàng có giá trị đơn cao hơn trung bình toàn hệ thống
SELECT name
FROM users
WHERE id IN (
SELECT user_id
FROM orders
WHERE total_amount > (SELECT AVG(total_amount) FROM orders)
);
Ở đây có tới hai subquery lồng nhau:
(SELECT AVG(total_amount) FROM orders)trả về một giá trị duy nhất — giá trị đơn trung bình. Đây gọi là scalar subquery (subquery vô hướng).(SELECT user_id FROM orders WHERE ...)trả về một danh sách cácuser_id. Đây gọi là subquery trả về tập hợp, thường dùng kèmIN,NOT IN,ANY,ALL.
Subquery trong SELECT
Bạn có thể đặt một scalar subquery ngay trong danh sách cột để hiển thị thêm thông tin bên cạnh mỗi dòng:
SELECT
name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS so_don_hang
FROM users u;
Mỗi dòng khách hàng sẽ kèm theo số đơn hàng của họ. Lưu ý: subquery loại này phải trả về đúng một giá trị cho mỗi dòng, nếu không SQL sẽ báo lỗi.
Subquery trong FROM (derived table)
Khi đặt subquery trong FROM, nó trở thành một "bảng ảo" (derived table) mà bạn có thể truy vấn tiếp như một bảng thật. Bắt buộc phải đặt alias (tên bí danh) cho nó:
SELECT thang, AVG(doanh_thu_ngay) AS doanh_thu_tb_ngay
FROM (
SELECT
DATE(created_at) AS ngay,
MONTH(created_at) AS thang,
SUM(total_amount) AS doanh_thu_ngay
FROM orders
GROUP BY DATE(created_at), MONTH(created_at)
) AS doanh_thu_theo_ngay
GROUP BY thang;
Đây là pattern cực kỳ mạnh cho báo cáo: tổng hợp dữ liệu ở một mức (theo ngày), rồi tổng hợp tiếp ở mức khác (trung bình theo tháng).
Correlated vs Non-correlated
Phân biệt này rất quan trọng:
- Non-correlated subquery: inner query chạy độc lập, không phụ thuộc outer query. Nó được tính một lần duy nhất. Ví dụ
(SELECT AVG(total_amount) FROM orders). - Correlated subquery: inner query tham chiếu đến cột của outer query (như
o.user_id = u.idở ví dụ trên). Nó được tính lại cho từng dòng của outer query — mạnh nhưng có thể chậm trên dữ liệu lớn.
Tình huống thực tế
Ví dụ 1 — Shopee: tìm sản phẩm bán chạy hơn mặt bằng danh mục
Bối cảnh: Bạn là BA mảng ngành hàng Mỹ phẩm tại Shopee Việt Nam. Trưởng nhóm muốn biết những sản phẩm nào trong danh mục "Son môi" có số lượng bán vượt trung bình của chính danh mục đó, để cân nhắc đẩy mạnh quảng cáo.
SELECT product_name, units_sold
FROM products
WHERE category = 'Son môi'
AND units_sold > (
SELECT AVG(units_sold)
FROM products
WHERE category = 'Son môi'
);
Diễn giải: Scalar subquery tính trung bình số lượng bán chỉ trong danh mục Son môi (giả sử ra 1.250 sản phẩm/SKU). Outer query lọc những SKU bán trên 1.250. Kết quả: 18 sản phẩm nổi bật được lọc ra trong vài giây.
Bài học rút ra: Điều kiện lọc trong subquery phải khớp với phạm vi câu hỏi nghiệp vụ. Nếu bỏ WHERE category = 'Son môi' trong subquery, bạn sẽ so sánh son môi với trung bình của cả sàn — sai hoàn toàn về mặt nghiệp vụ.
Ví dụ 2 — Tiki: khách hàng đã đăng ký nhưng chưa từng mua
Bối cảnh: Đội Marketing của Tiki muốn gửi voucher kích hoạt cho nhóm khách đã tạo tài khoản nhưng chưa phát sinh đơn hàng nào. Đây là bài toán "tồn tại trong bảng A nhưng không tồn tại trong bảng B".
SELECT user_id, email, created_at
FROM users
WHERE user_id NOT IN (
SELECT user_id
FROM orders
WHERE user_id IS NOT NULL
);
Diễn giải: Subquery trả về danh sách tất cả user_id đã từng đặt hàng. NOT IN giữ lại những user không nằm trong danh sách đó. Giả sử hệ thống có 500.000 user và 380.000 người đã mua, query trả về 120.000 khách chưa kích hoạt — danh sách vàng cho chiến dịch.
Bài học rút ra: Hãy để ý điều kiện WHERE user_id IS NOT NULL trong subquery. Đây không phải thừa thãi — với NOT IN, nếu danh sách con chứa dù chỉ một giá trị NULL, toàn bộ kết quả sẽ trả về rỗng do cách SQL xử lý logic ba trạng thái với NULL. Đây là cái bẫy kinh điển khiến BA giao nhầm danh sách rỗng cho Marketing. (Cách xử lý NULL kỹ hơn sẽ học ở Bài 7.)
Ví dụ 3 — Be: chi nhánh có doanh thu vượt trung bình toàn quốc
Bối cảnh: Bạn là BA tại ứng dụng gọi xe Be. Ban giám đốc muốn báo cáo những thành phố có doanh thu tháng vượt mức trung bình toàn quốc, dùng derived table.
SELECT thanh_pho, doanh_thu_thang
FROM (
SELECT city AS thanh_pho, SUM(fare) AS doanh_thu_thang
FROM trips
WHERE MONTH(trip_date) = 5 AND YEAR(trip_date) = 2026
GROUP BY city
) AS dt_theo_tp
WHERE doanh_thu_thang > (
SELECT AVG(tong) FROM (
SELECT SUM(fare) AS tong
FROM trips
WHERE MONTH(trip_date) = 5 AND YEAR(trip_date) = 2026
GROUP BY city
) AS tb
);
Diễn giải: Derived table dt_theo_tp tổng hợp doanh thu tháng 5/2026 theo từng thành phố. Subquery bên phải tính trung bình doanh thu của các thành phố. Outer query giữ lại những thành phố vượt trung bình. Kết quả: TP.HCM, Hà Nội, Đà Nẵng được liệt kê.
Bài học rút ra: Khi cần "trung bình của các nhóm đã tổng hợp", bạn không thể viết AVG(SUM(...)) trực tiếp — phải tổng hợp ở tầng trong (derived table) rồi mới AVG ở tầng ngoài. Đây là kỹ thuật báo cáo hai tầng rất hay gặp.
Hướng dẫn từng bước
Khi gặp một yêu cầu báo cáo cần subquery, hãy theo quy trình sau:
- Tách câu hỏi nghiệp vụ thành hai vế. Tìm cụm từ so sánh như "cao hơn", "thấp hơn trung bình", "thuộc nhóm đã...", "chưa từng...". Vế cần tính trước (cái mốc so sánh) thường chính là subquery.
- Viết và chạy riêng subquery trước. Đừng vội lồng ngay. Chạy
SELECT AVG(total_amount) FROM ordersđộc lập, xác nhận nó ra một con số hợp lý. Subquery sai thì cả câu sai, mà lại khó debug khi đã lồng vào.
- Xác định kiểu giá trị subquery trả về. Một giá trị (scalar) thì dùng với
=,>,<. Một danh sách thì dùngIN,NOT IN. Một bảng thì đặt vàoFROMkèm alias.
- Lồng subquery vào đúng vị trí (
WHERE,SELECT, hayFROM) tùy mục đích: lọc dòng, thêm cột, hay tạo bảng trung gian.
- Đặt alias rõ ràng cho cột và cho derived table. Báo cáo BA cần tên cột tiếng Việt dễ đọc để dán thẳng vào Excel/dashboard.
- Kiểm tra biên với NULL và tập rỗng. Đặc biệt với
NOT IN, luôn tự hỏi: "Cột con này có thể chứa NULL không?"
Lỗi thường gặp & mẹo
Lỗi 1 — Subquery trả về nhiều dòng trong khi bạn dùng =. Câu WHERE id = (SELECT user_id FROM orders) sẽ lỗi "Subquery returns more than 1 row". Nếu kỳ vọng nhiều giá trị, dùng IN; nếu thật sự chỉ một giá trị, thêm điều kiện hoặc LIMIT 1/MAX() cho phù hợp.
Lỗi 2 — Bẫy NULL với NOT IN. Như đã nói ở Ví dụ 2: nếu subquery của NOT IN chứa NULL, kết quả về rỗng. Mẹo: luôn thêm WHERE cot IS NOT NULL vào subquery, hoặc cân nhắc dùng NOT EXISTS thay thế (an toàn hơn với NULL).
Lỗi 3 — Quên đặt alias cho derived table. Subquery trong FROM mà thiếu alias sẽ báo lỗi cú pháp trên MySQL/PostgreSQL. Luôn kết thúc bằng ) AS ten_bang.
Lỗi 4 — Lạm dụng correlated subquery trên dữ liệu lớn. Một correlated subquery trong SELECT chạy lại cho từng dòng — với bảng triệu dòng có thể rất chậm. Khi báo cáo ì ạch, đây là điểm cần nghi ngờ đầu tiên.
Mẹo: Khi một subquery bắt đầu lồng quá 2 tầng và khó đọc, đó là tín hiệu bạn nên chuyển sang CTE (WITH) — đặt tên cho từng bước, đọc như đọc văn. Bạn sẽ học CTE ở Bài 16; nhưng hãy nhớ: subquery là nền tảng để hiểu CTE.
Bài tập thực hành
Giả sử bạn có hai bảng: users(user_id, name, city, created_at) và orders(order_id, user_id, total_amount, created_at).
- Cơ bản: Liệt kê tên các khách hàng có ít nhất một đơn hàng với giá trị lớn hơn giá trị đơn trung bình của toàn hệ thống.
- Trung bình: Hiển thị mỗi khách hàng kèm tổng số đơn và tổng số tiền họ đã chi, dùng scalar subquery trong
SELECT.
- Tập hợp: Tìm danh sách khách hàng ở "Hà Nội" chưa từng đặt đơn nào. Nhớ xử lý bẫy NULL với
NOT IN.
- Derived table: Tính doanh thu trung bình mỗi ngày trong tháng 6/2026 (gợi ý: tổng doanh thu theo ngày trong subquery
FROM, rồiAVGở ngoài).
- Nâng cao: Tìm các thành phố có số lượng khách hàng nhiều hơn số khách trung bình mỗi thành phố. Bạn sẽ cần derived table tổng hợp đếm khách theo thành phố, rồi so với trung bình.
Tóm tắt
- Subquery là câu
SELECTlồng bên trong một câu SQL khác, dùng để tính ra mốc so sánh hoặc tập hợp ngay trong cùng một query — nền tảng của hầu hết báo cáo nghiệp vụ. - Ba vị trí chính: trong WHERE (lọc theo điều kiện động), trong SELECT (thêm cột tính toán), trong FROM (tạo derived table cho báo cáo hai tầng).
- Phân biệt scalar (một giá trị, dùng với
=,>), tập hợp (nhiều giá trị, dùng vớiIN/NOT IN), và derived table (một bảng, cần alias). - Cẩn thận các bẫy: subquery trả nhiều dòng khi dùng
=, bẫy NULL vớiNOT IN, quên alias derived table, và correlated subquery chậm trên dữ liệu lớn. - Quy trình an toàn: tách câu hỏi thành hai vế, chạy subquery riêng trước, xác định kiểu trả về, rồi mới lồng và đặt alias rõ ràng.