Appearance
📝 Blog Buổi 6: Window Function — Siêu năng lực SQL ít người biết
Spotify xếp hạng hàng triệu bài hát trong mỗi playlist, theo từng quốc gia, mỗi ngày. Không ai ngồi sort tay. Họ dùng Window Function.
Hook
Bạn đang ngồi trên xe buýt. Xe chạy dọc đường Nguyễn Huệ.
Qua cửa sổ, bạn thấy tòa nhà Bitexco, quán cà phê đang đông khách, một cặp đôi chụp ảnh cưới, xe máy chen nhau qua đèn đỏ. Bạn nhìn thấy mọi thứ bên ngoài — nhưng bạn vẫn ngồi yên tại chỗ. Bạn không rời ghế. Bạn không ra khỏi xe. Bạn chỉ quan sát qua cửa sổ.
Đó chính xác là cách Window Function hoạt động trong SQL.
Khi bạn dùng GROUP BY, bạn xuống xe — gom tất cả hành khách thành một nhóm, đếm đầu người, rồi chỉ giữ lại con số tổng. Dữ liệu chi tiết biến mất. Nhưng với Window Function, bạn vẫn ngồi tại dòng của mình, vẫn giữ nguyên mọi chi tiết — nhưng nhìn qua cửa sổ để thấy bức tranh lớn hơn: tổng doanh thu cả nhóm, thứ hạng của mình, doanh thu tháng trước là bao nhiêu.
Window Function = nhìn qua cửa sổ xe buýt. Thấy cảnh bên ngoài, nhưng không rời khỏi chỗ ngồi.
Hôm nay bạn sẽ học siêu năng lực SQL mà 70% bài phỏng vấn Data Analyst yêu cầu — nhưng phần lớn ứng viên không trả lời được.
🤔 Tại sao Window Function và Data Warehouse quan trọng?
Ở Buổi 5, bạn đã biết JOIN, GROUP BY, HAVING. Bạn ghép được nhiều bảng, gom nhóm được doanh thu theo tháng, đếm được đơn hàng theo khu vực. Tuyệt vời. Nhưng rồi sếp hỏi tiếp:
"Cho anh bảng xếp hạng top 3 sản phẩm bán chạy nhất trong mỗi category — không phải top 3 chung."
Bạn nghĩ... GROUP BY category rồi ORDER BY? Không được — vì ORDER BY sắp xếp toàn bộ kết quả, không phải trong từng nhóm. Bạn cần ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) — xếp hạng bên trong mỗi cửa sổ (mỗi category), rồi lọc WHERE rank <= 3.
Hoặc sếp hỏi: "So sánh doanh thu tháng này với tháng trước — growth bao nhiêu phần trăm?"
Không có Window Function, bạn phải self-join bảng với chính nó — JOIN orders tháng 1 với orders tháng 2, rồi tháng 2 với tháng 3... Với hàm LAG(), bạn lấy doanh thu tháng trước bằng một dòng code. Không JOIN. Không subquery. Không đau đầu.
Còn Data Warehouse? Khi doanh nghiệp lớn lên — hàng triệu, hàng tỷ dòng dữ liệu — database giao dịch hàng ngày (OLTP) không còn phù hợp cho phân tích. Bạn cần một kho hàng dữ liệu (Data Warehouse) được tổ chức gọn gàng, tối ưu cho truy vấn. Giống như siêu thị có kho hàng riêng ở phía sau — không ai bán hàng từ kho, nhưng mọi sản phẩm trên kệ đều đến từ kho. Data Warehouse là nơi dữ liệu được sắp xếp, làm sạch, sẵn sàng để bạn query.
💀 Sai lầm chết người
Tháng 11/2025. Công ty e-commerce tại Hà Nội. Đội DA được yêu cầu làm báo cáo: "Doanh thu tích lũy theo ngày (running total), so sánh MoM growth mỗi category, và xếp hạng seller theo doanh thu trong từng khu vực."
Minh — Junior DA, 6 tháng kinh nghiệm — bắt đầu viết query. Minh không biết Window Function. Minh chỉ biết GROUP BY và subquery.
Để tính running total, Minh viết correlated subquery — mỗi dòng chạy một subquery tính tổng tất cả dòng từ đầu tháng đến ngày hiện tại. Database quét lại toàn bộ bảng cho mỗi dòng. 500.000 dòng × 500.000 lần quét = query chạy 45 phút rồi timeout.
Để tính MoM growth, Minh self-join bảng monthly_revenue với chính nó, dịch lệch 1 tháng. Rồi self-join thêm lần nữa để so sánh với 2 tháng trước. Rồi thêm lần nữa cho quý trước. Query dài 87 dòng, subquery lồng 7 tầng, nhìn như mê cung — chính Minh viết mà Minh cũng không debug nổi.
Để xếp hạng seller theo khu vực? Minh dùng subquery đếm "có bao nhiêu seller có doanh thu cao hơn seller hiện tại trong cùng khu vực." Mỗi dòng = một subquery. 10.000 seller × 10.000 so sánh = database quá tải.
3 bài toán. 3 ngày. 0 kết quả chạy được.
Senior DA — chị Lan — ngồi xuống cạnh Minh. Viết lại toàn bộ trong 25 phút:
sql
-- Running total: 1 dòng thay vì correlated subquery
SUM(revenue) OVER (ORDER BY order_date) AS running_total
-- MoM growth: 1 dòng thay vì self-join 7 tầng
LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue
-- Ranking: 1 dòng thay vì subquery lồng nhau
ROW_NUMBER() OVER (PARTITION BY region ORDER BY revenue DESC) AS rankGROUP BY + subquery 87 dòng → 3 dòng Window Function. Query chạy trong 2.3 giây.
Chị Lan nói: "Em không sai vì kém. Em sai vì chưa biết đúng công cụ. Subquery lồng 7 tầng là dấu hiệu của người chưa biết Window Function — giống như dùng tuốc-nơ-vít đóng đinh. Đóng được, nhưng mệt lắm, và đinh thì cong."
🧪 Ai đã làm đúng?
Spotify — nền tảng streaming âm nhạc 600 triệu người dùng — xử lý một bài toán ranking khổng lồ mỗi ngày: xếp hạng bài hát theo lượt nghe, trong từng quốc gia, trong từng thể loại, trong từng playlist. Đó không phải ORDER BY đơn giản — đó là RANK() OVER (PARTITION BY country, genre ORDER BY streams DESC).
Đội data engineering của Spotify dùng Window Functions để tính daily active users tích lũy (running count), tỷ lệ skip rate so với tuần trước (LAG trên weekly aggregation), và phần trăm lượt nghe mỗi artist trong tổng thể loại (percent of total). Tất cả chạy trên Data Warehouse — Spotify dùng Google BigQuery, nơi dữ liệu được tổ chức theo mô hình Star Schema: fact table streams (ai nghe gì, lúc nào, bao lâu) liên kết với dimension tables users, tracks, artists, playlists.
Amazon — mỗi giây xử lý hàng nghìn giao dịch. Đội analytics dùng Window Functions hàng ngày cho bài toán kinh điển: "Top 5 sản phẩm bán chạy nhất trong mỗi category trong 7 ngày qua." Kết quả được đẩy lên trang chủ — đó chính là mục "Best Sellers" bạn thấy mỗi khi mở Amazon. Phía sau là DENSE_RANK() OVER (PARTITION BY category ORDER BY units_sold DESC), chạy trên Data Warehouse Redshift với hàng tỷ dòng giao dịch.
Gần hơn — MoMo, ví điện tử lớn nhất Việt Nam. MoMo xử lý hàng triệu giao dịch mỗi ngày — chuyển tiền, thanh toán hóa đơn, mua voucher. Đội DA dùng Window Functions để:
- Running total doanh số theo ngày — theo dõi tiến độ so với target tháng.
- LAG so sánh số lượng giao dịch tuần này vs tuần trước — phát hiện sớm khi một tính năng bị drop.
- PERCENT_RANK xếp hạng merchant theo volume giao dịch — top 10% merchant tạo ra 65% tổng giá trị giao dịch.
Tất cả những phân tích này chạy trên Data Warehouse riêng biệt, tách khỏi database giao dịch (OLTP) — để query phân tích không làm chậm hệ thống thanh toán real-time.
🎯 Vậy cụ thể là làm gì?
🪟 Window Function — "Nhìn qua cửa sổ" mà không rời chỗ ngồi
Window Function gồm 3 nhóm chính:
1. Ranking — Xếp hạng:
sql
-- ROW_NUMBER: mỗi dòng một số, không trùng
SELECT product_name, category, revenue,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rank
FROM products;
-- RANK: cho phép trùng hạng, bỏ qua hạng tiếp theo
-- Revenue: 100, 100, 80 → Rank: 1, 1, 3 (bỏ qua 2)
-- DENSE_RANK: trùng hạng nhưng KHÔNG bỏ qua
-- Revenue: 100, 100, 80 → Rank: 1, 1, 2ROW_NUMBER = đánh số thứ tự. RANK = xếp hạng kiểu thể thao (hai VĐV cùng HCV, không có HCB). DENSE_RANK = xếp hạng liên tục (hai VĐV cùng HCV, người tiếp theo là HCB).
2. Offset — Truy cập dòng trước/sau:
sql
-- LAG: lấy giá trị dòng TRƯỚC
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
revenue - LAG(revenue) OVER (ORDER BY month) AS mom_change
FROM monthly_sales;
-- LEAD: lấy giá trị dòng SAU
-- Dùng khi dự báo: so sánh tháng hiện tại với tháng kế tiếpLAG = nhìn qua gương chiếu hậu — thấy tháng trước. LEAD = nhìn qua kính lái — thấy tháng sau. Cả hai đều không cần self-join.
3. Aggregate Window — Tổng hợp mà giữ chi tiết:
sql
-- Running total: doanh thu tích lũy theo ngày
SELECT order_date, daily_revenue,
SUM(daily_revenue) OVER (ORDER BY order_date) AS running_total
FROM daily_sales;
-- Moving average: trung bình 7 ngày gần nhất
SELECT order_date, daily_revenue,
AVG(daily_revenue) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7d
FROM daily_sales;
-- Percent of total: tỷ trọng mỗi sản phẩm trong category
SELECT product_name, category, revenue,
revenue * 100.0 / SUM(revenue) OVER (PARTITION BY category) AS pct_of_category
FROM products;🏗 Data Warehouse — Kho hàng gọn gàng cho phân tích
OLTP vs OLAP:
| Đặc điểm | OLTP (giao dịch) | OLAP (phân tích) |
|---|---|---|
| Mục đích | Ghi nhận giao dịch hàng ngày | Phân tích dữ liệu lịch sử |
| Ví dụ | Đặt đơn trên Shopee | Báo cáo doanh thu quý |
| Tốc độ ghi | Nhanh, real-time | Batch load (ETL) |
| Tốc độ đọc | Chậm với query phức tạp | Tối ưu cho query phân tích |
| Schema | Normalized (nhiều bảng nhỏ) | Denormalized (Star Schema) |
OLTP là quầy thu ngân — ghi nhận từng giao dịch nhanh nhất có thể. OLAP là phòng kế toán — tổng hợp, phân tích, ra báo cáo. Bạn không lôi sổ cái ra quầy thu ngân, cũng không bắt kế toán ngồi ở quầy.
Star Schema — mô hình phổ biến nhất trong Data Warehouse:
- Fact Table (bảng sự kiện) — ở trung tâm — chứa các số đo (metrics): doanh thu, số lượng, chi phí. Mỗi dòng = một giao dịch. Ví dụ:
fact_ordersvới các cộtorder_id,customer_key,product_key,date_key,revenue,quantity. - Dimension Tables (bảng chiều) — xung quanh — chứa thuộc tính mô tả: tên khách hàng, category sản phẩm, ngày tháng. Ví dụ:
dim_customers,dim_products,dim_date.
Tưởng tượng ngôi sao: fact table ở giữa, dimension tables tỏa ra xung quanh — vì thế gọi là Star Schema. Khi query, bạn JOIN fact table với dimension tables để có context: "doanh thu của sản phẩm nào, từ khách nào, vào ngày nào."
ETL (Extract – Transform – Load): quy trình đưa dữ liệu từ OLTP vào Data Warehouse.
- Extract: rút dữ liệu từ các nguồn (database giao dịch, API, file CSV).
- Transform: làm sạch, chuẩn hóa, tính toán (chuyển currency, gom ngày thành tuần/tháng).
- Load: nạp vào Data Warehouse.
📦 CTE — Viết SQL sạch như viết văn có đoạn
CTE (Common Table Expression) — dùng WITH — chia query phức tạp thành từng bước có tên:
sql
-- Bài toán: Top 3 seller có MoM growth cao nhất trong mỗi khu vực
WITH monthly_revenue AS (
-- Bước 1: Tính doanh thu theo seller, theo tháng
SELECT seller_id, region, month, SUM(revenue) AS total_revenue
FROM orders
GROUP BY seller_id, region, month
),
with_growth AS (
-- Bước 2: Tính MoM growth bằng LAG
SELECT *,
LAG(total_revenue) OVER (PARTITION BY seller_id ORDER BY month) AS prev_month,
CASE
WHEN LAG(total_revenue) OVER (PARTITION BY seller_id ORDER BY month) = 0 THEN NULL
ELSE (total_revenue - LAG(total_revenue) OVER (PARTITION BY seller_id ORDER BY month))
* 100.0 / LAG(total_revenue) OVER (PARTITION BY seller_id ORDER BY month)
END AS mom_growth_pct
FROM monthly_revenue
),
ranked AS (
-- Bước 3: Xếp hạng trong mỗi khu vực
SELECT *,
ROW_NUMBER() OVER (PARTITION BY region ORDER BY mom_growth_pct DESC) AS rank
FROM with_growth
WHERE mom_growth_pct IS NOT NULL
)
-- Bước 4: Lấy top 3
SELECT * FROM ranked WHERE rank <= 3;Không có CTE, query này sẽ là subquery lồng 4 tầng — gần như không thể đọc. Với CTE, mỗi bước có tên rõ ràng: monthly_revenue → with_growth → ranked → kết quả. Giống viết văn có đoạn mở, thân, kết — người đọc (và chính bạn 2 tuần sau) hiểu ngay logic.
CASE WHEN — if-else trong SQL:
sql
SELECT customer_name, total_spent,
CASE
WHEN total_spent >= 10000000 THEN 'VIP'
WHEN total_spent >= 3000000 THEN 'Regular'
ELSE 'New'
END AS customer_tier
FROM customers;Kết hợp CASE WHEN với Window Function — phân loại khách hàng dựa trên ranking:
sql
WITH ranked_customers AS (
SELECT customer_name, total_spent,
NTILE(4) OVER (ORDER BY total_spent DESC) AS quartile
FROM customers
)
SELECT customer_name, total_spent,
CASE quartile
WHEN 1 THEN 'Top 25%'
WHEN 2 THEN 'Trên trung bình'
WHEN 3 THEN 'Dưới trung bình'
WHEN 4 THEN 'Bottom 25%'
END AS segment
FROM ranked_customers;🏪 Câu chuyện: Khang — DA tại fintech Việt Nam
Khang, 26 tuổi, Data Analyst tại một công ty fintech ở TP.HCM — mảng cho vay tiêu dùng.
Khang vào công ty được 8 tháng. SQL cơ bản ngon: JOIN 5 bảng không run tay, GROUP BY thuần thục, subquery viết được. Sếp — Head of Analytics — tin tưởng giao những report routine: doanh thu tuần, số khoản vay mới, tỷ lệ trễ hạn. Khang xuất CSV, mở Excel, Pivot Table, gửi email. Quy trình cũ, ổn định, không ai phàn nàn.
Rồi một ngày thứ Hai, sếp gửi Slack: "Khang ơi, tuần này em làm giúp anh bảng phân tích MoM growth tỷ lệ nợ xấu (NPL ratio) theo từng khu vực. Thêm running total giải ngân tích lũy từ đầu năm. Và rank top 10 sản phẩm vay có NPL thấp nhất mỗi quý."
Khang đọc xong, đứng hình.
MoM growth — Khang biết khái niệm, nhưng chưa bao giờ tính trong SQL. Khang mở Google: "SQL compare current month with previous month." Kết quả: self-join. Khang viết:
sql
SELECT a.region, a.month, a.npl_ratio,
b.npl_ratio AS prev_npl,
(a.npl_ratio - b.npl_ratio) / b.npl_ratio * 100 AS growth
FROM monthly_npl a
LEFT JOIN monthly_npl b
ON a.region = b.region
AND a.month = b.month - INTERVAL 1 MONTH;Chạy được. Nhưng khi thêm logic cho quý, cho năm, cho 3 tháng trước — query dài ra, self-join chồng lên self-join. 67 dòng SQL. Khang mất 1 ngày viết, nửa ngày debug, rồi phát hiện khu vực "Miền Trung" bị thiếu tháng 4 — self-join trả về NULL, phép chia NULL, cả cột growth biến thành trống.
Running total giải ngân — Khang dùng correlated subquery:
sql
SELECT order_date, amount,
(SELECT SUM(amount) FROM disbursements d2
WHERE d2.order_date <= d1.order_date) AS running_total
FROM disbursements d1;500.000 dòng giải ngân. Mỗi dòng chạy một subquery quét toàn bộ bảng. Query timeout sau 30 phút.
Ranking sản phẩm theo quý — Khang viết subquery: "Đếm số sản phẩm có NPL thấp hơn sản phẩm hiện tại trong cùng quý." Lồng thêm một lớp để lấy top 10. Ba tầng subquery, không tên, không comment — chính Khang đọc lại cũng không hiểu.
Thứ Tư. Khang chưa xong. Sếp hỏi tiến độ. Khang nói: "Em đang gặp vấn đề performance ạ."
Sếp ngồi cạnh. Nhìn query. Im lặng 10 giây. Rồi nói: "Em chưa biết Window Function à?"
Khang lắc đầu.
Sếp mở một tab mới. Gõ:
sql
WITH monthly_npl AS (
SELECT region, month, npl_ratio,
LAG(npl_ratio) OVER (PARTITION BY region ORDER BY month) AS prev_npl
FROM npl_summary
)
SELECT region, month, npl_ratio, prev_npl,
CASE
WHEN prev_npl = 0 OR prev_npl IS NULL THEN NULL
ELSE ROUND((npl_ratio - prev_npl) / prev_npl * 100, 2)
END AS mom_growth_pct
FROM monthly_npl;MoM growth — 12 dòng. Không self-join. LAG lấy giá trị tháng trước, PARTITION BY region đảm bảo mỗi khu vực so sánh với chính nó. CASE WHEN xử lý edge case (tháng đầu tiên không có prev_npl).
Running total:
sql
SELECT order_date, amount,
SUM(amount) OVER (ORDER BY order_date) AS running_total
FROM disbursements;Một dòng Window Function thay thế correlated subquery. Chạy trong 1.8 giây thay vì timeout.
Ranking:
sql
SELECT quarter, product_name, npl_ratio,
DENSE_RANK() OVER (PARTITION BY quarter ORDER BY npl_ratio ASC) AS rank
FROM quarterly_npl
QUALIFY rank <= 10;4 dòng. Xếp hạng trong mỗi quý, lấy top 10, xong.
Khang nhìn màn hình. 67 dòng self-join biến thành 12 dòng CTE. Correlated subquery timeout biến thành 1.8 giây. Subquery 3 tầng biến thành 4 dòng.
Khang nói: "Tại sao trước giờ em không biết cái này?"
Sếp cười: "Vì phần lớn tutorial dạy SQL dừng ở GROUP BY. Window Function là chỗ phân biệt junior và mid-level DA."
Tuần sau đó, Khang dành mỗi tối 1 tiếng luyện Window Function trên DataLemur và LeetCode. ROW_NUMBER để xếp hạng. LAG để tính MoM. SUM() OVER để running total. NTILE để chia quartile. Mỗi bài giải, Khang cảm thấy SQL không còn là "viết query lấy data" — mà là công cụ phân tích.
Tháng 12/2025, sếp giao Khang làm dashboard phân tích rủi ro tín dụng cho ban giám đốc. Khang viết toàn bộ logic trong SQL — CTE chia bước rõ ràng, Window Function tính mọi metric, CASE WHEN phân loại risk tier — rồi đẩy kết quả vào Power BI. Ban giám đốc mở dashboard, thấy:
- Running total giải ngân vs target — biết ngay tiến độ.
- MoM growth NPL ratio theo khu vực — phát hiện Miền Tây tăng đột biến.
- Top sản phẩm vay rủi ro thấp nhất — quyết định đẩy mạnh marketing cho nhóm này.
CFO nói trong cuộc họp: "Lần đầu tiên tôi thấy phân tích rủi ro rõ ràng thế này."
Khang được promote lên Mid-level DA. Lương tăng 35%. Nhưng quan trọng hơn — Khang không còn sợ những bài toán phân tích phức tạp. Query nào cũng chia được thành bước (CTE), mỗi bước dùng đúng công cụ (Window Function), và xử lý edge case bằng logic rõ ràng (CASE WHEN).
Khang nói: "Window Function không khó. Khó là ở chỗ biết nó tồn tại. Khi biết rồi, mọi bài toán phân tích đều trở nên đơn giản hơn — vì bạn đã có đúng công cụ trong tay."
💡 Bài học rút ra
🪟 Window Function = nhìn qua cửa sổ. Giữ nguyên chi tiết từng dòng, nhưng thấy được bức tranh lớn: tổng, ranking, dòng trước, dòng sau. Khác hoàn toàn với GROUP BY — nơi chi tiết bị gom lại và biến mất.
🔢 ROW_NUMBER, RANK, DENSE_RANK — ba anh em khác tính. ROW_NUMBER: đánh số duy nhất, không trùng. RANK: cho phép trùng, bỏ hạng. DENSE_RANK: cho phép trùng, không bỏ hạng. Chọn sai = kết quả sai mà không biết tại sao.
📈 LAG + CASE WHEN = combo tính MoM growth. LAG lấy giá trị tháng trước. CASE WHEN xử lý tháng đầu tiên (NULL) và mẫu số bằng 0. Hai hàm kết hợp thay thế mọi self-join phức tạp.
🏗 Data Warehouse không phải database. OLTP ghi giao dịch — nhanh, real-time. OLAP phân tích — tối ưu cho query phức tạp. Star Schema (fact + dimension) là kiến trúc DA cần hiểu để biết JOIN bảng nào khi viết query trên Data Warehouse.
📦 CTE biến spaghetti thành recipe. Query 87 dòng subquery lồng nhau = mì Ý rối. CTE chia thành từng bước có tên = công thức nấu ăn rõ ràng. Ai đọc cũng hiểu. Debug cũng dễ — chạy từng CTE riêng lẻ để kiểm tra.
🎯 Window Function là ranh giới junior vs mid-level. 70% bài phỏng vấn DA hỏi Window Function (DataLemur, 2025). Biết GROUP BY thì qua được vòng screening. Biết Window Function thì qua được vòng technical — và bắt đầu giải quyết bài toán phân tích thực sự trong công việc.
🤓 Muốn tìm hiểu thêm?
- 📚 DataLemur — SQL Interview Questions — Luyện Window Function qua bài phỏng vấn thực tế từ FAANG. Bắt đầu từ Medium difficulty.
- 🎥 freeCodeCamp — Window Functions Tutorial — Giải thích trực quan, nhiều ví dụ thực tế.
- 📖 SQL for Data Scientists — Renee Teate (Wiley) — Chương về Window Functions viết cực kỳ rõ ràng, hướng phân tích.
- 📚 Mode Analytics — SQL Window Functions — Tutorial tương tác, chạy query trực tiếp trên trình duyệt.
- 📖 The Data Warehouse Toolkit — Ralph Kimball — Cuốn sách gối đầu giường về Star Schema và Dimensional Modeling.
- 🏋️ LeetCode — Hard SQL Problems — Phần lớn bài Hard yêu cầu Window Function. Giải được 10 bài = tự tin phỏng vấn.
- 🇻🇳 Học SQL tiếng Việt — SQLZoo — Bài tập tương tác, miễn phí, hỗ trợ nhiều database.
🔗 Xem thêm Buổi 6
→ 📘 Nội dung chính → 🧠 Case Study → 🏆 Tiêu chuẩn → 🛠 Workshop → 🎮 Mini Game