Lồng một truy vấn bên trong truy vấn khác; phân biệt subquery độc lập và subquery tương quan; dùng IN, ANY, ALL, EXISTS đúng chỗ.
1. Subquery là gì?
Subquery (truy vấn con) là một câu SELECT nằm bên trong một câu lệnh SQL khác. PostgreSQL luôn chạy subquery trước, rồi dùng kết quả của nó cho truy vấn ngoài.
1.1. Bài toán mở đầu
Tìm các phim có thời lượng dài hơn trung bình. Không thể viết:
-- ❌ LỖI: không dùng hàm tổng hợp trực tiếp trong WHERESELECT title, length FROM film WHERE length > avg(length);-- ERROR: aggregate functions are not allowed in WHERE
Phải tách làm hai bước:
-- Bước 1SELECT avg(length) FROM film; -- 115.272-- Bước 2SELECT title, length FROM film WHERE length > 115.272;
Subquery gộp hai bước thành một:
SELECT title, lengthFROM filmWHERE length > (SELECT avg(length) FROM film)ORDER BY length DESC, film_idLIMIT 5;
2. Phân loại theo số dòng/cột trả về
2.1. Scalar subquery — 1 dòng, 1 cột
Dùng được ở bất cứ đâu chấp nhận một giá trị đơn:
-- Trong WHERESELECT title FROM filmWHERE length > (SELECT avg(length) FROM film);-- Trong SELECTSELECT title, length, (SELECT avg(length) FROM film) AS tb_chung, length - (SELECT avg(length) FROM film) AS chenh_lechFROM filmLIMIT 5;-- Trong ORDER BY, HAVING, thậm chí trong VALUES
Scalar subquery phải trả về tối đa 1 dòng
Nếu trả về nhiều dòng:
ERROR: more than one row returned by a subquery used as an expression
Nếu trả về 0 dòng, kết quả là NULL (không lỗi) — điều này có thể gây bất ngờ.
2.2. Row subquery — 1 dòng, nhiều cột
SELECT * FROM filmWHERE (rating, rental_duration) = ( SELECT rating, rental_duration FROM film WHERE film_id = 1);
2.3. Table subquery — nhiều dòng
Dùng với IN, ANY, ALL, EXISTS, hoặc trong FROM:
SELECT first_name, last_nameFROM customerWHERE customer_id IN ( SELECT customer_id FROM rental WHERE return_date IS NULL);
2.4. Subquery trong FROM — bảng dẫn xuất
SELECT the_loai, so_phimFROM ( SELECT c.name AS the_loai, count(*) AS so_phim FROM category c JOIN film_category fc ON fc.category_id = c.category_id GROUP BY c.name) AS thong_ke -- ← bí danh BẮT BUỘCWHERE so_phim > 60ORDER BY so_phim DESC;
Đây là phân biệt quan trọng nhất về mặt hiệu năng.
3.1. Subquery độc lập (uncorrelated)
Không tham chiếu tới truy vấn ngoài → chạy một lần duy nhất, kết quả được tái sử dụng.
SELECT title, lengthFROM filmWHERE length > (SELECT avg(length) FROM film);-- ↑ không nhắc tới bảng ngoài
3.2. Subquery tương quan (correlated)
Tham chiếu cột của truy vấn ngoài → về mặt logic chạy lại cho từng dòng của truy vấn ngoài.
Tìm các phim dài hơn trung bình của thể loại chính nó:
SELECT f.title, f.length, c.name AS the_loaiFROM film fJOIN film_category fc ON fc.film_id = f.film_idJOIN category c ON c.category_id = fc.category_idWHERE f.length > ( SELECT avg(f2.length) FROM film f2 JOIN film_category fc2 ON fc2.film_id = f2.film_id WHERE fc2.category_id = fc.category_id -- ← tham chiếu fc của truy vấn ngoài)ORDER BY c.name, f.length DESC;
Trả về true nếu có ít nhất một giá trị trong subquery thỏa mãn. SOME là từ đồng nghĩa hoàn toàn.
-- Phim dài hơn ít nhất một phim thuộc thể loại ActionSELECT title, lengthFROM filmWHERE length > ANY ( SELECT f.length FROM film f JOIN film_category fc ON fc.film_id = f.film_id JOIN category c ON c.category_id = fc.category_id WHERE c.name = 'Action')ORDER BY lengthLIMIT 5;
> ANY (...) tương đương > min(...) — chỉ cần dài hơn phim ngắn nhất là đủ.
5.1. Quan hệ giữa = ANY và IN
x = ANY (subquery) ≡ x IN (subquery)x <> ALL (subquery) ≡ x NOT IN (subquery)
5.2. ANY với mảng — dùng nhiều trong ứng dụng
SELECT * FROM filmWHERE film_id = ANY (ARRAY[1, 2, 3, 5, 8]);-- Hoặc ép từ chuỗiSELECT * FROM filmWHERE film_id = ANY (string_to_array('1,2,3', ',')::int[]);
Đây là cách chuẩn để truyền một danh sách tham số từ ứng dụng — an toàn hơn hẳn việc nối chuỗi để tạo IN (...), vốn là con đường dẫn tới SQL injection.
6. ALL
bieu_thuc toan_tu ALL (subquery)
Trả về true chỉ khi mọi giá trị trong subquery đều thỏa mãn.
-- Phim dài hơn TẤT CẢ phim thể loại ActionSELECT title, lengthFROM filmWHERE length > ALL ( SELECT f.length FROM film f JOIN film_category fc ON fc.film_id = f.film_id JOIN category c ON c.category_id = fc.category_id WHERE c.name = 'Action')ORDER BY length DESC;
> ALL (...) tương đương > max(...).
6.1. Bảng tương đương
Viết với ANY/ALL
Tương đương
x > ANY (...)
x > min(...)
x < ANY (...)
x < max(...)
x > ALL (...)
x > max(...)
x < ALL (...)
x < min(...)
x = ANY (...)
x IN (...)
x <> ALL (...)
x NOT IN (...)
Dạng dùng min/max thường dễ đọc và dễ tối ưu hơn.
6.2. Cạm bẫy: subquery rỗng
Trường hợp
ANY
ALL
Subquery trả về 0 dòng
false
true ⚠️
Subquery chứa NULL, không có giá trị nào khớp
NULL
NULL
x > ALL (tập rỗng) cho true vì “mọi phần tử của tập rỗng đều thỏa mãn” — đúng về logic nhưng thường không phải điều bạn muốn.
7. EXISTS
EXISTS (subquery)
Trả về true nếu subquery trả về ít nhất một dòng, bất kể nội dung dòng đó là gì.
SELECT c.first_name, c.last_nameFROM customer cWHERE EXISTS ( SELECT 1 FROM payment p WHERE p.customer_id = c.customer_id AND p.amount > 11)ORDER BY c.first_name;
7.1. Vì sao viết SELECT 1?
PostgreSQL không đánh giá danh sách cột của subquery trong EXISTS — nó chỉ kiểm tra có dòng nào không. Bạn có thể viết SELECT 1, SELECT *, hay SELECT 1/0 — kết quả và tốc độ như nhau:
SELECT EXISTS (SELECT 1/0 FROM film WHERE film_id = 1);-- true — không hề báo lỗi chia cho 0!
Quy ước dùng SELECT 1 để thể hiện rõ với người đọc rằng nội dung không quan trọng.
7.2. EXISTS dừng ngay khi tìm thấy
Điểm mạnh về hiệu năng: PostgreSQL dừng subquery ngay khi có dòng đầu tiên. Với bảng lớn, điều này nhanh hơn nhiều so với IN (vốn có thể phải vật chất hóa toàn bộ danh sách).
7.3. NOT EXISTS — anti-join an toàn
-- Tìm khách hàng chưa từng thanh toánSELECT c.customer_id, c.first_name, c.last_nameFROM customer cWHERE NOT EXISTS ( SELECT 1 FROM payment p WHERE p.customer_id = c.customer_id);
NOT EXISTS an toàn với NULL, NOT IN thì không
-- ⚠️ Trả về 0 dòng nếu subquery có bất kỳ NULL nàoWHERE x NOT IN (SELECT y FROM t)-- ✅ Luôn đúngWHERE NOT EXISTS (SELECT 1 FROM t WHERE t.y = x)
Đây là lý do NOT EXISTS được coi là cách viết anti-join chuẩn mực.
7.4. Ba cách viết anti-join
Ba câu sau cho cùng kết quả:
-- 1. NOT EXISTS (khuyến nghị)SELECT f.* FROM film fWHERE NOT EXISTS (SELECT 1 FROM inventory i WHERE i.film_id = f.film_id);-- 2. LEFT JOIN + IS NULLSELECT f.* FROM film fLEFT JOIN inventory i ON i.film_id = f.film_idWHERE i.film_id IS NULL;-- 3. EXCEPT (chỉ tiện khi cần một cột)SELECT film_id FROM filmEXCEPTSELECT film_id FROM inventory;
Cách
Ưu điểm
Nhược điểm
NOT EXISTS
An toàn NULL, rõ ý định, thường nhanh nhất
Dài hơn chút
LEFT JOIN ... IS NULL
Quen thuộc
Dễ chọn nhầm cột IS NULL
EXCEPT
Ngắn gọn
Khó lấy thêm cột từ bảng gốc
NOT IN — tránh dùng với subquery.
8. Subquery trong các mệnh đề khác
8.1. Trong SELECT
SELECT c.name AS the_loai, (SELECT count(*) FROM film_category fc WHERE fc.category_id = c.category_id) AS so_phimFROM category cORDER BY so_phim DESC;
Cách này dễ đọc nhưng thường chậm hơn JOIN + GROUP BY khi bảng lớn.
8.2. Trong HAVING
SELECT customer_id, sum(amount) AS tong_chiFROM paymentGROUP BY customer_idHAVING sum(amount) > (SELECT avg(amount) * 30 FROM payment)ORDER BY tong_chi DESC;
8.3. Trong UPDATE / DELETE
UPDATE filmSET rental_rate = rental_rate * 1.1WHERE film_id IN ( SELECT film_id FROM inventory GROUP BY film_id HAVING count(*) > 5);DELETE FROM rentalWHERE customer_id IN ( SELECT customer_id FROM customer WHERE activebool = false);
Subquery trong FROM bình thường không tham chiếu được bảng đứng trước nó. LATERAL gỡ bỏ hạn chế đó:
SELECT c.first_name, c.last_name, gan_day.rental_dateFROM customer cCROSS JOIN LATERAL ( SELECT r.rental_date FROM rental r WHERE r.customer_id = c.customer_id -- ← tham chiếu c, chỉ được nhờ LATERAL ORDER BY r.rental_date DESC LIMIT 1) AS gan_dayORDER BY gan_day.rental_date DESCLIMIT 10;
Dùng LEFT JOIN LATERAL ... ON true nếu muốn giữ cả những khách không có lượt thuê nào.
Tìm các phim có rental_rate cao hơn giá thuê trung bình.
Liệt kê khách hàng đã thuê phim thể loại 'Horror' (dùng EXISTS).
Tìm các thể loại không có phim nào dài trên 180 phút.
Với mỗi khách hàng, hiển thị họ tên và số tiền của lần thanh toán lớn nhất (dùng LATERAL).
Tìm phim dài hơn tất cả phim thể loại 'Documentary'.
Viết lại câu sau bằng JOIN và so sánh EXPLAIN ANALYZE:
SELECT c.name, (SELECT count(*) FROM film_category fc WHERE fc.category_id = c.category_id) AS so_phimFROM category c;
Câu sau đây có vấn đề gì?
SELECT title FROM filmWHERE film_id NOT IN (SELECT film_id FROM film_actor);
Đáp án
1.
SELECT title, rental_rateFROM filmWHERE rental_rate > (SELECT avg(rental_rate) FROM film)ORDER BY rental_rate DESC, title;
2.
SELECT DISTINCT c.customer_id, c.first_name, c.last_nameFROM customer cWHERE EXISTS ( SELECT 1 FROM rental r JOIN inventory i ON i.inventory_id = r.inventory_id JOIN film_category fc ON fc.film_id = i.film_id JOIN category cat ON cat.category_id = fc.category_id WHERE r.customer_id = c.customer_id AND cat.name = 'Horror')ORDER BY c.last_name;
3.
SELECT c.nameFROM category cWHERE NOT EXISTS ( SELECT 1 FROM film_category fc JOIN film f ON f.film_id = fc.film_id WHERE fc.category_id = c.category_id AND f.length > 180)ORDER BY c.name;
4.
SELECT c.first_name, c.last_name, lon_nhat.amount, lon_nhat.payment_dateFROM customer cLEFT JOIN LATERAL ( SELECT p.amount, p.payment_date FROM payment p WHERE p.customer_id = c.customer_id ORDER BY p.amount DESC, p.payment_id LIMIT 1) AS lon_nhat ON trueORDER BY lon_nhat.amount DESC NULLS LASTLIMIT 10;
Dùng LEFT JOIN LATERAL ... ON true để không mất khách chưa thanh toán.
5.
SELECT title, lengthFROM filmWHERE length > ALL ( SELECT f.length FROM film f JOIN film_category fc ON fc.film_id = f.film_id JOIN category c ON c.category_id = fc.category_id WHERE c.name = 'Documentary')ORDER BY length DESC;
Cách rõ ràng hơn:
WHERE length > (SELECT max(f.length) FROM ...)
6.
SELECT c.name, count(fc.film_id) AS so_phimFROM category cLEFT JOIN film_category fc ON fc.category_id = c.category_idGROUP BY c.category_id, c.nameORDER BY so_phim DESC;
Với 16 thể loại, chênh lệch không đáng kể. Với hàng triệu dòng, phiên bản JOIN + GROUP BY nhanh hơn rõ rệt vì chỉ quét bảng con một lần thay vì một lần cho mỗi dòng ngoài. Chú ý phải dùng count(fc.film_id) chứ không phải count(*) để thể loại rỗng cho ra 0.
7. Nếu film_actor.film_id cho phép NULL và có dòng nào là NULL, câu này trả về 0 dòng dù có phim không diễn viên. Trong CSDL mẫu, film_id là NOT NULL nên vẫn chạy đúng, nhưng đây là mẫu code mong manh. Viết an toàn:
SELECT f.title FROM film fWHERE NOT EXISTS ( SELECT 1 FROM film_actor fa WHERE fa.film_id = f.film_id);
11. Tóm tắt
Scalar subquery trả về 1 giá trị, dùng được ở SELECT, WHERE, HAVING, ORDER BY.
Subquery trong FROM bắt buộc có bí danh; nhiều tầng thì dùng CTE cho dễ đọc.
Subquery độc lập chạy một lần; tương quan tham chiếu truy vấn ngoài.
= ANY ≡ IN; <> ALL ≡ NOT IN; > ALL ≡ > max(); > ANY ≡ > min().
ALL với subquery rỗng trả về true — cạm bẫy dễ bỏ sót.
EXISTS chỉ quan tâm có dòng hay không; viết SELECT 1 theo quy ước.
NOT EXISTS là cách viết anti-join chuẩn — an toàn với NULL, khác NOT IN.
LATERAL cho phép subquery trong FROM tham chiếu bảng bên trái — công cụ giải bài toán top-N mỗi nhóm.