SQL cho Interview — Lộ trình 10 tuần
Nhấn phím → để bắt đầu · T để xem mục lục
Window functions là kỹ năng phân biệt sinh viên SQL trung bình với senior. Nếu bạn nắm được tuần này, bạn sẽ:
Window function khác với aggregate ở điểm cốt lõi:
| Aggregate (GROUP BY) | Window function | |
|---|---|---|
| Số dòng đầu vào | n | n |
| Số dòng đầu ra | 1 dòng / nhóm (collapse) | n dòng (preserve) |
| Cột không-aggregate | Phải trong GROUP BY | Tùy ý — không bị ràng buộc |
| Mỗi dòng output | Đại diện một nhóm | Đại diện một dòng gốc, kèm "view" lên dữ liệu xung quanh |
Hình dung: với mỗi dòng, engine cho bạn nhìn qua một "cửa sổ" (window) đến tập dữ liệu — bạn có thể tính chỉ số dựa trên dữ liệu trong cửa sổ đó, mà không mất chính dòng đang xét.
-- GROUP BY: 1 dòng / phòng
SELECT dept, AVG(salary) FROM employees GROUP BY dept;
-- Window: mọi dòng employee, kèm AVG phòng họ
SELECT name, dept, salary,
AVG(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees;
Window cho bạn tính chỉ số tham chiếu mà không mất chi tiết.
function_name([args]) OVER (
[PARTITION BY col1, col2, ...] -- (1) phân hoạch tập
[ORDER BY col3 [ASC|DESC]] -- (2) thứ tự trong mỗi partition
[frame_clause] -- (3) khung tính toán
)
Ba thành phần đều tùy chọn, nhưng kết hợp khác nhau cho semantic khác nhau:
| Có gì | Ý nghĩa |
|---|---|
| chỉ OVER () | Toàn bộ bảng là một window — tính trên cả bảng |
| chỉ PARTITION BY | Chia tập thành nhóm độc lập, không thứ tự |
| chỉ ORDER BY | Toàn bộ tập, có thứ tự — frame mặc định áp dụng |
| cả hai | Phổ biến nhất — chia nhóm rồi sắp xếp trong nhóm |
So với GROUP BY: GROUP BY collapse mỗi nhóm thành 1 dòng. PARTITION BY tạo phạm vi tính trong khi vẫn trả 6 dòng gốc.
| GROUP BY | PARTITION BY (window) | |
|---|---|---|
| Vị trí | Mệnh đề riêng GROUP BY | Trong OVER (...) |
| Dòng output | 1 dòng / nhóm | n dòng (tất cả input) |
| Cột non-aggregate | Phải trong GROUP BY | Lấy thoải mái — chính cột của dòng đó |
| Filter sau | HAVING | Không có WHERE/HAVING — phải bao subquery/CTE |
| Use case | Tổng kết, báo cáo | Ranking, running total, so dòng-dòng, chỉ số tham chiếu |
-- SAI — không thể WHERE trên window function trực tiếp
SELECT name, ROW_NUMBER() OVER (...) AS rn
FROM employees WHERE rn = 1;
-- ĐÚNG — bao bằng subquery hoặc CTE
SELECT * FROM (
SELECT name, ROW_NUMBER() OVER (...) AS rn FROM employees
) t WHERE rn = 1;
Lý do: window function chạy sau WHERE/GROUP BY/HAVING trong thứ tự thực thi. Bạn chỉ truy cập kết quả nó qua một "tầng" subquery hoặc CTE.
-- Đánh số employee theo salary giảm dần, trong từng phòng
SELECT name, dept, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employees;
| name | dept | salary | rn |
|---|---|---|---|
| Chi | Eng | 120 | 1 |
| Fa | Eng | 110 | 2 |
| An | Eng | 100 | 3 |
| Bo | Mkt | 80 | 1 |
| En | Mkt | 70 | 2 |
SELECT name, dept, salary FROM (
SELECT name, dept, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employees
) t WHERE rn <= 3;
Đây là pattern thay thế cho cả correlated subquery và self-join + derived table tuần 5. Gọn hơn, nhanh hơn, xử lý ties tốt hơn (sẽ thấy ở slide tiếp).
Đây là khác biệt phải thuộc. Cho dữ liệu salary {120, 100, 100, 95}:
| Salary | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 120 | 1 | 1 | 1 |
| 100 | 2 | 2 | 2 |
| 100 | 3 | 2 (ties) | 2 (ties) |
| 95 | 4 | 4 (skip 3) | 3 (no skip) |
Đây là pattern interview xuất hiện nhiều nhất trong các bài Medium/Hard. Xương sống:
SELECT cols
FROM (
SELECT cols,
ROW_NUMBER() OVER (
PARTITION BY group_key
ORDER BY rank_key [DESC]
) AS rn
FROM table
) t
WHERE rn <= N;
| Yêu cầu | Function |
|---|---|
| "Top 3 nhân viên cao lương nhất mỗi phòng" | ROW_NUMBER (cố định 3 dòng) |
| "Top 3 mức lương khác nhau mỗi phòng" | DENSE_RANK |
| "Top 3 hạng (có thể >3 nhân viên)" | RANK |
| "Đơn mới nhất của mỗi khách" | ROW_NUMBER với ORDER BY date DESC, lấy rn = 1 |
| "Sản phẩm bán chạy thứ 2 mỗi category" | DENSE_RANK với rn = 2 |
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY date DESC, order_id DESC)
Trong mock interview, nói rõ tie-breaker này — interviewer thường hỏi "nếu ties thì sao?".
-- Chia khách thành 4 quartile theo doanh thu
SELECT
customer_id, revenue,
NTILE(4) OVER (ORDER BY revenue DESC) AS quartile
FROM customer_revenue;
-- quartile 1 = top 25% spenders, 4 = bottom 25%
Nếu số dòng không chia hết cho N, các nhóm đầu nhận thêm 1 dòng.
-- So sánh doanh thu hôm nay với hôm qua, mỗi store
SELECT
store_id, sale_date, revenue,
LAG(revenue) OVER (PARTITION BY store_id ORDER BY sale_date) AS prev_revenue,
revenue - LAG(revenue) OVER (PARTITION BY store_id ORDER BY sale_date) AS delta
FROM daily_sales;
LAG(col, 2, 0) -- nhìn về dòng cách 2, default 0 nếu thiếu
LAG(col, 1, NULL) -- equivalent với LAG(col)
revenue - LAG(revenue) OVER (PARTITION BY store_id ORDER BY date) AS day_over_day
LEAD đối xứng với LAG — nhìn về dòng sau.
-- Dự đoán: ngày tiếp theo có sale lớn hơn hôm nay không?
SELECT
sale_date, revenue,
LEAD(revenue) OVER (ORDER BY sale_date) AS next_revenue,
CASE WHEN LEAD(revenue) OVER (ORDER BY sale_date) > revenue
THEN 'increasing'
ELSE 'flat or decreasing'
END AS trend
FROM daily_sales;
SELECT user_id, login_date,
CASE WHEN DATEDIFF(login_date, LAG(login_date) OVER (PARTITION BY user_id ORDER BY login_date)) = 1
THEN 'consecutive' ELSE 'new streak'
END AS streak_type
FROM logins;
Tuần 8 sẽ học pattern gaps and islands hoàn chỉnh.
Bài này tuần 4 đã giải bằng self-join. Giờ với LAG, gọn hơn nhiều.
SELECT today.id
FROM Weather today
JOIN Weather yesterday
ON yesterday.recordDate = today.recordDate - INTERVAL 1 DAY
AND today.temperature > yesterday.temperature;
SELECT id FROM (
SELECT id, recordDate, temperature,
LAG(temperature) OVER (ORDER BY recordDate) AS prev_temp,
LAG(recordDate) OVER (ORDER BY recordDate) AS prev_date
FROM Weather
) t
WHERE temperature > prev_temp
AND DATEDIFF(recordDate, prev_date) = 1;
Mọi aggregate function (SUM, AVG, COUNT, MIN, MAX) có thể chạy như window — không collapse dòng.
-- Mỗi đơn, kèm tổng đơn của khách đó
SELECT order_id, customer_id, total,
SUM(total) OVER (PARTITION BY customer_id) AS customer_total,
total / SUM(total) OVER (PARTITION BY customer_id) AS share_of_customer
FROM orders;
Đây là pattern "tỷ lệ trong nhóm" — rất phổ biến cho dashboard và phân tích.
-- Thị phần mỗi sản phẩm trong category
SELECT
product_id, category, revenue,
100.0 * revenue / SUM(revenue) OVER (PARTITION BY category) AS pct_of_category,
100.0 * revenue / SUM(revenue) OVER () AS pct_of_total
FROM product_revenue;
Hai chỉ số trong một query — tuần 5 phải dùng 2 subquery hoặc 2 lần JOIN.
Khi OVER có cả PARTITION BY và ORDER BY, aggregate trở thành running — tích lũy đến dòng hiện tại.
-- Tổng tích lũy doanh thu theo ngày, từng store
SELECT
store_id, sale_date, revenue,
SUM(revenue) OVER (
PARTITION BY store_id
ORDER BY sale_date
) AS running_total
FROM daily_sales;
| store | date | rev | running_total |
|---|---|---|---|
| 1 | 01-01 | 100 | 100 |
| 1 | 01-02 | 50 | 150 |
| 1 | 01-03 | 80 | 230 |
| 2 | 01-01 | 200 | 200 |
| 2 | 01-02 | 150 | 350 |
Frame mặc định: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — từ đầu partition đến dòng hiện tại.
Frame định nghĩa bao nhiêu dòng trong partition tham gia tính cho mỗi dòng.
-- Cú pháp đầy đủ
function() OVER (
PARTITION BY ...
ORDER BY ...
{ROWS | RANGE} BETWEEN start AND end
)
| Mode | Đơn vị | Khi có ties trên ORDER BY |
|---|---|---|
| ROWS | Số dòng vật lý | Phân biệt rõ — mỗi dòng là 1 đơn vị |
| RANGE | Phạm vi giá trị logic | Tất cả dòng cùng giá trị ORDER BY được gộp |
Hai đơn cùng ngày, frame mặc định là RANGE ⇒ cả hai được tính trong cùng "step" của running total — running_total nhảy bậc lớn. Sinh viên thường mong "tăng dần từng đơn" (ROWS).
-- ROWS — running total tăng từng dòng, kể cả cùng date
SUM(amount) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
-- RANGE — các dòng cùng date đều có cùng running_total
SUM(amount) OVER (ORDER BY date RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
Khuyên: khi viết running total, luôn explicit ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — phòng bug.
Moving average / rolling sum: frame "trượt" với dòng hiện tại.
-- Moving 7-day average
SELECT
sale_date, revenue,
AVG(revenue) OVER (
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS ma_7d
FROM daily_sales;
| Frame | Ý nghĩa |
|---|---|
| ROWS BETWEEN 6 PRECEDING AND CURRENT ROW | 7 dòng gần nhất kể cả hôm nay (MA-7) |
| ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING | Centered moving avg, 7 dòng quanh hiện tại |
| ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | Cumulative (running total) |
| ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING | "Reverse cumulative" — tổng từ đây đến hết |
CASE WHEN ROW_NUMBER() OVER (ORDER BY sale_date) >= 7 THEN ma_7d END
-- hoặc lọc trong WHERE bao ngoài
-- Đơn đầu tiên và cuối cùng của mỗi khách
SELECT
customer_id, order_id, order_date,
FIRST_VALUE(order_id) OVER (PARTITION BY customer_id ORDER BY order_date) AS first_order,
LAST_VALUE (order_id) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_order
FROM orders;
Frame mặc định khi có ORDER BY là RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Với LAST_VALUE, "last" trong frame này chính là CURRENT ROW — không phải cuối partition!
Phải thêm ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING để LAST_VALUE hoạt động như mong đợi.
Đây là bug rất phổ biến trong code production và là câu hỏi follow-up senior interview hay xuất hiện. Pattern thay thế: dùng FIRST_VALUE(...) OVER (... ORDER BY ... DESC) — đảo thứ tự.
NTH_VALUE(col, n): lấy giá trị dòng thứ n trong frame. Ít dùng hơn — đa số bài "thứ N" giải bằng ranking.
Trả về (score, rank) sắp xếp giảm dần. Score bằng nhau cùng rank, không skip rank.
"Bằng nhau cùng rank" + "không skip" ⇒ DENSE_RANK.
SELECT
score,
DENSE_RANK() OVER (ORDER BY score DESC) AS "rank"
FROM Scores
ORDER BY score DESC;
Mỗi phòng, trả về 3 mức lương cao nhất khác nhau và tất cả nhân viên ở các mức đó. Có thể nhiều hơn 3 nhân viên/phòng nếu có ties.
SELECT
d.name AS Department,
e.name AS Employee,
e.salary AS Salary
FROM (
SELECT
name, salary, departmentId,
DENSE_RANK() OVER (PARTITION BY departmentId ORDER BY salary DESC) AS rk
FROM Employee
) e
JOIN Department d ON d.id = e.departmentId
WHERE e.rk <= 3;
Đây là bài SQL Hard — và lời giải chỉ 10 dòng nhờ window function. Không có window function, sẽ cần correlated subquery phức tạp với 3 EXISTS lồng nhau.
Tính moving sum và moving average 7 ngày của tổng amount, bắt đầu từ ngày thứ 7. Trả (visited_on, amount, average_amount), với amount là tổng 7 ngày, average_amount = amount/7 (làm tròn 2 chữ số).
SELECT
visited_on,
amount,
ROUND(amount / 7.0, 2) AS average_amount
FROM (
SELECT
visited_on,
SUM(daily) OVER (
ORDER BY visited_on
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS amount,
ROW_NUMBER() OVER (ORDER BY visited_on) AS rn
FROM (
SELECT visited_on, SUM(amount) AS daily
FROM Customer GROUP BY visited_on
) daily_t
) t
WHERE rn >= 7
ORDER BY visited_on;
Bài Hard điển hình — mix GROUP BY với window function trong 2 tầng subquery. Tuần 7 (CTE) sẽ làm code này dễ đọc hơn nhiều.
Cài SQLFiddle hoặc DB Fiddle local. Generate dataset 1M dòng (loại orders). Đo thời gian:
Trả lời điển hình: window 5–50× nhanh hơn correlated subquery, tùy index và optimizer.
Nhấn T hoặc Escape để đóng