SQL cho Interview — Lộ trình 10 tuần
Nhấn phím → để bắt đầu · T để xem mục lục
Hai tuần trước bạn đã làm chủ cấp độ "một dòng vào, một dòng ra" — lọc và biến đổi. Tuần này nâng cấp lên cấp độ "nhiều dòng vào, một dòng ra" — gốc rễ của phân tích dữ liệu trong SQL.
GROUP BY là phân hoạch tập dữ liệu thành các nhóm theo khóa, sau đó reduce mỗi nhóm thành một dòng bằng aggregator.
SQL: SELECT customer, SUM(total) FROM orders GROUP BY customer;
Đây là chủ đề bị nhầm nhiều nhất. Cho bảng customers(id, country) với 5 dòng:
| id | country |
|---|---|
| 1 | Vietnam |
| 2 | Vietnam |
| 3 | NULL |
| 4 | Australia |
| 5 | NULL |
| Biểu thức | Kết quả | Ý nghĩa |
|---|---|---|
| COUNT(*) | 5 | Đếm số dòng (kể cả dòng toàn NULL) |
| COUNT(country) | 3 | Đếm dòng có country không NULL |
| COUNT(DISTINCT country) | 2 | Đếm số quốc gia khác nhau (bỏ NULL) |
| COUNT(1) | 5 | Tương đương COUNT(*) — không phụ thuộc cột |
Tất cả aggregator (trừ COUNT(*)) bỏ qua NULL trước khi tính:
| Cột salary | Aggregator | Kết quả |
|---|---|---|
| {100, 200, NULL, 300} | SUM(salary) | 600 |
| {100, 200, NULL, 300} | AVG(salary) | 200 (= 600/3, không phải 600/4) |
| {100, 200, NULL, 300} | MIN(salary) | 100 |
| {100, 200, NULL, 300} | MAX(salary) | 300 |
| {NULL, NULL} | SUM(salary) | NULL (không phải 0) |
| {} (rỗng) | SUM(salary) | NULL (không phải 0) |
"Trung bình lương" với NULL: AVG bỏ NULL ⇒ chia cho số dòng không-NULL. Nếu bạn muốn coi NULL là 0:
AVG(COALESCE(salary, 0)) -- coi NULL như 0
SUM(salary) / COUNT(*) -- chia cho tổng số dòng
Hai biểu thức trên cho kết quả khác AVG mặc định. Trong interview, luôn xác nhận với người ra đề: "NULL có tham gia trung bình không?"
SQL kế thừa quy tắc số học từ kiểu dữ liệu — và kiểu INT chia INT có thể không như bạn mong đợi.
-- 3 đơn paid trên 7 đơn tổng
SELECT
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) / COUNT(*) AS rate
FROM orders;
-- MySQL/PostgreSQL: 0.4286 (decimal — OK)
-- SQL Server: 0 (integer division!)
SQL Server (và một số phương ngữ khác) thực hiện integer division khi cả hai vế đều INT. 3 / 7 = 0.
-- Cast một vế sang DECIMAL/FLOAT
SUM(CASE WHEN status = 'paid' THEN 1.0 ELSE 0 END) / COUNT(*)
-- Hoặc nhân với 1.0
1.0 * paid_count / total_count
-- AVG bỏ trắng việc đếm — tự nó đã là float trong nhiều dialect
AVG(CASE WHEN status = 'paid' THEN 1.0 ELSE 0 END)
SELECT customer_id, COUNT(*) AS n_orders, SUM(total) AS revenue
FROM orders
GROUP BY customer_id;
Sau khi GROUP BY, mỗi dòng kết quả đại diện cho một nhóm — không phải một dòng gốc. Nếu bạn yêu cầu cột không thuộc khóa nhóm và không aggregate, SQL không biết chọn dòng nào trong nhóm.
-- SAI (chuẩn ANSI và PostgreSQL/SQL Server reject)
SELECT customer_id, name, COUNT(*)
FROM orders GROUP BY customer_id;
-- name không trong GROUP BY và không aggregate → ambiguous
Khi GROUP BY nhiều cột, nhóm được định nghĩa bởi tổ hợp các giá trị.
-- Đếm số đơn theo cặp (customer, status)
SELECT customer_id, status, COUNT(*) AS n
FROM orders
GROUP BY customer_id, status;
Một customer có thể xuất hiện nhiều dòng nếu có đơn ở nhiều status khác nhau. Ví dụ:
| customer_id | status | n |
|---|---|---|
| 1 | paid | 3 |
| 1 | cancelled | 1 |
| 2 | paid | 5 |
-- Số đơn và doanh thu theo ngày + theo status
SELECT order_date, status, COUNT(*) AS n_orders, SUM(total) AS revenue
FROM orders
GROUP BY order_date, status
ORDER BY order_date, status;
Khác với hầu hết hành vi của NULL trong SQL, GROUP BY coi tất cả NULL là cùng một nhóm.
SELECT country, COUNT(*) FROM customers GROUP BY country;
Trả về 3 dòng:
| country | count |
|---|---|
| Vietnam | 2 |
| Australia | 1 |
| NULL | 1 |
-- PostgreSQL/MySQL cho phép
SELECT YEAR(order_date) AS y, COUNT(*) FROM orders GROUP BY y;
-- Standard ANSI yêu cầu lặp lại expression — chạy mọi nơi
SELECT YEAR(order_date) AS y, COUNT(*) FROM orders GROUP BY YEAR(order_date);
Trong portable code, lặp expression. Trong code dialect-specific, dùng alias cho ngắn.
Đây là sơ đồ phải thuộc. Cú pháp viết theo thứ tự nào không quan trọng — engine luôn thực thi theo logic này:
| Tiêu chí | WHERE | HAVING |
|---|---|---|
| Lọc cái gì | Dòng gốc | Nhóm sau khi GROUP BY |
| Khi nào chạy | Trước GROUP BY | Sau GROUP BY |
| Dùng được aggregator? | Không (WHERE COUNT(*) > 5 sai) | Có (HAVING COUNT(*) > 5 đúng) |
| Dùng được cột bất kỳ? | Có (mọi cột bảng) | Chỉ cột trong GROUP BY hoặc aggregator |
-- 1. "Khách hàng có hơn 5 đơn paid" — KHÔNG phải hơn 5 đơn tổng
SELECT customer_id, COUNT(*) AS n_paid
FROM orders
WHERE status = 'paid' -- lọc dòng trước khi đếm
GROUP BY customer_id
HAVING COUNT(*) > 5; -- lọc nhóm theo số đếm
Hai bộ lọc làm hai việc khác nhau và không thay thế cho nhau được.
Quy tắc đơn giản: nếu predicate dùng aggregator, đặt vào HAVING. Ngược lại, đặt vào WHERE.
-- Cách 1: filter dòng trước
SELECT customer_id FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE c.country = 'Vietnam'
GROUP BY o.customer_id
HAVING COUNT(*) > 3;
-- Cách 2: HAVING làm tất (không khuyên — chậm hơn)
SELECT o.customer_id FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
GROUP BY o.customer_id, c.country
HAVING c.country = 'Vietnam' AND COUNT(*) > 3;
Đây là pattern giải được khoảng 30% bài LeetCode SQL Medium. Ý tưởng: aggregate có điều kiện bằng cách bọc CASE WHEN bên trong aggregator.
SELECT
COUNT(*) AS total_orders,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS n_paid,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS n_cancelled,
SUM(CASE WHEN total > 1000 THEN 1 ELSE 0 END) AS n_big
FROM orders;
Kết quả: một dòng, 4 cột — tất cả tính từ một lần quét bảng duy nhất. Hiệu quả gấp nhiều lần chạy 4 query riêng.
Trick: COUNT bỏ NULL. Nếu CASE không có ELSE thì giá trị mặc định là NULL — ta tận dụng để viết ngắn:
-- Hai biểu thức tương đương:
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END)
COUNT(CASE WHEN status = 'paid' THEN 1 END)
-- (Không có ELSE → giá trị NULL → COUNT bỏ qua)
-- Trước
SELECT
SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) AS n_paid,
SUM(CASE WHEN status='cancelled' THEN 1 ELSE 0 END) AS n_cancelled
FROM orders;
-- Sau (gọn hơn)
SELECT
COUNT(CASE WHEN status='paid' THEN 1 END) AS n_paid,
COUNT(CASE WHEN status='cancelled' THEN 1 END) AS n_cancelled
FROM orders;
Cả hai đều đúng, độ rõ ràng tương đương — chọn pattern bạn dùng nhất quán. Trong code review, tránh trộn cả hai trong cùng một query.
Đây là một trong những use case đẹp nhất của conditional aggregation: chuyển dữ liệu "long format" thành "wide format".
Bảng sales(customer_id, status, amount) ở dạng long. Yêu cầu: với mỗi khách, hiển thị cùng dòng các cột paid, pending, cancelled.
SELECT
customer_id,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'cancelled' THEN amount ELSE 0 END) AS cancelled
FROM sales
GROUP BY customer_id;
Pattern phổ biến: tính tỷ lệ dòng thỏa điều kiện X trên tổng số dòng (hoặc tổng có điều kiện Y).
SELECT
customer_id,
COUNT(*) AS n_total,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS n_paid,
-- 1.0 phòng integer division
SUM(CASE WHEN status = 'paid' THEN 1.0 ELSE 0 END) / COUNT(*) AS paid_rate
FROM orders
GROUP BY customer_id;
SELECT
SUM(CASE WHEN status='paid' AND total > 1000 THEN 1.0 ELSE 0 END)
/ NULLIF(SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END), 0) AS big_paid_rate
FROM orders;
Chú ý NULLIF(..., 0) phòng chia cho 0 khi không có đơn paid nào.
Một số phương ngữ hỗ trợ FILTER (WHERE ...) — cú pháp ANSI cho conditional aggregation, đọc tự nhiên hơn CASE WHEN.
-- PostgreSQL, SQLite 3.30+
SELECT
customer_id,
COUNT(*) AS n_total,
COUNT(*) FILTER (WHERE status='paid') AS n_paid,
SUM(total) FILTER (WHERE status='paid') AS revenue_paid
FROM orders
GROUP BY customer_id;
| Phương ngữ | Hỗ trợ FILTER? |
|---|---|
| PostgreSQL | Có (từ 9.4) |
| SQLite | Có (từ 3.30) |
| MySQL | Không — phải CASE WHEN |
| SQL Server | Không — phải CASE WHEN |
Trả về tên các lớp có ít nhất 5 học sinh.
SELECT class
FROM Courses
GROUP BY class
HAVING COUNT(DISTINCT student) >= 5;
Đề bài nói (student, class) là composite key — không có trùng. Nhưng các bài tương tự trong production thường KHÔNG có ràng buộc này. COUNT(DISTINCT student) an toàn hơn COUNT(*) trong general case.
Trong interview, nói rõ: "Tôi dùng DISTINCT để phòng dữ liệu có dòng trùng. Nếu được đảm bảo bảng không có duplicate, COUNT(*) đủ."
Với mỗi (emp_id, event_day), tính tổng số phút làm việc. Một nhân viên có thể vào/ra nhiều lần trong ngày.
SELECT
event_day AS day,
emp_id,
SUM(out_time - in_time) AS total_time
FROM Employees
GROUP BY event_day, emp_id;
Với mỗi stock, tính tổng lãi/lỗ = (tổng tiền Sell) − (tổng tiền Buy).
Với mỗi stock: cần một số dương từ Sell và một số âm từ Buy, sau đó tổng. Dùng conditional aggregation:
-- Cách 1: SUM(CASE WHEN)
SELECT
stock_name,
SUM(CASE WHEN operation = 'Sell' THEN price
WHEN operation = 'Buy' THEN -price
END) AS capital_gain_loss
FROM Stocks
GROUP BY stock_name;
-- Cách 2: tách hai SUM rồi trừ
SELECT
stock_name,
SUM(CASE WHEN operation = 'Sell' THEN price ELSE 0 END)
- SUM(CASE WHEN operation = 'Buy' THEN price ELSE 0 END) AS capital_gain_loss
FROM Stocks
GROUP BY stock_name;
Cho query sau, dự đoán kết quả trên dữ liệu có NULL và giải thích:
SELECT customer_id,
COUNT(*) AS a,
COUNT(country) AS b,
COUNT(DISTINCT country) AS c,
SUM(CASE WHEN country IS NULL THEN 1 ELSE 0 END) AS d
FROM customers
GROUP BY customer_id;
Quan hệ giữa a, b, c, d? Cái nào có thể bằng 0? Cái nào có thể NULL?
Nhấn T hoặc Escape để đóng