Skip to content

📘 Buổi 6: SQL nâng cao — Window Function & Tư duy Data Warehouse

Level up SQL: phân tích ranking, running total, và hiểu kho dữ liệu doanh nghiệp.

🎯 Mục tiêu buổi học

Sau buổi này, học viên sẽ:

  1. Sử dụng Window Functions: ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD
  2. Tính running total, moving average, percent of total bằng SQL
  3. Hiểu kiến trúc Data Warehouse: star schema, dimension vs fact table
  4. Viết CTE (Common Table Expression) để query phức tạp dễ đọc

📋 Tổng quan

Ở Buổi 5, bạn đã nắm vững SQL cơ bản: viết SELECT, JOIN nhiều bảng, và dùng GROUP BY + aggregate functions để tổng hợp dữ liệu. Bạn biết cách đếm đơn hàng theo thành phố (COUNT ... GROUP BY city), tính doanh thu trung bình (AVG(total_amount)), và kết nối 3–4 bảng bằng JOIN. Đó là nền tảng vững chắc — nhưng thực tế công việc DA đòi hỏi nhiều hơn thế.

Hãy tưởng tượng sếp hỏi: "Cho anh bảng xếp hạng top sản phẩm theo doanh thu trong mỗi category"GROUP BY không đủ, vì bạn cần xếp hạng trong mỗi nhóm. Hoặc: "Tính doanh thu tích lũy theo ngày để xem tiến độ so với target" — cần running total, không chỉ tổng theo ngày. Hay: "So sánh doanh thu tháng này với tháng trước (MoM growth)" — cần truy cập dòng trước đó trong kết quả. Tất cả những bài toán này đều được giải quyết bằng Window Functions — tính năng SQL nâng cao mà 90% Data Analyst sử dụng hàng ngày.

Song song đó, khi dữ liệu doanh nghiệp tăng lên hàng triệu–hàng tỷ dòng, hệ thống database giao dịch (OLTP) không còn phù hợp cho phân tích. Doanh nghiệp cần một kiến trúc riêng gọi là Data Warehouse — nơi dữ liệu được tổ chức theo mô hình Star Schema (fact table + dimension table) tối ưu cho truy vấn phân tích. Hiểu Data Warehouse giúp bạn biết dữ liệu đến từ đâu, tại sao schema trông khác so với database giao dịch, và bạn cần JOIN bảng nào để trả lời câu hỏi kinh doanh.

Cuối cùng, query phức tạp với nhiều bước tính toán dễ trở nên khó đọc, khó debug. CTE (Common Table Expression) là cách viết SQL "sạch" — chia query thành từng bước logic rõ ràng, mỗi bước có tên — giống cách bạn chia hàm trong lập trình. CTE là kỹ năng bắt buộc khi làm việc với real-world data.

Buổi này đánh dấu bước chuyển từ SQL cơ bản → SQL phân tích chuyên nghiệp. Sau buổi học, bạn sẽ viết được những query mà trước đây phải xuất ra Excel rồi tính tay — giờ chỉ cần 1 câu SQL.


📌 Phần 1: Window Functions — Sức mạnh phân tích trong SQL

Khái niệm

Window Function (hàm cửa sổ) là loại hàm SQL thực hiện phép tính trên một tập hợp các dòng liên quan đến dòng hiện tại — mà không gom nhóm (collapse) kết quả như GROUP BY. Mỗi dòng vẫn giữ nguyên trong output, nhưng kèm thêm giá trị tính toán từ "cửa sổ" dữ liệu xung quanh.

Cú pháp tổng quát:

sql
function_name(expression) OVER (
    [PARTITION BY cột_phân_nhóm]     -- Chia dữ liệu thành nhóm (tùy chọn)
    [ORDER BY cột_sắp_xếp]          -- Sắp xếp trong mỗi nhóm (tùy chọn)
    [ROWS/RANGE BETWEEN ... AND ...] -- Khung cửa sổ (tùy chọn)
)

So sánh GROUP BY vs Window Function:

Đặc điểmGROUP BY + AggregateWindow Function
Kết quảGom nhiều dòng thành 1 dòng/nhómGiữ nguyên tất cả dòng
Cột outputChỉ cột trong GROUP BY + aggregateTất cả cột gốc + giá trị window
Ví dụDoanh thu theo tháng (1 dòng/tháng)Doanh thu từng đơn + tổng tháng kèm theo
Từ khóaGROUP BYOVER()
Use caseTổng hợp, báo cáo summaryRanking, running total, so sánh dòng

Ba nhóm Window Functions:

NhómHàmMô tả
RankingROW_NUMBER(), RANK(), DENSE_RANK(), NTILE(n)Xếp hạng dòng trong mỗi nhóm
OffsetLAG(), LEAD(), FIRST_VALUE(), LAST_VALUE()Truy cập dòng trước/sau dòng hiện tại
AggregateSUM() OVER, AVG() OVER, COUNT() OVER, MIN() OVER, MAX() OVERHàm tổng hợp nhưng giữ nguyên dòng gốc

Tại sao quan trọng cho Data Analyst?

Window Functions là kỹ năng SQL được hỏi nhiều nhất trong phỏng vấn DA — theo DataLemur (2025), 70%+ bài SQL interview yêu cầu window functions. Lý do: chúng giải quyết chính xác các bài toán phân tích thực tế mà GROUP BY không làm được:

  • Ranking: Top N sản phẩm trong mỗi category, xếp hạng nhân viên theo KPI
  • Running total: Doanh thu tích lũy từ đầu tháng — theo dõi tiến độ vs target
  • Period comparison: So sánh MoM, YoY bằng LAG — không cần self-join
  • Percent of total: Tỷ trọng mỗi sản phẩm trong tổng doanh thu danh mục

Nhớ lại Buổi 5, bạn dùng GROUP BY để tính tổng doanh thu theo thành phố. Window Function là bước tiếp theo: bạn giữ nguyên chi tiết từng đơn hàng, nhưng thêm cột tổng thành phố bên cạnh — để mỗi đơn hàng tự biết nó chiếm bao nhiêu phần trăm doanh thu thành phố đó.

Áp dụng thực tế

Ranking Functions — phân biệt:

HàmCách xếp hạngKhi có giá trị bằng nhauVí dụ: [100, 90, 90, 80]
ROW_NUMBER()Mỗi dòng 1 số duy nhấtKhông quan tâm — gán ngẫu nhiên1, 2, 3, 4
RANK()Cùng hạng cho giá trị bằng, bỏ qua hạng kế tiếp2 người hạng 2 → tiếp theo là hạng 41, 2, 2, 4
DENSE_RANK()Cùng hạng cho giá trị bằng, không bỏ qua2 người hạng 2 → tiếp theo vẫn hạng 31, 2, 2, 3
NTILE(n)Chia đều thành n nhómPhân bổ đềuNTILE(4) → chia thành 4 phần tư (quartile)
sql
-- Xếp hạng sản phẩm theo doanh thu trong mỗi category
SELECT
    category,
    product_name,
    revenue,
    ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS row_num,
    RANK()       OVER (PARTITION BY category ORDER BY revenue DESC) AS rank_pos,
    DENSE_RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS dense_rank_pos
FROM product_sales;
categoryproduct_namerevenuerow_numrank_posdense_rank_pos
Điện thoạiiPhone 165000000111
Điện thoạiSamsung S254200000222
Điện thoạiXiaomi 154200000322
Điện thoạiOppo Find3100000443
LaptopMacBook Pro8500000111
LaptopDell XPS6000000222

Offset Functions — LAG & LEAD:

sql
-- So sánh doanh thu tháng hiện tại vs tháng trước (MoM Growth)
SELECT
    month,
    revenue,
    LAG(revenue, 1) OVER (ORDER BY month)  AS prev_month_revenue,
    LEAD(revenue, 1) OVER (ORDER BY month) AS next_month_revenue,
    ROUND(
        (revenue - LAG(revenue, 1) OVER (ORDER BY month))
        / LAG(revenue, 1) OVER (ORDER BY month) * 100, 1
    ) AS mom_growth_pct
FROM monthly_revenue;
monthrevenueprev_month_revenuenext_month_revenuemom_growth_pct
2026-01500000000NULL580000000NULL
2026-0258000000050000000062000000016.0
2026-036200000005800000005500000006.9
2026-04550000000620000000NULL-11.3

Aggregate Windows — SUM OVER, AVG OVER:

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,
    AVG(daily_revenue) OVER (
        ORDER BY order_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS moving_avg_7d
FROM daily_sales;

📌 Phần 2: Phân tích nâng cao — Running Total, Moving Average, Percent of Total

Khái niệm

Phân tích nâng cao trong SQL tận dụng Window Functions để tính toán các chỉ số mà business cần hàng ngày nhưng GROUP BY đơn thuần không thể tạo ra. Bốn kỹ thuật quan trọng nhất:

Kỹ thuậtEnglishMô tảDùng khi
Tích lũyRunning TotalTổng cộng dồn từ đầu kỳ đến dòng hiện tạiTheo dõi tiến độ doanh thu vs target
Trung bình trượtMoving AverageTrung bình N ngày/tuần gần nhất — làm mượt biến độngPhân tích trend, loại bỏ noise
Tỷ trọngPercent of TotalTỷ lệ % của mỗi dòng so với tổng nhómHiểu cơ cấu doanh thu, portfolio mix
Tăng trưởng kỳMoM / YoY GrowthSo sánh giá trị hiện tại với kỳ trước (tháng, năm)Đánh giá xu hướng tăng/giảm

Tại sao quan trọng cho Data Analyst?

Đây là 4 kỹ thuật xuất hiện trong mọi báo cáo phân tích của doanh nghiệp:

  • CFO cần running total để biết "Tính đến hôm nay, đã đạt bao nhiêu % mục tiêu doanh thu quý?"
  • Product Manager cần moving average 7 ngày để xem DAU (Daily Active Users) có xu hướng tăng hay giảm — bỏ qua biến động cuối tuần
  • Marketing Director cần percent of total để biết "Kênh nào đóng góp nhiều nhất vào tổng chi phí marketing?"
  • CEO cần MoM/YoY growth trong slide deck hàng tháng

Trước Buổi 6, bạn phải xuất data ra Excel, dùng ô tham chiếu để tính running total, tạo cột helper cho moving average. Giờ — 1 câu SQL làm tất cả, chạy trên hàng triệu dòng trong vài giây.

Áp dụng thực tế

Running Total — doanh thu tích lũy theo ngày trong tháng:

sql
SELECT
    order_date,
    daily_revenue,
    SUM(daily_revenue) OVER (
        ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_revenue
FROM daily_sales
WHERE order_date BETWEEN '2026-01-01' AND '2026-01-31';

Moving Average 7 ngày — làm mượt DAU:

sql
SELECT
    log_date,
    dau,
    ROUND(
        AVG(dau) OVER (
            ORDER BY log_date
            ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
        ), 0
    ) AS dau_ma7
FROM app_daily_metrics
ORDER BY log_date;

Percent of Total — tỷ trọng doanh thu mỗi sản phẩm trong category:

sql
SELECT
    category,
    product_name,
    revenue,
    SUM(revenue) OVER (PARTITION BY category) AS category_total,
    ROUND(
        revenue * 100.0 / SUM(revenue) OVER (PARTITION BY category), 1
    ) AS pct_of_category
FROM product_sales
ORDER BY category, revenue DESC;
categoryproduct_namerevenuecategory_totalpct_of_category
Điện thoạiiPhone 1650000001650000030.3
Điện thoạiSamsung S2542000001650000025.5
Điện thoạiXiaomi 1542000001650000025.5
Điện thoạiOppo Find31000001650000018.8

MoM Growth bằng LAG — tăng trưởng doanh thu qua từng tháng:

sql
SELECT
    month,
    revenue,
    LAG(revenue) OVER (ORDER BY month) AS prev_month,
    ROUND(
        (revenue - LAG(revenue) OVER (ORDER BY month))
        * 100.0 / LAG(revenue) OVER (ORDER BY month), 1
    ) AS mom_growth_pct
FROM monthly_revenue
ORDER BY month;

YoY Growth — so sánh cùng kỳ năm trước:

sql
SELECT
    month,
    revenue,
    LAG(revenue, 12) OVER (ORDER BY month) AS same_month_last_year,
    ROUND(
        (revenue - LAG(revenue, 12) OVER (ORDER BY month))
        * 100.0 / LAG(revenue, 12) OVER (ORDER BY month), 1
    ) AS yoy_growth_pct
FROM monthly_revenue
ORDER BY month;

📌 Phần 3: Data Warehouse Basics — Kiến trúc kho dữ liệu

Khái niệm

Data Warehouse (kho dữ liệu) là hệ thống lưu trữ dữ liệu tập trung được thiết kế riêng cho phân tích và báo cáo — khác biệt hoàn toàn với database giao dịch (OLTP) mà ứng dụng sử dụng hàng ngày. Data warehouse thu thập dữ liệu từ nhiều nguồn (ERP, CRM, website, app...), biến đổi và tổ chức lại theo mô hình tối ưu cho query phân tích.

OLTP vs OLAP — hai thế giới database:

Đặc điểmOLTP (Online Transaction Processing)OLAP (Online Analytical Processing)
Mục đíchXử lý giao dịch — INSERT, UPDATE hàng ngàyPhân tích, báo cáo — SELECT phức tạp
Người dùngỨng dụng, developerData Analyst, BI team, management
QueryĐơn giản, nhanh: SELECT * WHERE id = 123Phức tạp, quét nhiều data: JOIN 5 bảng + GROUP BY + Window
SchemaNormalized (3NF) — ít dư thừa, nhiều bảngDenormalized — Star/Snowflake schema
Dữ liệuDữ liệu hiện tại (current state)Dữ liệu lịch sử (historical)
Kích thướcGB → TBTB → PB
Ví dụMySQL cho app Tiki, PostgreSQL cho VNPayBigQuery, Redshift, Snowflake

Star Schema — mô hình cốt lõi của Data Warehouse:

Star schema tổ chức dữ liệu thành 2 loại bảng:

Loại bảngEnglishMô tảĐặc điểmVí dụ
Bảng sự kiệnFact TableChứa các số liệu đo lường (metrics) — mỗi dòng là 1 sự kiện kinh doanhNhiều dòng, nhiều FK, ít cột textfact_sales, fact_orders, fact_page_views
Bảng chiềuDimension TableChứa thông tin mô tả (context) cho fact — ai, cái gì, ở đâu, khi nàoÍt dòng, nhiều cột text, có PKdim_customer, dim_product, dim_date, dim_store
    ┌─────────────┐
    │ dim_product  │
    │─────────────│
    │ product_key  │──┐
    │ product_name │  │
    │ category     │  │    ┌──────────────────────────┐
    │ brand        │  │    │       fact_sales          │
    └─────────────┘  │    │──────────────────────────│
                      ├───>│ sale_id                   │
    ┌─────────────┐  │    │ date_key (FK)      ──────│───> dim_date
    │ dim_customer │  │    │ customer_key (FK)  ──────│───> dim_customer
    │─────────────│  │    │ product_key (FK)   ──────│───> dim_product
    │ customer_key │──┘    │ store_key (FK)     ──────│───> dim_store
    │ customer_name│       │ quantity                  │
    │ segment      │       │ unit_price                │
    │ city         │       │ total_amount              │
    │ join_date    │       │ discount                  │
    └─────────────┘       └──────────────────────────┘

    ┌─────────────┐                  │         ┌──────────┐
    │  dim_date   │                  │         │ dim_store │
    │─────────────│                  │         │──────────│
    │ date_key    │<─────────────────┘         │ store_key│
    │ full_date   │                            │ store_name│
    │ day_of_week │                            │ region    │
    │ month       │                            │ city      │
    │ quarter     │                            └──────────┘
    │ year        │
    │ is_weekend  │
    └─────────────┘

Các khái niệm liên quan:

Khái niệmEnglishMô tả
ETLExtract – Transform – LoadQuy trình lấy dữ liệu từ nguồn (Extract), biến đổi (Transform), nạp vào warehouse (Load)
Data MartData Mart"Warehouse nhỏ" cho 1 bộ phận — ví dụ: Marketing Data Mart, Finance Data Mart
Data LakeData LakeKho lưu trữ dữ liệu thô (raw), chưa xử lý — structured + unstructured — giá rẻ, linh hoạt
Surrogate KeySurrogate KeyKhóa nhân tạo (thường integer tăng tự động) thay thế natural key trong dimension table
Slowly Changing DimensionSCDKỹ thuật xử lý khi thông tin dimension thay đổi (khách hàng đổi địa chỉ, sản phẩm đổi giá)

Tại sao quan trọng cho Data Analyst?

Là DA, bạn không xây data warehouse — đó là việc của Data Engineer. Nhưng bạn query từ warehouse mỗi ngày. Hiểu kiến trúc giúp bạn:

  • Biết JOIN bảng nào: fact_sales JOIN dim_product → lấy tên sản phẩm cho báo cáo doanh thu
  • Hiểu tại sao schema "lạ": dim_date có cột is_weekend, quarter — vì đã được thiết kế sẵn cho phân tích
  • Tránh query sai: Không GROUP BY trên fact table mà quên JOIN dimension → kết quả vô nghĩa
  • Nói cùng ngôn ngữ với Data Engineer, BI Developer — sử dụng thuật ngữ chính xác khi yêu cầu dữ liệu

Theo Kimball Dimensional Modeling — tiêu chuẩn thiết kế Data Warehouse phổ biến nhất — Star Schema là mô hình tối ưu vì: đơn giản, dễ hiểu, và query nhanh (ít JOIN hơn snowflake schema).

Áp dụng thực tế

Query Star Schema — Doanh thu theo danh mục sản phẩm, năm, quý:

sql
SELECT
    dd.year,
    dd.quarter,
    dp.category,
    SUM(fs.total_amount) AS total_revenue,
    COUNT(DISTINCT fs.sale_id) AS num_transactions,
    COUNT(DISTINCT fs.customer_key) AS num_customers
FROM fact_sales fs
JOIN dim_date dd ON fs.date_key = dd.date_key
JOIN dim_product dp ON fs.product_key = dp.product_key
WHERE dd.year = 2025
GROUP BY dd.year, dd.quarter, dp.category
ORDER BY dd.quarter, total_revenue DESC;

So sánh Data Lake vs Data Warehouse vs Data Mart:

Đặc điểmData LakeData WarehouseData Mart
Dữ liệuThô (raw), mọi định dạngĐã xử lý, có cấu trúcTập con của warehouse
SchemaSchema-on-readSchema-on-write (Star/Snowflake)Star schema đơn giản
Người dùngData Scientist, Data EngineerData Analyst, BI teamBộ phận cụ thể
Công nghệS3, Azure Data Lake, GCSBigQuery, Redshift, SnowflakeDerived từ warehouse
Chi phí lưu trữThấpTrung bình – CaoThấp (ít data)

📌 Phần 4: CTE & Advanced Query — Viết SQL sạch, dễ bảo trì

Khái niệm

CTE (Common Table Expression) là bảng tạm có tên được định nghĩa ngay trong câu query bằng mệnh đề WITH ... AS (...). CTE giúp chia query phức tạp thành nhiều bước logic — mỗi bước có tên rõ ràng — sau đó kết hợp lại trong query cuối.

Cú pháp CTE:

sql
WITH tên_cte AS (
    SELECT ...
    FROM ...
    WHERE ...
)
SELECT *
FROM tên_cte;

So sánh: Subquery vs CTE:

Đặc điểmSubqueryCTE
Vị tríNằm trong FROM hoặc WHEREKhai báo trước query chính bằng WITH
Đọc hiểuKhó khi lồng nhiều tầngDễ — mỗi bước có tên
Tái sử dụngPhải viết lại nếu dùng nhiều lầnĐặt tên 1 lần, dùng nhiều lần trong query
DebugKhó tách ra kiểm traDễ — chạy riêng từng CTE
PerformanceTương đươngTương đương (hầu hết RDBMS)

Nested CTE — nhiều bước nối tiếp:

sql
WITH step1 AS (
    -- Bước 1: Tính doanh thu theo tháng
    SELECT
        DATE_TRUNC('month', order_date) AS month,
        SUM(total_amount) AS monthly_revenue
    FROM orders
    WHERE status = 'completed'
    GROUP BY DATE_TRUNC('month', order_date)
),
step2 AS (
    -- Bước 2: Thêm MoM growth
    SELECT
        month,
        monthly_revenue,
        LAG(monthly_revenue) OVER (ORDER BY month) AS prev_month,
        ROUND(
            (monthly_revenue - LAG(monthly_revenue) OVER (ORDER BY month))
            * 100.0 / LAG(monthly_revenue) OVER (ORDER BY month), 1
        ) AS mom_growth_pct
    FROM step1
)
-- Bước 3: Chỉ lấy tháng tăng trưởng âm
SELECT *
FROM step2
WHERE mom_growth_pct < 0
ORDER BY month;

CASE WHEN — logic điều kiện trong SQL:

sql
SELECT
    customer_name,
    total_spent,
    CASE
        WHEN total_spent >= 10000000 THEN 'VIP'
        WHEN total_spent >= 5000000  THEN 'Gold'
        WHEN total_spent >= 1000000  THEN 'Silver'
        ELSE 'Standard'
    END AS customer_tier
FROM customer_summary;

UNION / INTERSECT / EXCEPT — phép toán tập hợp:

Phép toánMô tảVí dụ
UNIONGộp kết quả 2 query, loại trùngGộp danh sách khách hàng từ 2 source
UNION ALLGộp kết quả, giữ trùngGộp log từ nhiều bảng — cần tất cả dòng
INTERSECTChỉ lấy dòng có ở cả 2 queryTìm khách hàng mua cả online lẫn offline
EXCEPTLấy dòng ở query 1 mà không có ở query 2Tìm khách hàng tháng trước nhưng không mua tháng này (churned)

Tại sao quan trọng cho Data Analyst?

CTE là tiêu chuẩn viết SQL trong mọi team DA chuyên nghiệp. Query thực tế thường có 3–5 bước tính toán — nếu viết thành subquery lồng nhau, không ai review hoặc debug được. CTE giúp:

  • Code review dễ hơn: đồng nghiệp đọc từng bước logic rõ ràng
  • Debug dễ hơn: chạy riêng từng CTE để kiểm tra kết quả trung gian
  • Bảo trì dễ hơn: thay đổi 1 bước không ảnh hưởng bước khác

CASE WHEN là cách bạn tạo business logic trong SQL — phân nhóm khách hàng, gán label, tạo bucket — thay vì phải làm bên ngoài (Excel, Python).

Áp dụng thực tế

sql
-- CTE kết hợp Window Function + CASE WHEN
-- Bài toán: Phân tích RFM (Recency, Frequency, Monetary) bằng SQL
WITH customer_metrics AS (
    SELECT
        customer_id,
        MAX(order_date) AS last_order_date,
        COUNT(order_id) AS frequency,
        SUM(total_amount) AS monetary
    FROM orders
    WHERE status = 'completed'
    GROUP BY customer_id
),
rfm_scores AS (
    SELECT
        customer_id,
        NTILE(5) OVER (ORDER BY last_order_date ASC)  AS r_score,  -- 5 = gần nhất
        NTILE(5) OVER (ORDER BY frequency ASC)         AS f_score,  -- 5 = mua nhiều nhất
        NTILE(5) OVER (ORDER BY monetary ASC)           AS m_score   -- 5 = chi nhiều nhất
    FROM customer_metrics
)
SELECT
    customer_id,
    r_score, f_score, m_score,
    CASE
        WHEN r_score >= 4 AND f_score >= 4 AND m_score >= 4 THEN 'Champions'
        WHEN r_score >= 4 AND f_score >= 3                   THEN 'Loyal'
        WHEN r_score >= 4                                     THEN 'Recent'
        WHEN r_score <= 2 AND f_score >= 3                   THEN 'At Risk'
        WHEN r_score <= 2 AND f_score <= 2                   THEN 'Lost'
        ELSE 'Other'
    END AS rfm_segment
FROM rfm_scores
ORDER BY r_score DESC, f_score DESC, m_score DESC;

📊 Framework tổng hợp

Window Function Syntax Map

┌─────────────────────────────────────────────────────────────────────┐
│                    WINDOW FUNCTION SYNTAX                           │
│                                                                     │
│   function(expr) OVER (                                             │
│       ┌─ PARTITION BY col ──── Chia nhóm (như GROUP BY nhưng giữ   │
│       │                         nguyên dòng)                        │
│       │                                                             │
│       ├─ ORDER BY col ──────── Sắp xếp trong mỗi nhóm             │
│       │                                                             │
│       └─ ROWS BETWEEN ─────── Khung cửa sổ (frame)                │
│           ├─ UNBOUNDED PRECEDING ── từ đầu nhóm                    │
│           ├─ N PRECEDING ────────── N dòng trước                   │
│           ├─ CURRENT ROW ────────── dòng hiện tại                  │
│           ├─ N FOLLOWING ────────── N dòng sau                     │
│           └─ UNBOUNDED FOLLOWING ── đến cuối nhóm                  │
│   )                                                                 │
│                                                                     │
│   ┌──────────────┬──────────────┬──────────────────┐               │
│   │  RANKING     │   OFFSET     │   AGGREGATE      │               │
│   ├──────────────┼──────────────┼──────────────────┤               │
│   │ ROW_NUMBER() │ LAG(col, n)  │ SUM(col) OVER    │               │
│   │ RANK()       │ LEAD(col, n) │ AVG(col) OVER    │               │
│   │ DENSE_RANK() │ FIRST_VALUE()│ COUNT(col) OVER  │               │
│   │ NTILE(n)     │ LAST_VALUE() │ MIN/MAX OVER     │               │
│   └──────────────┴──────────────┴──────────────────┘               │
└─────────────────────────────────────────────────────────────────────┘

Star Schema — Mô hình Data Warehouse

                    ┌──────────────┐
                    │  dim_date    │
                    │  (Khi nào?)  │
                    └──────┬───────┘

┌──────────────┐    ┌──────┴───────┐    ┌──────────────┐
│ dim_product  │    │  FACT TABLE  │    │ dim_customer │
│ (Cái gì?)    │────│ (Sự kiện +  │────│ (Ai?)        │
│              │    │   Metrics)   │    │              │
└──────────────┘    └──────┬───────┘    └──────────────┘

                    ┌──────┴───────┐
                    │  dim_store   │
                    │  (Ở đâu?)   │
                    └──────────────┘

Fact table ở giữa, các Dimension tables xung quanh → hình ngôi sao ⭐

🛠️ Demo — 10+ SQL Queries thực hành

Demo 1: ROW_NUMBER — Top 3 sản phẩm mỗi category

sql
WITH ranked AS (
    SELECT
        category,
        product_name,
        revenue,
        ROW_NUMBER() OVER (
            PARTITION BY category
            ORDER BY revenue DESC
        ) AS rn
    FROM product_sales
)
SELECT category, product_name, revenue
FROM ranked
WHERE rn <= 3
ORDER BY category, rn;

Demo 2: RANK vs DENSE_RANK — Xếp hạng có trùng

sql
SELECT
    employee_name,
    department,
    sales_amount,
    RANK()       OVER (ORDER BY sales_amount DESC) AS rank_pos,
    DENSE_RANK() OVER (ORDER BY sales_amount DESC) AS dense_rank_pos
FROM employee_sales
WHERE quarter = 'Q1-2026';

Demo 3: LAG — MoM Revenue Growth

sql
SELECT
    DATE_TRUNC('month', order_date) AS month,
    SUM(total_amount) AS revenue,
    LAG(SUM(total_amount)) OVER (
        ORDER BY DATE_TRUNC('month', order_date)
    ) AS prev_month_rev,
    ROUND(
        (SUM(total_amount) - LAG(SUM(total_amount)) OVER (
            ORDER BY DATE_TRUNC('month', order_date)
        )) * 100.0 / NULLIF(LAG(SUM(total_amount)) OVER (
            ORDER BY DATE_TRUNC('month', order_date)
        ), 0), 1
    ) AS mom_growth_pct
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', order_date)
ORDER BY month;

Demo 4: Running Total — Doanh thu tích lũy theo ngày

sql
SELECT
    order_date,
    SUM(total_amount) AS daily_revenue,
    SUM(SUM(total_amount)) OVER (
        ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_revenue
FROM orders
WHERE order_date BETWEEN '2026-01-01' AND '2026-01-31'
  AND status = 'completed'
GROUP BY order_date
ORDER BY order_date;

Demo 5: Moving Average 7 ngày — DAU trend

sql
SELECT
    log_date,
    dau,
    ROUND(AVG(dau) OVER (
        ORDER BY log_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ), 0) AS ma_7d,
    ROUND(AVG(dau) OVER (
        ORDER BY log_date
        ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
    ), 0) AS ma_30d
FROM app_daily_metrics
WHERE log_date >= '2026-01-01'
ORDER BY log_date;

Demo 6: Percent of Total — Tỷ trọng doanh thu theo region

sql
SELECT
    region,
    store_name,
    revenue,
    SUM(revenue) OVER () AS grand_total,
    ROUND(revenue * 100.0 / SUM(revenue) OVER (), 1) AS pct_of_grand,
    SUM(revenue) OVER (PARTITION BY region) AS region_total,
    ROUND(revenue * 100.0 / SUM(revenue) OVER (PARTITION BY region), 1) AS pct_of_region
FROM store_revenue
ORDER BY region, revenue DESC;

Demo 7: CTE + CASE WHEN — Phân nhóm khách hàng theo giá trị

sql
WITH customer_value AS (
    SELECT
        c.customer_id,
        c.customer_name,
        c.city,
        COUNT(o.order_id) AS total_orders,
        SUM(o.total_amount) AS total_spent,
        MAX(o.order_date) AS last_order
    FROM customers c
    LEFT JOIN orders o ON c.customer_id = o.customer_id
        AND o.status = 'completed'
    GROUP BY c.customer_id, c.customer_name, c.city
)
SELECT
    customer_name,
    city,
    total_orders,
    total_spent,
    last_order,
    CASE
        WHEN total_spent >= 20000000 THEN 'Platinum'
        WHEN total_spent >= 10000000 THEN 'Gold'
        WHEN total_spent >= 3000000  THEN 'Silver'
        WHEN total_spent > 0         THEN 'Bronze'
        ELSE 'Inactive'
    END AS tier,
    CASE
        WHEN last_order >= CURRENT_DATE - INTERVAL '30 days' THEN 'Active'
        WHEN last_order >= CURRENT_DATE - INTERVAL '90 days' THEN 'Warm'
        ELSE 'Cold'
    END AS engagement
FROM customer_value
ORDER BY total_spent DESC;

Demo 8: LEAD — Dự báo gap giữa 2 đơn hàng liên tiếp

sql
SELECT
    customer_id,
    order_date,
    LEAD(order_date) OVER (
        PARTITION BY customer_id
        ORDER BY order_date
    ) AS next_order_date,
    LEAD(order_date) OVER (
        PARTITION BY customer_id
        ORDER BY order_date
    ) - order_date AS days_between_orders
FROM orders
WHERE status = 'completed'
ORDER BY customer_id, order_date;

Demo 9: FIRST_VALUE / LAST_VALUE — Đơn hàng đầu tiên và cuối cùng

sql
SELECT
    customer_id,
    order_date,
    total_amount,
    FIRST_VALUE(total_amount) OVER (
        PARTITION BY customer_id
        ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS first_order_amount,
    LAST_VALUE(total_amount) OVER (
        PARTITION BY customer_id
        ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS last_order_amount
FROM orders
WHERE status = 'completed';

Demo 10: Nested CTE + Window — Phân tích Cohort Retention

sql
WITH first_purchase AS (
    SELECT
        customer_id,
        DATE_TRUNC('month', MIN(order_date)) AS cohort_month
    FROM orders
    WHERE status = 'completed'
    GROUP BY customer_id
),
monthly_activity AS (
    SELECT
        o.customer_id,
        fp.cohort_month,
        DATE_TRUNC('month', o.order_date) AS activity_month,
        (EXTRACT(YEAR FROM DATE_TRUNC('month', o.order_date)) -
         EXTRACT(YEAR FROM fp.cohort_month)) * 12 +
        (EXTRACT(MONTH FROM DATE_TRUNC('month', o.order_date)) -
         EXTRACT(MONTH FROM fp.cohort_month)) AS month_offset
    FROM orders o
    JOIN first_purchase fp ON o.customer_id = fp.customer_id
    WHERE o.status = 'completed'
),
cohort_size AS (
    SELECT cohort_month, COUNT(DISTINCT customer_id) AS num_customers
    FROM first_purchase
    GROUP BY cohort_month
)
SELECT
    ma.cohort_month,
    ma.month_offset,
    COUNT(DISTINCT ma.customer_id) AS retained_customers,
    cs.num_customers AS cohort_size,
    ROUND(
        COUNT(DISTINCT ma.customer_id) * 100.0 / cs.num_customers, 1
    ) AS retention_pct
FROM monthly_activity ma
JOIN cohort_size cs ON ma.cohort_month = cs.cohort_month
GROUP BY ma.cohort_month, ma.month_offset, cs.num_customers
ORDER BY ma.cohort_month, ma.month_offset;

Demo 11: UNION + CASE WHEN — Gộp nguồn revenue

sql
WITH all_revenue AS (
    SELECT 'Online' AS channel, order_date, total_amount
    FROM online_orders
    WHERE status = 'completed'

    UNION ALL

    SELECT 'Offline' AS channel, sale_date AS order_date, amount AS total_amount
    FROM pos_transactions
    WHERE status = 'completed'
)
SELECT
    DATE_TRUNC('month', order_date) AS month,
    channel,
    SUM(total_amount) AS revenue,
    SUM(SUM(total_amount)) OVER (
        PARTITION BY channel
        ORDER BY DATE_TRUNC('month', order_date)
    ) AS cumulative_revenue
FROM all_revenue
GROUP BY DATE_TRUNC('month', order_date), channel
ORDER BY month, channel;

🏪 Ví dụ thực tế từ doanh nghiệp

🌍 Global: Spotify — Window Functions cho Music Analytics

Spotify — nền tảng streaming nhạc với 600M+ users — sử dụng SQL window functions hàng ngày để phân tích hành vi nghe nhạc, xếp hạng nghệ sĩ, và đo lường engagement.

Bài toán phân tích tại Spotify:

Bài toánWindow Function dùngMô tả
Top 50 bài hát theo quốc giaROW_NUMBER() PARTITION BY countryXếp hạng bài hát theo lượt stream trong mỗi thị trường
Trending songsLAG(streams) + % changeSo sánh lượt stream tuần này vs tuần trước — tăng mạnh = trending
Cumulative streamsSUM(streams) OVER (ORDER BY date)Running total lượt nghe từ ngày phát hành — biểu đồ tích lũy
Artist ranking evolutionRANK() OVER (ORDER BY monthly_streams) theo thángTheo dõi thứ hạng nghệ sĩ thay đổi qua thời gian
Listening session gapLEAD(session_start) - session_endKhoảng thời gian giữa 2 lần nghe — đo engagement

Ví dụ SQL tại Spotify (đơn giản hóa):

sql
-- Top 5 bài hát trending mỗi quốc gia (tuần này vs tuần trước)
WITH weekly_streams AS (
    SELECT
        country,
        track_name,
        artist_name,
        SUM(CASE WHEN stream_date BETWEEN '2026-02-09' AND '2026-02-15'
                 THEN 1 ELSE 0 END) AS this_week,
        SUM(CASE WHEN stream_date BETWEEN '2026-02-02' AND '2026-02-08'
                 THEN 1 ELSE 0 END) AS last_week
    FROM streams
    GROUP BY country, track_name, artist_name
),
trending AS (
    SELECT
        country,
        track_name,
        artist_name,
        this_week,
        last_week,
        ROUND((this_week - last_week) * 100.0 / NULLIF(last_week, 0), 1) AS wow_growth,
        ROW_NUMBER() OVER (
            PARTITION BY country
            ORDER BY (this_week - last_week) * 100.0 / NULLIF(last_week, 0) DESC
        ) AS trend_rank
    FROM weekly_streams
    WHERE last_week >= 1000    -- Chỉ bài đã có lượng nghe đáng kể
)
SELECT country, track_name, artist_name, this_week, last_week, wow_growth
FROM trending
WHERE trend_rank <= 5
ORDER BY country, trend_rank;

Data Warehouse tại Spotify:

  • Spotify dùng Google BigQuery + internal data platform xây trên Star Schema
  • Fact table: fact_streams (hàng tỷ dòng/ngày) — mỗi dòng = 1 lượt nghe
  • Dimension tables: dim_track, dim_artist, dim_user, dim_date, dim_country
  • ETL pipeline xử lý 100+ TB data mỗi ngày — từ app event logs → warehouse → dashboard

🇻🇳 Việt Nam: VNPay — Window Functions cho phân tích giao dịch thanh toán

VNPay — nền tảng thanh toán số hàng đầu Việt Nam với 40M+ users — sử dụng SQL nâng cao để phân tích giao dịch, phát hiện gian lận, và theo dõi tăng trưởng merchant.

Database schema tại VNPay (đơn giản hóa — Star Schema):

BảngLoạiMô tảDòng (ước tính)
fact_transactionsFactMỗi giao dịch thanh toán500M+/tháng
dim_merchantDimensionCửa hàng, đối tác chấp nhận VNPay500K+
dim_customerDimensionNgười dùng VNPay40M+
dim_dateDimensionNgày, tuần, tháng, quý, năm10K+ dòng
dim_payment_methodDimensionQR, thẻ, ví điện tử, chuyển khoản20+ phương thức
dim_categoryDimensionNgành hàng: F&B, retail, utility...50+ danh mục

Bài toán phân tích tại VNPay:

Bài toánSQL Approach
GMV running total theo thángSUM(amount) OVER (ORDER BY date) — theo dõi mục tiêu quý
Top 10 merchant mỗi ngànhROW_NUMBER() PARTITION BY category ORDER BY gmv DESC
MoM transaction growthLAG(txn_count) OVER (ORDER BY month) → growth %
Moving avg giá trị giao dịch 7 ngàyAVG(avg_txn_value) OVER (ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
Tỷ trọng phương thức thanh toánSUM(amount) OVER (PARTITION BY method) / SUM(amount) OVER ()
Phát hiện giao dịch bất thườngLAG + LEAD để tìm spike đột ngột trong chuỗi giao dịch

Ví dụ SQL tại VNPay (đơn giản hóa):

sql
-- Running total GMV theo ngày trong tháng + Moving Average 7 ngày
WITH daily_gmv AS (
    SELECT
        dd.full_date,
        dc.category_name,
        SUM(ft.amount) AS daily_amount,
        COUNT(ft.txn_id) AS daily_txn_count
    FROM fact_transactions ft
    JOIN dim_date dd ON ft.date_key = dd.date_key
    JOIN dim_category dc ON ft.category_key = dc.category_key
    WHERE dd.year = 2026 AND dd.month = 2
      AND ft.status = 'success'
    GROUP BY dd.full_date, dc.category_name
)
SELECT
    full_date,
    category_name,
    daily_amount,
    daily_txn_count,
    SUM(daily_amount) OVER (
        PARTITION BY category_name
        ORDER BY full_date
    ) AS cumulative_gmv,
    ROUND(AVG(daily_amount) OVER (
        PARTITION BY category_name
        ORDER BY full_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ), 0) AS gmv_ma7d,
    ROUND(
        daily_amount * 100.0 / SUM(daily_amount) OVER (
            PARTITION BY full_date
        ), 1
    ) AS pct_of_daily_total
FROM daily_gmv
ORDER BY full_date, category_name;

Bài học cho DA:

  • VNPay xử lý 500M+ giao dịch/tháng — cùng cú pháp Window Function bạn đang học, nhưng chạy trên Data Warehouse (BigQuery/Redshift)
  • Star Schema tại VNPay: fact_transactions ở giữa, JOIN với dim_merchant, dim_customer, dim_date, dim_payment_method
  • CTE giúp query phân tích phức tạp dễ đọc, dễ review — team DA 20+ người cùng maintain codebase SQL
  • Window Functions thay thế hoàn toàn việc "xuất Excel → tính running total bằng SUM ô" — nhanh hơn, chính xác hơn, tái sử dụng được

✅ Checklist buổi học

Sau buổi học này, bạn nên tự tin trả lời "Có" cho tất cả các câu hỏi dưới đây:

  • [ ] Tôi phân biệt được Window Function vs GROUP BY: window giữ nguyên dòng, GROUP BY gom dòng
  • [ ] Tôi viết được OVER(), PARTITION BY, ORDER BY trong window function
  • [ ] Tôi phân biệt và sử dụng được ROW_NUMBER(), RANK(), DENSE_RANK()
  • [ ] Tôi dùng được NTILE(n) để chia dữ liệu thành n nhóm bằng nhau
  • [ ] Tôi viết được LAG()LEAD() để so sánh dòng hiện tại vs dòng trước/sau
  • [ ] Tôi tính được running total bằng SUM() OVER (ORDER BY ... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
  • [ ] Tôi tính được moving average 7 ngày bằng AVG() OVER (ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
  • [ ] Tôi tính được percent of total bằng value / SUM(value) OVER ()
  • [ ] Tôi tính được MoM/YoY growth bằng LAG() kết hợp phép tính %
  • [ ] Tôi giải thích được OLTP vs OLAP và tại sao cần Data Warehouse
  • [ ] Tôi hiểu Star Schema: fact table (metrics) ở giữa, dimension tables (context) xung quanh
  • [ ] Tôi phân biệt được fact table vs dimension table và cho ví dụ thực tế
  • [ ] Tôi giải thích được ETL, Data Mart, Data Lake là gì
  • [ ] Tôi viết được CTE (WITH ... AS) để chia query phức tạp thành bước rõ ràng
  • [ ] Tôi viết được nested CTE (nhiều WITH nối tiếp)
  • [ ] Tôi sử dụng được CASE WHEN để tạo logic phân nhóm trong SQL
  • [ ] Tôi phân biệt được UNION, UNION ALL, INTERSECT, EXCEPT

🔑 Từ khóa quan trọng

Tiếng ViệtEnglishGiải thích
Hàm cửa sổWindow FunctionHàm SQL tính toán trên tập dòng liên quan mà không gom nhóm — dùng OVER()
Phân vùngPARTITION BYChia dữ liệu thành nhóm trong window function — mỗi nhóm tính riêng
Xếp hạngRanking (ROW_NUMBER, RANK, DENSE_RANK)Gán thứ hạng cho mỗi dòng dựa trên giá trị sắp xếp
Dòng trước/sauLAG / LEADTruy cập giá trị dòng trước (LAG) hoặc dòng sau (LEAD) dòng hiện tại
Tích lũyRunning TotalTổng cộng dồn từ đầu kỳ đến dòng hiện tại — SUM() OVER (ORDER BY)
Trung bình trượtMoving AverageTrung bình N dòng/ngày gần nhất — làm mượt biến động
Tỷ trọngPercent of TotalTỷ lệ % của giá trị dòng so với tổng nhóm hoặc tổng toàn bộ
Tăng trưởng theo kỳMoM / YoY GrowthSo sánh giá trị tháng này vs tháng trước (MoM) hoặc cùng kỳ năm trước (YoY)
Kho dữ liệuData WarehouseHệ thống lưu trữ dữ liệu tập trung, tối ưu cho phân tích — OLAP
Bảng sự kiệnFact TableBảng chứa metrics/measures — mỗi dòng là 1 sự kiện kinh doanh
Bảng chiềuDimension TableBảng chứa thông tin mô tả (context): ai, cái gì, ở đâu, khi nào
Mô hình saoStar SchemaKiến trúc data warehouse: fact table ở giữa, dimension tables xung quanh
Biểu thức bảng chungCTE (Common Table Expression)Bảng tạm có tên trong query — WITH ... AS (...) — giúp chia bước logic
Khung cửa sổWindow Frame (ROWS BETWEEN)Xác định phạm vi dòng cho phép tính window — PRECEDING, CURRENT ROW, FOLLOWING

🔗 Xem thêm Buổi 6

📝 Blog🧠 Case Study🏆 Tiêu chuẩn🛠 Workshop🎮 Mini Game