SQL là kỹ năng nền tảng số một khi làm việc với dữ liệu: hầu hết dữ liệu doanh nghiệp nằm trong quan hệ (relational) và data warehouse. Bài này đi từ thứ tự thực thi logic của SELECT, các loại JOIN, tổng hợp & GROUP BY, subquery vs CTE, tới window functions và một loạt mẫu truy vấn hay gặp trong phỏng vấn DS/DA.
Trước khi mô hình hoá hay vẽ biểu đồ, bạn phải lấy đúng dữ liệu. Trong thực tế, phần lớn dữ liệu doanh nghiệp sống trong CSDL quan hệ (PostgreSQL, MySQL) hoặc data warehouse (BigQuery, Snowflake, Redshift) — và SQL là cách truy cập chuẩn.
Ta viết câu lệnh theo thứ tự SELECT → FROM → WHERE..., nhưng DB thực thi logic theo thứ tự khác. Hiểu điều này giải thích nhiều lỗi thường gặp:
| # | Bước | Làm gì |
|---|---|---|
| 1 | FROM / JOIN | Xác định & kết hợp các bảng nguồn. |
| 2 | WHERE | Lọc từng dòng trước khi nhóm. Không dùng được alias của SELECT hay hàm tổng hợp. |
| 3 | GROUP BY | Gom các dòng thành nhóm. |
| 4 | HAVING | Lọc trên nhóm — dùng được hàm tổng hợp (vd HAVING COUNT(*) > 5). |
| 5 | SELECT | Tính biểu thức & chọn cột; alias được tạo ở đây. |
| 6 | ORDER BY | Sắp xếp kết quả — dùng được alias của SELECT. |
| 7 | LIMIT / OFFSET | Cắt số dòng trả về. |
WHERE revenue > 100 nếu revenue là alias sẽ lỗi). Lọc theo kết quả tổng hợp phải dùng HAVING, không phải WHERE. Nhưng ORDER BY (chạy sau SELECT) thì dùng alias thoải mái.SELECT department, AVG(salary) AS avg_sal FROM employees WHERE active = true -- lọc dòng trước GROUP BY department HAVING AVG(salary) > 50000 -- lọc nhóm sau ORDER BY avg_sal DESC -- alias OK ở đây LIMIT 10;
JOIN kết hợp dòng từ nhiều bảng theo điều kiện khớp (thường trên khoá). Hình dung hai bảng customers và orders:
| Loại JOIN | Trả về | Mô tả bằng chữ |
|---|---|---|
| INNER JOIN | Chỉ các dòng khớp ở CẢ hai bảng | Giao của hai tập — khách hàng CÓ đơn hàng. |
| LEFT JOIN | Tất cả dòng bảng trái + khớp bên phải (NULL nếu không khớp) | MỌI khách hàng, kèm đơn nếu có; khách không mua vẫn hiện với cột order = NULL. |
| RIGHT JOIN | Tất cả dòng bảng phải + khớp bên trái | Đối xứng của LEFT; ít dùng (thường viết lại thành LEFT cho dễ đọc). |
| FULL OUTER JOIN | Tất cả dòng cả hai bên, NULL ở phần không khớp | Hợp của hai tập — giữ cả khách không mua lẫn đơn mồ côi. |
| CROSS JOIN | Tích Descartes: mỗi dòng trái × mỗi dòng phải | m×n dòng; dùng sinh lịch/tổ hợp. Cẩn thận bùng nổ số dòng. |
| SELF JOIN | Bảng tự nối với chính nó (dùng alias khác nhau) | So các dòng trong cùng bảng: tìm nhân viên & quản lý của họ trong cùng bảng employees. |
-- LEFT JOIN: mọi khách hàng, kèm số đơn (0 nếu chưa mua) SELECT c.id, c.name, COUNT(o.id) AS n_orders FROM customers c LEFT JOIN orders o ON o.customer_id = c.id GROUP BY c.id, c.name; -- SELF JOIN: gắn tên quản lý cho từng nhân viên SELECT e.name AS employee, m.name AS manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.id;
WHERE sau một LEFT JOIN sẽ vô tình biến nó thành INNER JOIN (vì WHERE loại các dòng có NULL). Nếu muốn giữ dòng không khớp, đặt điều kiện đó vào mệnh đề ON thay vì WHERE.Hàm tổng hợp (aggregate) gộp nhiều dòng thành một giá trị. Kết hợp GROUP BY để tính theo từng nhóm.
| Hàm | Ý nghĩa | Lưu ý |
|---|---|---|
COUNT(*) | Đếm số dòng (kể cả NULL) | COUNT(col) bỏ qua NULL; COUNT(DISTINCT col) đếm giá trị duy nhất. |
SUM(col) | Tổng | Bỏ qua NULL. |
AVG(col) | Trung bình | Bỏ qua NULL (mẫu số không tính NULL) — coi chừng khác với "coi NULL = 0". |
MIN / MAX(col) | Nhỏ nhất / lớn nhất | Dùng được cho số, ngày, chuỗi. |
SELECT category, COUNT(*) AS n_rows, COUNT(DISTINCT customer_id) AS n_customers, SUM(amount) AS revenue, AVG(amount) AS avg_order FROM orders GROUP BY category HAVING SUM(amount) > 10000 -- lọc nhóm theo tổng doanh thu ORDER BY revenue DESC;
Một subquery là truy vấn lồng bên trong truy vấn khác (ở FROM, WHERE hoặc SELECT). Một CTE (Common Table Expression) với WITH đặt tên cho một truy vấn tạm để tái sử dụng, giúp câu lệnh phức tạp đọc dễ như nhiều bước.
-- CTE: dễ đọc, có thể chuỗi nhiều bước, tự tham chiếu (recursive) WITH dept_avg AS ( SELECT department, AVG(salary) AS avg_sal FROM employees GROUP BY department ) SELECT e.name, e.salary, d.avg_sal FROM employees e JOIN dept_avg d ON e.department = d.department WHERE e.salary > d.avg_sal; -- ai lương trên trung bình phòng
Correlated subquery là subquery tham chiếu tới bảng bên ngoài, nên phải chạy lại cho từng dòng ngoài — mạnh nhưng có thể chậm:
SELECT e.name, e.salary FROM employees e WHERE e.salary > ( SELECT AVG(salary) FROM employees WHERE department = e.department -- 'e' đến từ ngoài → correlated );
Khác aggregate (gộp nhiều dòng thành một), window function tính trên một tập dòng liên quan nhưng giữ nguyên từng dòng. Cú pháp lõi:
func() OVER ( PARTITION BY col -- chia thành nhóm (như GROUP BY nhưng không gộp) ORDER BY col -- thứ tự trong mỗi nhóm -- [frame: ROWS BETWEEN ... ] cho running/moving )
Hàm xếp hạng:
| Hàm | Hành vi khi có giá trị bằng nhau (ties) |
|---|---|
ROW_NUMBER() | Đánh số 1,2,3... duy nhất, tie phân xử tuỳ ý → 1,2,3,4 |
RANK() | Tie cùng hạng, sau đó NHẢY số → 1,2,2,4 |
DENSE_RANK() | Tie cùng hạng, KHÔNG nhảy → 1,2,2,3 |
NTILE(n) | Chia dòng thành n nhóm gần bằng nhau (quartile/decile) |
-- Xếp hạng lương trong từng phòng SELECT name, department, salary, ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn, RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rnk, DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS drnk FROM employees;
LAG / LEAD lấy giá trị của dòng trước/sau — lý tưởng để tính chênh lệch theo thời gian:
-- Doanh thu tháng này so với tháng trước & % tăng trưởng SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS prev_rev, revenue - LAG(revenue) OVER (ORDER BY month) AS diff FROM monthly_sales;
Running total & moving average dùng frame:
-- Tổng luỹ kế (running total) SELECT date, amount, SUM(amount) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total, -- Trung bình trượt 7 dòng gần nhất (moving average) AVG(amount) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma_7 FROM daily_sales;
ORDER BY trong OVER mà không ghi frame, nhiều hệ dùng RANGE UNBOUNDED PRECEDING → CURRENT ROW (tức đã là running). Với moving average chính xác theo số dòng, hãy ghi rõ ROWS BETWEEN n PRECEDING AND CURRENT ROW.CASE WHEN là if/else trong SQL — tạo cột phân loại, pivot thủ công, gán nhãn:
SELECT name, score, CASE WHEN score >= 90 THEN 'A' WHEN score >= 70 THEN 'B' ELSE 'C' END AS grade FROM students; -- Pivot bằng CASE + SUM: đếm đơn theo trạng thái trên mỗi cột SELECT region, SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) AS paid, SUM(CASE WHEN status='refunded' THEN 1 ELSE 0 END) AS refunded FROM orders GROUP BY region;
NULL nghĩa "không rõ/thiếu", KHÔNG phải 0 hay chuỗi rỗng. Mọi so sánh với NULL cho kết quả UNKNOWN:
| Hàm / cú pháp | Tác dụng |
|---|---|
IS NULL / IS NOT NULL | Cách DUY NHẤT để kiểm tra NULL. col = NULL luôn sai! |
COALESCE(a, b, c) | Trả về giá trị non-NULL đầu tiên → thay NULL bằng mặc định: COALESCE(discount, 0). |
NULLIF(a, b) | Trả NULL nếu a = b, ngược lại trả a. Hữu ích tránh chia cho 0: x / NULLIF(y, 0). |
WHERE col != 'A' sẽ BỎ SÓT các dòng col IS NULL (vì NULL != 'A' là UNKNOWN, không phải TRUE). Nếu muốn giữ, viết WHERE col != 'A' OR col IS NULL. Ngoài ra AVG/COUNT(col) bỏ qua NULL — cẩn thận khi mẫu số quan trọng.a) Top-N mỗi nhóm (vd 3 nhân viên lương cao nhất mỗi phòng) — dùng ROW_NUMBER trong CTE rồi lọc:
WITH ranked AS ( SELECT name, department, salary, ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn FROM employees ) SELECT name, department, salary FROM ranked WHERE rn <= 3;
b) Lương cao thứ nhì (second highest salary) — dùng DENSE_RANK để xử lý ties đúng:
SELECT MAX(salary) AS second_highest FROM employees WHERE salary < (SELECT MAX(salary) FROM employees); -- Cách tổng quát cho hạng thứ N: WITH r AS ( SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS dr FROM employees ) SELECT DISTINCT salary FROM r WHERE dr = 2;
c) Phát hiện bản ghi trùng (duplicates) theo email:
SELECT email, COUNT(*) AS n FROM users GROUP BY email HAVING COUNT(*) > 1; -- Giữ 1 bản, đánh dấu phần thừa để xoá: WITH d AS ( SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn FROM users ) SELECT id FROM d WHERE rn > 1; -- các dòng dư
d) Retention / cohort cơ bản — % người dùng đăng ký tháng đầu quay lại ở các tháng sau:
WITH cohort AS ( -- tháng đầu tiên của mỗi user = cohort SELECT user_id, MIN(DATE_TRUNC('month', event_date)) AS cohort_month FROM events GROUP BY user_id ), activity AS ( SELECT DISTINCT user_id, DATE_TRUNC('month', event_date) AS active_month FROM events ) SELECT c.cohort_month, a.active_month, COUNT(DISTINCT a.user_id) AS active_users FROM cohort c JOIN activity a ON a.user_id = c.user_id GROUP BY c.cohort_month, a.active_month ORDER BY c.cohort_month, a.active_month;