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ộemployees
vàprojects
→ Tổng cộng 20 * (150 + 300) = 9,000 lần quét.Bottleneck Chính:
Correlated Subquery: Subquery phụ thuộc vào
d.dept_id
→ Không thể tối ưu hóa độc lập.Full Scan trên
employees
vàprojects
: Thiếu index trêndept_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
employees
vàprojects
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ây → 0.52 giây (93% cải thiện).
4. Tổng Kết
Chỉ Số | Trước Khi Tối Ưu | Sau Khi Tối Ưu |
Total Cost | 250,000 | 8,000 |
Execution Time | 8,000 ms | 520 ms |
Số Lần Quét Bảng | 9,000 | 1 |
Phương Pháp Truy Vấn | Correlated Subquery | CTE + 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:
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.
Phân tích Execution Plan của query sử dụng subquery.
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ức | Cơ Chế | Hiệu Suất |
EXISTS | Dừng khi tìm thấy kết quả đầu tiên | Tốt hơn khi subquery trả về nhiều dòng |
IN | So 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 students
và exams
, so sánh hiệu suất khi query dữ liệu tổng hợp!