Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Subqueries: non-correlated, correlated, scalar

SQL and Data Analysis cho BA Bài 15/60

Mở đầu — vì sao bài này quan trọng

Nếu bạn đã đi qua các bài về SELECT, WHERE, JOIN và GROUP BY, bạn đang có trong tay những "viên gạch" cơ bản. Nhưng có một sự thật mà bất kỳ BA nào làm việc với dữ liệu thật cũng sớm nhận ra: rất nhiều câu hỏi nghiệp vụ không thể trả lời chỉ bằng một query phẳng. Sếp hỏi "Cho anh xem những đơn hàng có giá trị cao hơn mức trung bình của toàn hệ thống" — bạn không thể viết WHERE order_value > AVG(...) một cách ngây thơ được, vì giá trị trung bình đó phải được tính ra trước. Đây chính là lúc subquery (truy vấn con) bước vào.

Subquery là một câu SELECT lồng bên trong một câu lệnh SQL khác. Nó cho phép bạn "đặt một câu hỏi phụ" để lấy ra một con số, một danh sách, hoặc một bảng kết quả, rồi dùng kết quả đó làm đầu vào cho câu hỏi chính. Với một BA, subquery là công cụ chuyển đổi tư duy: từ "lấy dữ liệu thô" sang "đặt câu hỏi nhiều tầng" — đúng kiểu các yêu cầu nghiệp vụ thực tế thường được diễn đạt.

Trong bài này, chúng ta sẽ phân loại subquery theo cách kết quả nó trả về (scalar, row, table) và theo cách nó liên hệ với query bên ngoài (non-correlated và correlated). Hiểu rõ sự phân loại này không phải để học thuộc lý thuyết, mà để bạn biết khi nào dùng cái nào, và quan trọng hơn, để bạn đọc hiểu được vì sao một query chạy chậm hay trả về kết quả sai.

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

Phân loại theo kết quả trả về

Cách dễ nhất để bắt đầu là nhìn vào "hình dạng" của thứ mà subquery trả về.

Scalar subquery trả về đúng một giá trị duy nhất — một dòng, một cột. Bạn có thể đặt nó ở bất kỳ chỗ nào mà SQL mong đợi một giá trị đơn lẻ: trong SELECT, trong WHERE, hoặc trong HAVING. Ví dụ kinh điển là so sánh với một mức trung bình:

SELECT order_id, total_amount
FROM orders
WHERE total_amount > (SELECT AVG(total_amount) FROM orders);

Cái (SELECT AVG(total_amount) FROM orders) trả về đúng một con số, nên ta dùng được toán tử so sánh > với nó như thể nó là một hằng số.

Row subquery trả về một dòng nhưng nhiều cột. Loại này ít gặp hơn nhưng rất tiện khi bạn cần so sánh nhiều cột cùng lúc:

SELECT *
FROM employees
WHERE (department_id, manager_id) = (
    SELECT department_id, manager_id
    FROM employees
    WHERE employee_id = 1001
);

Ở đây ta so sánh đồng thời cả department_idmanager_id với một dòng tham chiếu. (Lưu ý: cú pháp so sánh tuple kiểu này được PostgreSQL và MySQL hỗ trợ tốt, nhưng không phải mọi hệ quản trị đều có.)

Table subquery trả về nhiều dòng, nhiều cột — tức là một bảng kết quả hoàn chỉnh. Loại này thường xuất hiện hai chỗ. Thứ nhất, trong mệnh đề IN, EXISTS, ANY/ALL để kiểm tra thành viên hoặc điều kiện tồn tại:

SELECT customer_id, customer_name
FROM customers
WHERE customer_id IN (
    SELECT customer_id FROM orders WHERE order_date >= '2026-01-01'
);

Thứ hai, trong mệnh đề FROM như một bảng tạm — gọi là derived table (bảng dẫn xuất), luôn phải có alias:

SELECT region, AVG(monthly_total) AS avg_per_customer
FROM (
    SELECT region, customer_id, SUM(total_amount) AS monthly_total
    FROM orders
    GROUP BY region, customer_id
) AS customer_summary
GROUP BY region;

Phân loại theo mối liên hệ: non-correlated vs correlated

Đây là phần khiến nhiều BA nhầm lẫn nhất, nhưng cũng là phần quan trọng nhất để hiểu về hiệu năng.

Non-correlated subquery (truy vấn con độc lập) là subquery có thể chạy độc lập, không cần biết gì về query bên ngoài. Bạn có thể copy nó ra, chạy riêng, và nó vẫn cho kết quả. SQL chỉ cần chạy nó một lần, lấy kết quả, rồi đưa vào query ngoài. Tất cả các ví dụ ở phần trên đều là non-correlated.

Correlated subquery (truy vấn con tương quan) thì khác hẳn: nó tham chiếu đến một cột của query bên ngoài, nên không thể chạy độc lập. Về mặt khái niệm, query ngoài duyệt qua từng dòng, và với mỗi dòng, subquery được chạy lại một lần, sử dụng giá trị của dòng hiện tại. Ví dụ — tìm những nhân viên có lương cao hơn mức lương trung bình của chính phòng ban họ:

SELECT e.employee_id, e.full_name, e.salary, e.department_id
FROM employees e
WHERE e.salary > (
    SELECT AVG(e2.salary)
    FROM employees e2
    WHERE e2.department_id = e.department_id
);

Để ý e.department_id bên trong subquery — đó là cột của bảng ngoài. Mức trung bình được tính lại cho từng phòng ban tương ứng với dòng đang xét. Đây chính là sức mạnh nhưng cũng là điểm yếu của correlated subquery: linh hoạt nhưng có thể tốn kém về hiệu năng vì subquery chạy lặp đi lặp lại.

EXISTS — người bạn của correlated subquery

EXISTS kiểm tra xem subquery có trả về ít nhất một dòng hay không, trả về TRUE/FALSE chứ không phải dữ liệu. Nó gần như luôn đi kèm correlated subquery và thường nhanh hơn IN khi xử lý bảng lớn, vì hệ quản trị có thể dừng ngay khi tìm thấy dòng đầu tiên thỏa mãn:

SELECT c.customer_id, c.customer_name
FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.customer_id = c.customer_id
      AND o.total_amount > 1000000
);

Câu này đọc là: "Lấy những khách hàng mà tồn tại ít nhất một đơn hàng trên 1 triệu đồng."

Tình huống thực tế

Tình huống 1 — Tiki: lọc đơn hàng "trên trung bình" cho báo cáo Marketing

Đội Marketing của một sàn TMĐT giả định kiểu Tiki muốn xác định các đơn hàng "giá trị cao" để chạy chương trình chăm sóc khách VIP. Họ định nghĩa "giá trị cao" là cao hơn giá trị đơn hàng trung bình của toàn sàn trong quý. Bạn — với vai trò BA — nhận yêu cầu này.

SELECT order_id, customer_id, total_amount
FROM orders
WHERE order_date BETWEEN '2026-04-01' AND '2026-06-30'
  AND total_amount > (
      SELECT AVG(total_amount)
      FROM orders
      WHERE order_date BETWEEN '2026-04-01' AND '2026-06-30'
  )
ORDER BY total_amount DESC;

Đây là scalar subquery non-correlated: con số trung bình được tính một lần cho cả quý. Giả sử trung bình ra 320.000 đồng, query trả về tất cả đơn vượt mức đó.

Bài học rút ra: Khi yêu cầu nghiệp vụ chứa cụm từ so sánh với "trung bình", "tổng", "lớn nhất" của một tập dữ liệu rộng hơn, đó gần như chắc chắn là dấu hiệu cần scalar subquery. Đừng cố nhồi AVG() trực tiếp vào WHERE — nó sẽ báo lỗi vì WHERE không cho dùng aggregate trực tiếp.

Tình huống 2 — Shopee: khách hàng "ngủ đông" cần kích hoạt lại

Đội CRM của một sàn kiểu Shopee muốn tìm khách hàng đã từng mua hàng nhưng không có đơn nào trong 90 ngày gần nhất, để gửi mã giảm giá kích hoạt. Đây là bài toán "tồn tại trong tập A nhưng không tồn tại trong tập B", rất hợp với NOT EXISTS:

SELECT c.customer_id, c.customer_name, c.phone
FROM customers c
WHERE EXISTS (
        SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
    )
  AND NOT EXISTS (
        SELECT 1 FROM orders o2
        WHERE o2.customer_id = c.customer_id
          AND o2.order_date >= '2026-03-29'
    );

Điều kiện đầu đảm bảo khách đã từng mua (loại bỏ tài khoản chưa bao giờ đặt hàng). Điều kiện sau loại những ai có đơn trong 90 ngày qua. Cả hai đều là correlated subquery.

Bài học rút ra: Logic "đã từng X nhưng gần đây không Y" xuất hiện cực kỳ nhiều trong phân tích khách hàng. EXISTS kết hợp NOT EXISTS là khuôn mẫu sạch và an toàn hơn so với dùng IN/NOT IN, đặc biệt khi dữ liệu có giá trị NULL (ta sẽ nói ở phần lỗi thường gặp).

Tình huống 3 — MoMo: phát hiện giao dịch lớn bất thường của từng người dùng

Một ví điện tử kiểu MoMo cần đánh dấu các giao dịch có giá trị bất thường so với thói quen chi tiêu của chính người dùng đó — không phải so với toàn hệ thống. Một khách thường giao dịch 50.000 đồng mà bỗng có giao dịch 5 triệu thì đáng chú ý, trong khi 5 triệu lại là bình thường với người khác. Đây là bài toán correlated điển hình:

SELECT t.transaction_id, t.user_id, t.amount, t.created_at
FROM transactions t
WHERE t.amount > 3 * (
    SELECT AVG(t2.amount)
    FROM transactions t2
    WHERE t2.user_id = t.user_id
);

Với mỗi giao dịch, subquery tính mức trung bình của riêng người dùng đó, và ta đánh dấu những giao dịch vượt 3 lần trung bình cá nhân.

Bài học rút ra: Khi "mức so sánh" phải được tính theo từng nhóm gắn với dòng đang xét (mỗi khách một mức riêng), bạn cần correlated subquery — chính sự tham chiếu t2.user_id = t.user_id tạo ra sự "cá nhân hóa" đó. Lưu ý về hiệu năng: trên bảng giao dịch hàng triệu dòng, query này có thể chậm, và đây là lúc bạn nên cân nhắc window function (sẽ học ở các bài sau) như một giải pháp thay thế nhanh hơn.

Hướng dẫn từng bước

Khi đối diện một yêu cầu nghiệp vụ và phân vân có nên dùng subquery không, hãy đi theo trình tự sau:

Bước 1 — Tách câu hỏi thành hai tầng. Đọc kỹ yêu cầu và hỏi: có một "câu hỏi phụ" cần trả lời trước không? Ví dụ "khách chi nhiều hơn trung bình" tách thành: (a) trung bình là bao nhiêu, rồi (b) ai vượt mức đó. Tầng (a) chính là subquery.

Bước 2 — Xác định hình dạng kết quả của tầng phụ. Tầng phụ trả về một con số (scalar), một danh sách giá trị (table cho IN), hay cả một bảng (derived table)? Câu trả lời quyết định bạn đặt subquery ở đâu: WHERE với toán tử so sánh, WHERE với IN/EXISTS, hay FROM.

Bước 3 — Quyết định độc lập hay tương quan. Mức so sánh có giống nhau cho mọi dòng không? Nếu giống (trung bình toàn hệ thống) thì non-correlated. Nếu mỗi dòng có mức riêng (trung bình theo từng khách) thì correlated, và bạn phải tham chiếu cột bảng ngoài bên trong subquery.

Bước 4 — Viết và chạy thử subquery một mình trước. Nếu là non-correlated, hãy copy phần subquery ra chạy riêng, kiểm tra nó trả đúng con số/danh sách mong đợi. Đây là thói quen debug vàng: cô lập từng tầng.

Bước 5 — Ghép vào query ngoài và kiểm chứng. Chạy toàn bộ, đối chiếu vài dòng kết quả bằng tay để chắc chắn logic đúng. Với correlated subquery, hãy thử với một customer_id cụ thể để xác minh.

Bước 6 — Cân nhắc hiệu năng và độ dễ đọc. Nếu subquery lồng quá sâu hoặc correlated trên bảng lớn, ghi chú lại để sau này tối ưu (CTE, JOIN, hay window function — các bài kế tiếp).

Lỗi thường gặp & mẹo

Lỗi 1 — Scalar subquery trả về nhiều dòng. Khi bạn viết WHERE total_amount > (SELECT total_amount FROM ... ) mà subquery lỡ trả về nhiều dòng, hệ quản trị sẽ báo lỗi kiểu "subquery returns more than one row". Mẹo: nếu bạn cần so sánh với một tập giá trị, dùng IN, ANY, hoặc ALL thay vì toán tử so sánh đơn.

Lỗi 2 — Cạm bẫy NULL với NOT IN. Đây là lỗi kinh điển khiến BA trả sai số liệu mà không hề hay biết. Nếu subquery trong NOT IN chứa dù chỉ một giá trị NULL, toàn bộ query sẽ trả về rỗng — không lỗi, không cảnh báo, chỉ là 0 dòng một cách âm thầm. Lý do nằm ở logic ba trạng thái của SQL. Mẹo: ưu tiên NOT EXISTS thay cho NOT IN, hoặc lọc NULL trong subquery bằng WHERE col IS NOT NULL.

Lỗi 3 — Quên alias cho derived table. Mọi subquery trong mệnh đề FROM bắt buộc phải có tên (alias). Thiếu nó, PostgreSQL báo lỗi cú pháp ngay. Mẹo: tập thói quen đặt alias có ý nghĩa như customer_summary thay vì t1, để query dễ đọc về sau.

Lỗi 4 — Lạm dụng correlated subquery gây chậm. Correlated subquery chạy lại cho từng dòng query ngoài. Trên bảng vài triệu dòng, điều này có thể biến một query lẽ ra chạy vài giây thành chạy vài phút. Mẹo: nếu thấy chậm, hãy nghĩ xem có thể viết lại bằng JOIN với một bảng tổng hợp (GROUP BY) hay không — thường nhanh hơn nhiều.

Lỗi 5 — Dùng aggregate trực tiếp trong WHERE. Viết WHERE total_amount > AVG(total_amount) sẽ báo lỗi vì WHERE được đánh giá trước khi gom nhóm. Đây chính xác là lý do scalar subquery tồn tại — nó tính trước con số rồi mới đem so sánh.

Mẹo chung: Khi một query có nhiều tầng subquery lồng nhau khiến bạn phải cuộn lên cuộn xuống để hiểu, đó là tín hiệu nên chuyển sang CTE (bài tiếp theo) để tách từng tầng thành các khối có tên, dễ đọc hơn nhiều.

Bài tập thực hành

Giả sử bạn có schema đơn giản với bảng customers(customer_id, customer_name, city), orders(order_id, customer_id, order_date, total_amount), và products(product_id, name, price).

  • Scalar subquery: Viết query liệt kê các sản phẩm có giá cao hơn giá trung bình của tất cả sản phẩm. Sắp xếp giảm dần theo giá.
  • Table subquery với IN: Tìm tên những khách hàng đã đặt ít nhất một đơn trong tháng 6/2026. Dùng IN với một subquery lấy danh sách customer_id.
  • Correlated subquery: Liệt kê các đơn hàng có total_amount lớn hơn giá trị trung bình các đơn của chính khách hàng đó. (Gợi ý: tham chiếu customer_id của bảng ngoài bên trong subquery.)
  • NOT EXISTS: Tìm các khách hàng chưa từng đặt đơn nào. So sánh kết quả với cách viết bằng NOT IN và quan sát điều gì xảy ra nếu cột customer_id trong orders có giá trị NULL.
  • Derived table: Tính số đơn hàng trung bình mỗi khách theo từng thành phố. (Gợi ý: subquery trong FROM gom nhóm theo customer trước, rồi query ngoài gom theo city.)
Hãy chạy thử từng subquery một cách độc lập trước khi ghép vào query hoàn chỉnh — đó là cách tốt nhất để bắt lỗi sớm.

Tóm tắt

Subquery là công cụ giúp BA chuyển từ tư duy "lấy dữ liệu" sang "đặt câu hỏi nhiều tầng". Có hai cách phân loại bạn cần nắm chắc. Theo kết quả trả về: scalar (một giá trị, dùng trong so sánh), row (một dòng nhiều cột), và table (nhiều dòng, dùng với IN/EXISTS hoặc làm derived table trong FROM). Theo mối liên hệ: non-correlated chạy độc lập một lần, còn correlated tham chiếu cột bảng ngoài và chạy lại cho từng dòng.

Quy tắc thực hành quan trọng nhất: tách yêu cầu nghiệp vụ thành câu hỏi phụ và câu hỏi chính, xác định hình dạng kết quả, rồi quyết định độc lập hay tương quan. Luôn chạy thử subquery một mình trước khi ghép. Cảnh giác với cạm bẫy NULL trong NOT IN — hãy ưu tiên NOT EXISTS. Và khi correlated subquery trở nên chậm hoặc query lồng quá sâu khó đọc, đó là lúc nghĩ đến CTE và window function ở những bài tiếp theo. Nắm vững subquery, bạn đã có thể trả lời phần lớn các câu hỏi phân tích nhiều tầng mà công việc BA đặt ra hằng ngày.