Bài 4

SQL cho Data Science

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.

1. Vì sao Data Scientist cần SQL mạnh
Ngôn ngữ để lấy & định hình dữ liệu

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.

Chuẩn vs phương ngữ (dialect): cú pháp cốt lõi tuân theo chuẩn ANSI SQL, nhưng mỗi hệ có khác biệt (LIMIT vs TOP, hàm ngày tháng, chuỗi). Ví dụ dưới đây theo phong cách PostgreSQL — chỉnh nhẹ khi đổi hệ.
2. Thứ tự thực thi logic của SELECT
Viết một đằng, chạy một nẻo

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ướcLàm gì
1FROM / JOINXác định & kết hợp các bảng nguồn.
2WHERELọ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.
3GROUP BYGom các dòng thành nhóm.
4HAVINGLọc trên nhóm — dùng được hàm tổng hợp (vd HAVING COUNT(*) > 5).
5SELECTTính biểu thức & chọn cột; alias được tạo ở đây.
6ORDER BYSắp xếp kết quả — dùng được alias của SELECT.
7LIMIT / OFFSETCắt số dòng trả về.
Hệ quả hay vướng: vì WHERE chạy TRƯỚC SELECT, bạn không thể tham chiếu alias trong WHERE (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;
3. JOIN các bảng
INNER · LEFT · RIGHT · FULL · CROSS · SELF

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 customersorders:

Loại JOINTrả vềMô tả bằng chữ
INNER JOINChỉ các dòng khớp ở CẢ hai bảngGiao của hai tập — khách hàng CÓ đơn hàng.
LEFT JOINTấ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 JOINTấ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 JOINTất cả dòng cả hai bên, NULL ở phần không khớpHợp của hai tập — giữ cả khách không mua lẫn đơn mồ côi.
CROSS JOINTích Descartes: mỗi dòng trái × mỗi dòng phảim×n dòng; dùng sinh lịch/tổ hợp. Cẩn thận bùng nổ số dòng.
SELF JOINBả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;
Cạm bẫy: đặt điều kiện lọc bảng phải vào 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.
4. Tổng hợp: GROUP BY & HAVING
COUNT · SUM · AVG · MIN · MAX · DISTINCT

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ĩaLư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ổngBỏ qua NULL.
AVG(col)Trung bìnhBỏ 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ấtDù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;
Quy tắc vàng: mọi cột trong SELECT mà KHÔNG nằm trong hàm tổng hợp thì PHẢI có trong GROUP BY. Muốn lọc trước khi nhóm dùng WHERE; lọc sau khi nhóm dùng HAVING.
5. Subquery vs CTE (WITH)
Truy vấn lồng & correlated subquery

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
);
Chọn cái nào: CTE tốt cho tính dễ đọc, dùng lại nhiều lần và truy vấn đệ quy (cây/đồ thị). Subquery gọn cho phép so đơn giản. Về hiệu năng thường tương đương — ưu tiên độ rõ ràng, rồi tối ưu khi đo được là chậm.
6. Window Functions
Tính toán trên "cửa sổ" dòng mà KHÔNG gộp dòng

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àmHà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;
Ghi nhớ: mặc định khi có 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.
7. CASE WHEN & xử lý NULL
Logic điều kiện & giá trị thiếu

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ápTác dụng
IS NULL / IS NOT NULLCá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).
Bẫy NULL: 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.
8. Mẫu truy vấn phỏng vấn
Top-N mỗi nhóm · second highest · dedup · retention

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;
Mẹo phỏng vấn: nói to cách tiếp cận trước khi gõ. Với top-N/dedup/hạng-thứ-N, gần như luôn có lời giải bằng window function trong CTE — sạch và dễ giải thích hơn subquery lồng nhiều tầng. Luôn hỏi rõ cách xử lý ties và NULL.
← Bài trước
Bài 3: Xác suất & Thống kê