Bài 16: Tối Ưu Subquery Và CTE – Khi Nào Dùng EXISTS/IN?

"Subquery lồng nhau như ma trận – hãy chọn lối thoát bằng JOIN hoặc MATERIALIZED VIEW!"


1. Vấn Đề: Subquery Lồng 3 Tầng Gây Chậm Query

Scenario:
3 bảng dữ liệu:

  • employees (50,000 nhân viên, dept_id, salary).

  • departments (100 phòng ban, dept_id, budget).

  • projects (10,000 dự án, project_id, dept_id, status).

Yêu cầu: Tìm các phòng ban có ngân sách > 1 triệu, ít nhất 5 nhân viên lương > 5000, và đang chạy ít nhất 3 dự án "active".
Query ban đầu (sử dụng subquery lồng):

SELECT d.dept_id  
FROM departments d  
WHERE d.budget > 1000000  
  AND (SELECT COUNT(*) FROM employees e  
       WHERE e.dept_id = d.dept_id AND e.salary > 5000) >= 5  
  AND (SELECT COUNT(*) FROM projects p  
       WHERE p.dept_id = d.dept_id AND p.status = 'active') >= 3;

Kết quả: Thời gian thực thi ~8 giây.


2. Phân Tích Execution Plan

Bước 1: Chạy EXPLAIN ANALYZE (PostgreSQL)
Seq Scan on departments d (cost=0.00..250000.00 rows=10 width=4) 
  (actual time=8000.123..8000.456 rows=20 loops=1)  
  Filter: (budget > 1000000)  
  Rows Removed by Filter: 80  
  SubPlan 1  
    -> Aggregate (cost=1500.00..1500.01 rows=1) 
      (actual time=50.123..50.123 rows=1 loops=20)  
      -> Seq Scan on employees e (cost=0.00..1000.00 rows=20000 width=0) 
        (actual time=0.012..30.123 rows=150 loops=20)  
        Filter: (dept_id = d.dept_id AND salary > 5000)  
  SubPlan 2  
    -> Aggregate (cost=200.00..200.01 rows=1) 
      (actual time=20.456..20.456 rows=1 loops=20)  
      -> Seq Scan on projects p (cost=0.00..150.00 rows=1000 width=0) 
        (actual time=0.010..10.456 rows=300 loops=20)  
        Filter: (dept_id = d.dept_id AND status = 'active')
Bước 2: Giải Mã Vấn Đề
  • 2 Subquery độc lập: Mỗi vòng lặp của departments quét toàn bộ employeesprojects → Tổng cộng 20 * (150 + 300) = 9,000 lần quét.

  • Bottleneck Chính:

    1. Correlated Subquery: Subquery phụ thuộc vào d.dept_id → Không thể tối ưu hóa độc lập.

    2. Full Scan trên employeesprojects: Thiếu index trên dept_id và các điều kiện lọc.


3. Tối Ưu: Rewrite Subquery Thành JOIN Và Sử Dụng Materialized View

Bước 1: Rewrite Subquery Thành JOIN
WITH dept_stats AS (  
  SELECT  
    e.dept_id,  
    COUNT(*) FILTER (WHERE e.salary > 5000) AS high_earners,  
    COUNT(p.project_id) FILTER (WHERE p.status = 'active') AS active_projects  
  FROM employees e  
  LEFT JOIN projects p ON e.dept_id = p.dept_id  
  GROUP BY e.dept_id  
)  
SELECT d.dept_id  
FROM departments d  
JOIN dept_stats s ON d.dept_id = s.dept_id  
WHERE d.budget > 1000000  
  AND s.high_earners >= 5  
  AND s.active_projects >= 3;
Bước 2: Thêm Index Trên Cột JOIN và Điều Kiện Lọc
CREATE INDEX idx_employees_dept_salary ON employees(dept_id, salary);  
CREATE INDEX idx_projects_dept_status ON projects(dept_id, status);
Bước 3: Execution Plan Sau Khi Tối Ưu
Hash Join (cost=5000.00..8000.00 rows=20 width=4) 
  (actual time=500.123..520.456 rows=20 loops=1)  
  Hash Cond: (d.dept_id = s.dept_id)  
  -> Seq Scan on departments d (cost=0.00..200.00 rows=10 width=4) 
    (actual time=0.012..0.123 rows=20 loops=1)  
    Filter: (budget > 1000000)  
  -> Hash (cost=4500.00..4500.00 rows=100 width=12) 
    (actual time=500.012..500.012 rows=100 loops=1)  
    -> Subquery Scan on s (cost=3000.00..4500.00 rows=100 width=12) 
      (actual time=300.123..480.789 rows=100 loops=1)  
      -> GroupAggregate (cost=3000.00..4400.00 rows=100 width=12)  
        Group Key: e.dept_id  
        -> Nested Loop Left Join (cost=0.42..3000.00 rows=100000 width=8)  
          -> Index Only Scan using idx_employees_dept_salary on employees e 
            (cost=0.42..1000.00 rows=50000 width=4)  
          -> Index Scan using idx_projects_dept_status on projects p 
            (cost=0.42..0.50 rows=1 width=4)  
              Index Cond: (dept_id = e.dept_id AND status = 'active')
Kết Quả:
  • Loại Bỏ Correlated Subquery: Dùng CTE kết hợp JOIN → Chỉ quét employeesprojects 1 lần.

  • Index Only Scan: Tận dụng index trên dept_id và điều kiện lọc.

  • Giảm Execution Time: Từ 8 giây0.52 giây (93% cải thiện).


4. Tổng Kết

Chỉ SốTrước Khi Tối ƯuSau Khi Tối Ưu
Total Cost250,0008,000
Execution Time8,000 ms520 ms
Số Lần Quét Bảng9,0001
Phương Pháp Truy VấnCorrelated SubqueryCTE + JOIN

Lý Do Hiệu Quả:

  • CTE + JOIN giúp tổng hợp dữ liệu 1 lần, tránh lặp lại subquery.

  • Index trên điều kiện lọc giảm I/O đáng kể.


5. Bài Tập Thực Hành

Dataset Mẫu:
CREATE TABLE students (  
    student_id INT PRIMARY KEY,  
    class_id INT,  
    score INT  
);  
CREATE TABLE classes (  
    class_id INT PRIMARY KEY,  
    teacher_id INT  
);  
CREATE TABLE exams (  
    exam_id INT PRIMARY KEY,  
    class_id INT,  
    exam_date DATE  
);
Yêu Cầu:
  1. Viết query tìm lớp học có:

    • Ít nhất 20 học sinh điểm > 80.

    • Ít nhất 5 kỳ thi trong năm 2024.

  2. Phân tích Execution Plan của query sử dụng subquery.

  3. Rewrite query bằng JOIN hoặc CTE và đo lường hiệu suất.

Câu Hỏi:
  • Khi nào nên sử dụng EXISTS thay vì IN trong subquery?

6. Mở Rộng & Thảo Luận

So Sánh EXISTS vs IN
Phương ThứcCơ ChếHiệu Suất
EXISTSDừng khi tìm thấy kết quả đầu tiênTốt hơn khi subquery trả về nhiều dòng
INSo sánh tất cả giá trịTốt hơn khi subquery trả về ít dòng

Ví Dụ:

-- Sử dụng EXISTS  
SELECT * FROM departments d  
WHERE EXISTS (  
  SELECT 1 FROM employees e  
  WHERE e.dept_id = d.dept_id AND e.salary > 5000  
);  

-- Sử dụng IN  
SELECT * FROM departments d  
WHERE d.dept_id IN (  
  SELECT dept_id FROM employees  
  WHERE salary > 5000  
);
Khi Nào Dùng MATERIALIZED VIEW?
  • Dữ liệu ít thay đổi: Ví dụ báo cáo tổng hợp hàng ngày.

  • Query phức tạp, tốn tài nguyên: Tổng hợp trước dữ liệu để tăng tốc truy vấn.
    Cài Đặt (PostgreSQL):

CREATE MATERIALIZED VIEW mv_dept_stats AS  
SELECT  
  dept_id,  
  COUNT(*) FILTER (WHERE salary > 5000) AS high_earners,  
  COUNT(*) FILTER (WHERE status = 'active') AS active_projects  
FROM employees  
LEFT JOIN projects USING (dept_id)  
GROUP BY dept_id;  

-- Query từ Materialized View  
SELECT * FROM mv_dept_stats WHERE high_earners >= 5;
Edge Case: Correlated Subquery Không Thể Tránh
  • Ví dụ: Tính lương trung bình của từng phòng ban và so sánh với lương từng nhân viên:

      SELECT e.*,  
        (SELECT AVG(salary) FROM employees WHERE dept_id = e.dept_id) AS avg_salary  
      FROM employees e;
    
  • Giải Pháp: Sử dụng Window Function:

      SELECT e.*, AVG(salary) OVER (PARTITION BY dept_id) AS avg_salary  
      FROM employees e;
    

Kết Luận

Subquery và CTE là công cụ mạnh nhưng dễ trở thành "điểm chết" hiệu suất. Bằng cách rewrite thành JOIN và sử dụng index phù hợp, thời gian thực thi đã giảm từ 8 giây xuống 0.5 giây. Trong bài tiếp theo (Bài 17), chúng ta sẽ xử lý các query phức tạp bằng phân rã thành nhiều bước!

👉 Bài Tập Về Nhà: Tạo MATERIALIZED VIEW cho bảng studentsexams, so sánh hiệu suất khi query dữ liệu tổng hợp!