DHDataHọc
Lý thuyếtBài 3/237 phút đọc+10 XP

EXISTS và NOT EXISTS

Bạn sẽ học được

  • EXISTS là gì và vì sao danh sách SELECT bên trong nó không quan trọng.
  • Dùng EXISTS như semi-join để lọc "có ít nhất một" mà không nhân dòng.
  • Dùng NOT EXISTS cho câu hỏi "không có".
  • Thêm điều kiện phụ vào truy vấn con tương quan.
  • Vì sao NOT IN có thể trả rỗng và khi nào nên tránh.

Khái niệm cốt lõi

EXISTS nhận một truy vấn con và trả về đúng/sai: có tồn tại ít nhất một dòng thỏa điều kiện hay không. Nó không trả dữ liệu, chỉ trả câu trả lời.

code
SELECT p.name
FROM products p
WHERE EXISTS (
  SELECT 1 FROM order_items oi WHERE oi.product_id = p.id
)
ORDER BY p.name;

Tám dòng — mọi sản phẩm đã từng được bán ít nhất một lần. Giá kê laptop không xuất hiện vì chưa có dòng hàng nào trỏ đến nó.

Hai chi tiết về cú pháp:

  • SELECT 1 là quy ước, không phải yêu cầu. SELECT *, SELECT NULL, SELECT oi.id đều cho kết quả y hệt, vì PostgreSQL chỉ kiểm tra có dòng hay không, không đọc giá trị. Viết SELECT 1 để người đọc hiểu ngay "tôi chỉ cần sự tồn tại".
  • Truy vấn con thường tương quan: nó tham chiếu p.id từ câu ngoài. Vì vậy nó được đánh giá cho từng sản phẩm, và dừng ngay khi tìm được dòng đầu tiên — không quét hết.

Điểm mạnh lớn nhất của EXISTS so với JOIN: không nhân dòng. Câu hỏi "khách nào đã mua hàng Điện tử" nếu viết bằng JOIN sẽ trả mỗi khách nhiều lần — một lần cho mỗi dòng hàng Điện tử họ mua:

code
-- 12 dòng, nhiều khách lặp lại
SELECT c.name
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
WHERE p.category = 'Điện tử';

Muốn đếm đúng, bạn phải thêm DISTINCT hoặc GROUP BY. Với EXISTS, mỗi khách chỉ được trả một lần vì câu hỏi là "có hay không", không phải "bao nhiêu":

code
SELECT c.name
FROM customers c
WHERE EXISTS (
  SELECT 1
  FROM orders o
  JOIN order_items oi ON oi.order_id = o.id
  JOIN products p ON p.id = oi.product_id
  WHERE o.customer_id = c.id AND p.category = 'Điện tử'
)
ORDER BY c.name;

Kết quả: 8 khách, không trùng lặp. Dạng truy vấn này gọi là semi-join — nối bán phần, chỉ giữ sự tồn tại.

NOT EXISTS đảo ngược: giữ dòng mà truy vấn con không tìm được gì.

code
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.customer_id = c.id
);

Kết quả: Hà Bùi — khách duy nhất chưa có đơn.

Vì EXISTS nhận mọi điều kiện, bạn có thể hỏi những câu phức tạp hơn một phép nối đơn giản. "Khách chưa từng có đơn hoàn tất":

code
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
  SELECT 1 FROM orders o
  WHERE o.customer_id = c.id AND o.status = 'completed'
)
ORDER BY c.name;

Bốn người: Em Võ, Hà Bùi, Inh Đỗ, Nga Lý. Điều kiện status = 'completed' nằm trong truy vấn con — nó thu hẹp định nghĩa "tồn tại", không lọc kết quả cuối.

Cùng ý tưởng với "khách đang có đơn chờ":

code
SELECT c.name
FROM customers c
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.customer_id = c.id AND o.status = 'pending'
)
ORDER BY c.name;

Hai người: Em Võ, Nga Lý.

Bảng chọn công cụ:

Cách viếtTrả gìNhân dòng?Dùng khi
JOINDữ liệu hai bảngCóCần cột của bảng phải
IN (subquery)Tập giá trịKhôngDanh sách giá trị nhỏ, không có NULL
EXISTSĐúng/saiKhôngChỉ cần biết "có tồn tại"
NOT EXISTSĐúng/saiKhôngCần "không tồn tại", an toàn với NULL

Cạm bẫy NOT IN: nếu danh sách con chứa NULL, toàn bộ điều kiện thành NULL và câu trả không dòng nào. Trong bảng khách có hai email NULL (Dũng Phạm, Inh Đỗ), nên:

code
SELECT COUNT(*) FROM customers
WHERE email NOT IN (SELECT email FROM customers WHERE city = 'Hà Nội');

trả 0, dù logic đời thường là "khách ở thành phố khác". NOT EXISTS viết lại an toàn:

code
SELECT COUNT(*) FROM customers c
WHERE NOT EXISTS (
  SELECT 1 FROM customers c2
  WHERE c2.city = 'Hà Nội' AND c2.email = c.email
);

Kết quả 10 — mọi khách trừ An Nguyễn và Khánh Hồ, hai người Hà Nội có email trùng với chính họ. Khách có email NULL (Dũng Phạm, Inh Đỗ) không "khớp" với ai, kể cả chính họ, nên Dũng vẫn nằm trong kết quả. Đó là ngữ nghĩa đúng của "không có email trùng với khách Hà Nội nào".

Ví dụ chạy được

Khách đã mua Sách SQL thực chiến:

code
SELECT c.name
FROM customers c
WHERE EXISTS (
  SELECT 1
  FROM orders o
  JOIN order_items oi ON oi.order_id = o.id
  WHERE o.customer_id = c.id AND oi.product_id = 6
)
ORDER BY c.name;

Bốn khách. Cùng câu hỏi bằng JOIN cần DISTINCT; bằng IN cần danh sách customer_id từ hai lần nối — EXISTS diễn đạt trực tiếp nhất.

Đếm sản phẩm chưa bán được:

code
SELECT COUNT(*) AS so_san_pham_chua_ban
FROM products p
WHERE NOT EXISTS (
  SELECT 1 FROM order_items oi WHERE oi.product_id = p.id
);

Kết quả 1 (Giá kê laptop). Đối chiếu: 9 sản phẩm = 8 đã bán + 1 chưa bán.

So sánh semi-join và join:

code
SELECT 'EXISTS' AS cach, COUNT(*) AS so_dong FROM (
  SELECT c.id FROM customers c
  WHERE EXISTS (
    SELECT 1 FROM orders o JOIN order_items oi ON oi.order_id = o.id
    JOIN products p ON p.id = oi.product_id
    WHERE o.customer_id = c.id AND p.category = 'Gia dụng'
  )
) t
UNION ALL
SELECT 'JOIN', COUNT(*) FROM (
  SELECT c.id
  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
  WHERE p.category = 'Gia dụng'
) t;

Kết quả: EXISTS | 8 và JOIN | 16. Cùng tám khách, nhưng JOIN trả 16 dòng vì mỗi khách mua nhiều món Gia dụng. EXISTS trả đúng hạt của câu hỏi — mỗi khách một dòng.

Một phép kiểm chứng hiệu năng đáng nhớ: EXISTS dừng ở dòng khớp đầu tiên. Nếu bảng order_items có chỉ mục trên product_id, PostgreSQL tìm được một dòng trong vài bước, không cần quét toàn bảng. NOT IN thì thường phải dựng toàn bộ danh sách con trước khi so sánh — với danh sách lớn, chênh lệch rất rõ.

Thực hành

  1. Liệt kê sản phẩm chưa từng được bán bằng NOT EXISTS. Kết quả: Giá kê laptop.
  2. Đếm khách đã mua hàng Gia dụng bằng EXISTS. Đáp số 8; đếm bằng JOIN mà quên DISTINCT sẽ ra 16.
  3. Tìm khách có ít nhất một đơn pending. Kết quả: Em Võ, Nga Lý.
  4. Chạy câu NOT IN với email, rồi viết lại bằng NOT EXISTS. Ghi lại hai kết quả 0 và 10.
  5. Đổi SELECT 1 trong một câu EXISTS thành SELECT * và xác nhận kết quả không đổi.

Lỗi thường gặp

Triệu chứngNguyên nhânCách sửa
Kết quả trùng lặpDùng JOIN thay EXISTSĐổi sang EXISTS hoặc thêm DISTINCT
NOT IN trả 0 dòngDanh sách con chứa NULLNOT EXISTS hoặc loại NULL
EXISTS luôn đúngTruy vấn con không tương quan, không lọc theo dòng ngoàiThêm WHERE bang_ngoai.id = ...
Kết quả thiếu ngườiĐiều kiện phụ đặt ngoài truy vấn conChuyển vào WHERE của EXISTS
Tưởng EXISTS trả cộtHiểu sai ngữ nghĩa booleanChỉ cần biết có dòng hay không

Dòng thứ ba là lỗi khó thấy: nếu truy vấn con không tham chiếu bảng ngoài và bảng trong có ít nhất một dòng, EXISTS đúng với mọi dòng ngoài. Kết quả trông như "tất cả khách đều thỏa" — và đó chính là dấu hiệu để kiểm tra lại mệnh đề tương quan.

Tóm tắt

  • EXISTS trả đúng/sai và dừng ở dòng khớp đầu tiên; danh sách SELECT bên trong không quan trọng.
  • Dùng EXISTS như semi-join: lọc "có ít nhất một" mà không nhân dòng.
  • NOT EXISTS là cách an toàn để hỏi "không có", kể cả khi dữ liệu chứa NULL.
  • Điều kiện phụ đặt trong truy vấn con để định nghĩa lại "tồn tại".
  • NOT IN với cột nullable có thể trả rỗng mà không báo lỗi — tránh dùng.

Dùng ← → để chuyển bài