Skip to content

🧠 Case Study Buổi 6: SQL — Window Function & Data Warehouse

Bài học thực tế từ các nền tảng công nghệ hàng đầu thế giới và Việt Nam

Tổng quan

Trong buổi học này, chúng ta đã tìm hiểu về Window Functions (ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD), Running Total, Moving Average, kiến trúc Data Warehouse (Star Schema — Fact vs Dimension Table), CTE (Common Table Expression), và quy trình ETL. Lý thuyết cho thấy Window Functions là bước nhảy từ SQL cơ bản sang SQL phân tích chuyên nghiệp, còn Data Warehouse là cách tổ chức dữ liệu tối ưu cho phân tích quy mô lớn. Nhưng chỉ khi nhìn vào thực tế, bạn mới thấy sức mạnh thực sự: ranking top bài hát theo từng quốc gia mỗi tuần (Spotify), phân tích doanh thu bán lẻ quy mô petabyte trên star schema (Amazon), hay xây dựng data warehouse cho hàng trăm triệu giao dịch thanh toán điện tử (VNPay).

Ba case study dưới đây minh họa cách Window Functions và Data Warehouse được áp dụng ở 3 bối cảnh khác nhau — từ nền tảng music streaming lớn nhất thế giới (Spotify), đế chế e-commerce toàn cầu (Amazon), đến cổng thanh toán điện tử hàng đầu Việt Nam (VNPay). Mỗi case study sẽ trả lời 3 câu hỏi cốt lõi: (1) Dữ liệu gì được lưu và tổ chức như thế nào? (2) Phân tích bằng kỹ thuật SQL nào (Window Function, CTE, Star Schema)? (3) Quyết định kinh doanh nào đã thay đổi nhờ kết quả phân tích?

#Công tyVấn đềKỹ thuật SQL chính
1SpotifyRanking top songs per region per weekROW_NUMBER, RANK, DENSE_RANK, PARTITION BY, LAG
2AmazonStar schema cho retail analytics quy mô petabyteStar Schema, Fact/Dimension Tables, CTE, SUM() OVER
3VNPayData Warehouse cho transaction analyticsData Warehouse, ETL, Running Total, LAG/LEAD, CTE

Case Study 1: Spotify — Window Function cho ranking top songs per region per week

🏷️ Thông tin

Tiêu chíChi tiết
Công tySpotify Technology S.A.
NgànhMusic Streaming — Phát nhạc trực tuyến
Quy mô675+ triệu monthly active users, 260+ triệu Premium subscribers, 100+ triệu bài hát, hoạt động tại 180+ quốc gia, revenue ~€15.7 tỷ (2025)
Thị trườngGlobal — 180+ quốc gia và vùng lãnh thổ
Chủ đề DA liên quanWindow Functions (ROW_NUMBER, RANK, DENSE_RANK, LAG), PARTITION BY, CTE, trend analysis, ranking per group

📋 Bối cảnh

Spotify là nền tảng music streaming lớn nhất thế giới, với hơn 675 triệu người dùng hoạt động hàng tháng trên 180+ quốc gia. Mỗi ngày, người dùng tạo ra hơn 1.8 tỷ lượt stream — mỗi lượt stream ghi lại thông tin: user nào, nghe bài gì, của nghệ sĩ nào, ở quốc gia nào, lúc mấy giờ, nghe bao lâu, skip hay nghe hết.

Bộ phận Content & Editorial Analytics tại Spotify chịu trách nhiệm tạo bảng xếp hạng hàng tuần (Weekly Charts) cho từng quốc gia — dữ liệu này quyết định: bài hát nào hiển thị trên "Top 50 Vietnam", "Top Songs Global", playlist "Today's Top Hits" (37+ triệu followers); nghệ sĩ nào được promote trên homepage; label nào được ưu tiên trong editorial playlists; và Spotify trả royalties cho label/nghệ sĩ dựa trên stream share.

Vấn đề cốt lõi: Spotify cần xếp hạng hàng triệu bài hát trong mỗi quốc gia mỗi tuần — không phải xếp hạng toàn cầu đơn giản (chỉ cần ORDER BY streams DESC), mà là ranking riêng trong từng quốc gia (PARTITION BY country) và so sánh thay đổi thứ hạng tuần này vs tuần trước (LAG). Đây chính xác là bài toán mà GROUP BY không giải quyết được — cần Window Functions.

⚡ Thách thức

  1. Xếp hạng per region, không phải global: Bài hát "APT." của Rosé & Bruno Mars có thể #1 ở Việt Nam nhưng #15 ở Mỹ, #3 ở Hàn Quốc. Cần ranking riêng biệt cho 180+ quốc gia trong cùng 1 query — GROUP BY chỉ cho tổng streams, không cho thứ hạng trong nhóm.
  2. Trend tracking tuần-qua-tuần: Sếp hỏi: "Bài nào tăng hạng nhanh nhất tuần này so với tuần trước?" — cần truy cập dòng tuần trước để so sánh. SQL cơ bản không có khái niệm "dòng trước" — cần LAG().
  3. Phân biệt RANK vs DENSE_RANK: Nếu 2 bài hát có cùng số streams, chúng nên xếp hạng giống nhau (tie). Nhưng bài tiếp theo nên xếp hạng gì? #3 (DENSE_RANK) hay #4 (RANK)? Spotify chọn DENSE_RANK cho charts công khai để không "nhảy hạng".
  4. Khối lượng dữ liệu cực lớn: ~1.8 tỷ streams/ngày × 7 ngày = ~12.6 tỷ stream records/tuần. Cần aggregate → rồi rank → rồi compare → trên 180+ quốc gia × 100 triệu bài hát. Query phải hiệu quả.
  5. Phát hiện viral songs sớm: Bài hát mới release mà tuần đầu đã vào top 50 — cần flag "New Entry" (tuần trước không có trên bảng xếp hạng). Điều này cần logic: nếu LAG() trả về NULL → đây là new entry.

🛠️ Giải pháp

Dữ liệu gì? (Database schema)

┌──────────────────┐       ┌──────────────────────┐       ┌──────────────────┐
│     tracks       │       │   weekly_streams      │       │     artists      │
├──────────────────┤       ├──────────────────────┤       ├──────────────────┤
│ track_id (PK)    │───┐   │ stream_id (PK)       │   ┌───│ artist_id (PK)   │
│ track_name       │   └──>│ track_id (FK)        │   │   │ artist_name      │
│ artist_id (FK)   │───────│ artist_id (FK)       │<──┘   │ label            │
│ album_id         │       │ country_code         │       │ genre            │
│ genre            │       │ week_start_date      │       │ country_origin   │
│ duration_ms      │       │ total_streams        │       │ monthly_listeners│
│ release_date     │       │ unique_listeners     │       └──────────────────┘
│ is_explicit      │       │ avg_stream_duration  │
└──────────────────┘       │ skip_rate            │       ┌──────────────────┐
                           │ save_rate            │       │    countries     │
                           └──────────────────────┘       ├──────────────────┤
                                                          │ country_code (PK)│
                                                          │ country_name     │
                                                          │ region           │
                                                          └──────────────────┘
  • tracks: ~100 triệu records — metadata mỗi bài hát
  • weekly_streams: aggregate từ raw streams — 1 dòng per (track, country, week). ~180 quốc gia × top 10,000 tracks = ~1.8 triệu records/tuần
  • artists: ~11 triệu records — thông tin nghệ sĩ
  • countries: 180+ records — mapping country_code → tên, khu vực

Phân tích bằng SQL query nào?

Query 1: Top 10 bài hát mỗi quốc gia mỗi tuần — ROW_NUMBER + PARTITION BY

sql
-- Xếp hạng top 10 bài hát theo streams cho MỖI quốc gia trong tuần
-- ROW_NUMBER: mỗi bài có 1 thứ hạng duy nhất (không tie)
-- PARTITION BY country_code: xếp hạng RIÊNG cho từng quốc gia

WITH weekly_ranking AS (
    SELECT
        ws.country_code,
        c.country_name,
        ws.track_id,
        t.track_name,
        a.artist_name,
        ws.total_streams,
        ws.unique_listeners,
        ROW_NUMBER() OVER (
            PARTITION BY ws.country_code
            ORDER BY ws.total_streams DESC
        ) AS rank_position
    FROM weekly_streams ws
    JOIN tracks t ON ws.track_id = t.track_id
    JOIN artists a ON t.artist_id = a.artist_id
    JOIN countries c ON ws.country_code = c.country_code
    WHERE ws.week_start_date = '2026-02-09'
)
SELECT *
FROM weekly_ranking
WHERE rank_position <= 10
ORDER BY country_code, rank_position;

Kết quả ví dụ (trích — Vietnam):

country_codecountry_nametrack_nameartist_nametotal_streamsunique_listenersrank_position
VNVietnamĐừng Làm Trái Tim Anh ĐauSơn Tùng M-TP18,500,0004,200,0001
VNVietnamAPT.Rosé & Bruno Mars15,200,0003,800,0002
VNVietnamDie With A SmileLady Gaga & Bruno Mars12,800,0003,100,0003
VNVietnamCó Em ChờMIN & MR.A11,500,0002,900,0004
VNVietnamEspressoSabrina Carpenter10,200,0002,600,0005

Insight: ROW_NUMBER + PARTITION BY cho phép Spotify tạo bảng xếp hạng riêng biệt cho 180+ quốc gia trong 1 query duy nhất. Không cần viết 180 query khác nhau. Đây là sức mạnh cốt lõi của Window Functions — xếp hạng trong mỗi nhóm mà vẫn giữ nguyên chi tiết từng dòng.

Query 2: So sánh thứ hạng tuần này vs tuần trước — LAG() + CTE

sql
-- So sánh rank tuần này với tuần trước cho mỗi bài hát tại mỗi quốc gia
-- LAG(): truy cập giá trị của dòng trước (tuần trước) trong cùng partition

WITH current_week AS (
    SELECT
        ws.country_code,
        ws.track_id,
        t.track_name,
        a.artist_name,
        ws.total_streams,
        ws.week_start_date,
        DENSE_RANK() OVER (
            PARTITION BY ws.country_code
            ORDER BY ws.total_streams DESC
        ) AS current_rank
    FROM weekly_streams ws
    JOIN tracks t ON ws.track_id = t.track_id
    JOIN artists a ON t.artist_id = a.artist_id
    WHERE ws.week_start_date = '2026-02-09'
),
previous_week AS (
    SELECT
        ws.country_code,
        ws.track_id,
        ws.total_streams AS prev_streams,
        DENSE_RANK() OVER (
            PARTITION BY ws.country_code
            ORDER BY ws.total_streams DESC
        ) AS prev_rank
    FROM weekly_streams ws
    WHERE ws.week_start_date = '2026-02-02'
)
SELECT
    cw.country_code,
    cw.track_name,
    cw.artist_name,
    cw.total_streams,
    cw.current_rank,
    pw.prev_rank,
    CASE
        WHEN pw.prev_rank IS NULL THEN 'NEW'
        WHEN cw.current_rank < pw.prev_rank THEN '▲ +' || (pw.prev_rank - cw.current_rank)
        WHEN cw.current_rank > pw.prev_rank THEN '▼ -' || (cw.current_rank - pw.prev_rank)
        ELSE '= (unchanged)'
    END AS rank_change,
    CASE
        WHEN pw.prev_streams IS NOT NULL AND pw.prev_streams > 0 THEN
            ROUND((cw.total_streams - pw.prev_streams) * 100.0 / pw.prev_streams, 1)
        ELSE NULL
    END AS stream_growth_pct
FROM current_week cw
LEFT JOIN previous_week pw
    ON cw.country_code = pw.country_code
    AND cw.track_id = pw.track_id
WHERE cw.current_rank <= 20
  AND cw.country_code = 'VN'
ORDER BY cw.current_rank;

Kết quả ví dụ (Vietnam, tuần 2026-02-09):

country_codetrack_nameartist_nametotal_streamscurrent_rankprev_rankrank_changestream_growth_pct
VNĐừng Làm Trái Tim Anh ĐauSơn Tùng M-TP18,500,00013▲ +245.2
VNAPT.Rosé & Bruno Mars15,200,00021▼ -1-8.5
VNDie With A SmileLady Gaga & Bruno Mars12,800,00032▼ -1-3.2
VNCó Em ChờMIN & MR.A11,500,0004NULLNEWNULL
VNEspressoSabrina Carpenter10,200,00054▲ +112.8

Insight: Sơn Tùng M-TP tăng 2 bậc lên #1 ở Việt Nam với stream growth +45.2% — dấu hiệu viral. "Có Em Chờ" là NEW entry (tuần trước không có trên chart) → Editorial team đưa vào playlist "New Music Friday Vietnam". "APT." giảm 1 bậc nhưng vẫn giữ #2 — bài hát ở giai đoạn plateau sau khi peak 3 tuần trước. Spotify dùng insight này để quyết định bài nào promote trên homepage Vietnam.

Query 3: Bài hát debut mạnh nhất — LAG() trả NULL = New Entry

sql
-- Tìm bài hát mới debut tuần này (chưa từng lên chart tuần trước)
-- mà đã vào top 50 → dấu hiệu viral, cần promote ngay

WITH ranked_songs AS (
    SELECT
        ws.country_code,
        ws.track_id,
        t.track_name,
        a.artist_name,
        t.release_date,
        ws.total_streams,
        ws.week_start_date,
        DENSE_RANK() OVER (
            PARTITION BY ws.country_code
            ORDER BY ws.total_streams DESC
        ) AS chart_rank,
        LAG(ws.total_streams) OVER (
            PARTITION BY ws.country_code, ws.track_id
            ORDER BY ws.week_start_date
        ) AS prev_week_streams
    FROM weekly_streams ws
    JOIN tracks t ON ws.track_id = t.track_id
    JOIN artists a ON t.artist_id = a.artist_id
    WHERE ws.week_start_date IN ('2026-02-02', '2026-02-09')
)
SELECT
    country_code,
    track_name,
    artist_name,
    release_date,
    total_streams,
    chart_rank,
    'NEW ENTRY' AS status
FROM ranked_songs
WHERE week_start_date = '2026-02-09'
  AND chart_rank <= 50
  AND prev_week_streams IS NULL    -- LAG = NULL → tuần trước không có dữ liệu
ORDER BY country_code, chart_rank;

Insight: LAG() trả NULL khi không tồn tại dòng trước — tức bài hát này chưa từng xuất hiện trên chart. Spotify tự động flag các bài hát "New Entry in Top 50" để Editorial team review và đưa vào playlist. Trung bình mỗi tuần có 200–400 new entries toàn cầu, nhưng chỉ 15–20 bài debut ở top 10 — đây là tiềm năng viral lớn.

Query 4: Running total streams tích lũy — SUM() OVER + CTE

sql
-- Tính running total streams tích lũy theo tuần cho 1 bài hát tại 1 quốc gia
-- Mục tiêu: theo dõi trajectory — bài hát đang tăng trưởng hay suy giảm?

WITH track_weekly AS (
    SELECT
        ws.week_start_date,
        ws.total_streams,
        ws.unique_listeners,
        SUM(ws.total_streams) OVER (
            ORDER BY ws.week_start_date
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS cumulative_streams,
        ROUND(AVG(ws.total_streams) OVER (
            ORDER BY ws.week_start_date
            ROWS BETWEEN 3 PRECEDING AND CURRENT ROW
        ), 0) AS moving_avg_4w,
        LAG(ws.total_streams, 1) OVER (
            ORDER BY ws.week_start_date
        ) AS prev_week_streams,
        ROUND(
            (ws.total_streams - LAG(ws.total_streams, 1) OVER (
                ORDER BY ws.week_start_date
            )) * 100.0 /
            NULLIF(LAG(ws.total_streams, 1) OVER (
                ORDER BY ws.week_start_date
            ), 0), 1
        ) AS wow_growth_pct
    FROM weekly_streams ws
    WHERE ws.track_id = 'SPT_TRACK_001'    -- APT. - Rosé & Bruno Mars
      AND ws.country_code = 'VN'
)
SELECT *
FROM track_weekly
ORDER BY week_start_date;

Kết quả ví dụ:

week_start_datetotal_streamsunique_listenerscumulative_streamsmoving_avg_4wprev_week_streamswow_growth_pct
2026-01-058,500,0002,100,0008,500,0008,500,000NULLNULL
2026-01-1212,200,0002,800,00020,700,00010,350,0008,500,00043.5
2026-01-1916,800,0003,500,00037,500,00012,500,00012,200,00037.7
2026-01-2618,100,0003,900,00055,600,00013,900,00016,800,0007.7
2026-02-0216,600,0003,700,00072,200,00015,925,00018,100,000-8.3
2026-02-0915,200,0003,800,00087,400,00016,675,00016,600,000-8.4

Insight: "APT." đạt peak ở tuần 2026-01-26 (18.1M streams) rồi bắt đầu giảm — WoW growth chuyển từ +7.7% sang -8.3%, -8.4%. Moving average 4 tuần cho thấy trend đang đi ngang/giảm nhẹ. Cumulative streams đạt 87.4M sau 6 tuần. Spotify dùng insight này để quyết định: bài hát đang ở giai đoạn "decline" → giảm dần vị trí trên playlist lớn, nhường chỗ cho bài hát mới đang trending lên.

Query 5: Stream share — tỷ trọng mỗi bài hát trong tổng quốc gia

sql
-- Tính % stream mỗi bài hát so với tổng streams quốc gia (Percent of Total)
-- SUM() OVER (PARTITION BY country) = tổng toàn bộ quốc gia, giữ nguyên mỗi dòng

SELECT
    ws.country_code,
    t.track_name,
    a.artist_name,
    ws.total_streams,
    SUM(ws.total_streams) OVER (
        PARTITION BY ws.country_code
    ) AS country_total_streams,
    ROUND(
        ws.total_streams * 100.0 /
        SUM(ws.total_streams) OVER (PARTITION BY ws.country_code),
        3
    ) AS stream_share_pct
FROM weekly_streams ws
JOIN tracks t ON ws.track_id = t.track_id
JOIN artists a ON t.artist_id = a.artist_id
WHERE ws.week_start_date = '2026-02-09'
  AND ws.country_code = 'VN'
ORDER BY ws.total_streams DESC
LIMIT 10;

Insight: Top 1 bài hát (Sơn Tùng M-TP) chiếm 2.8% tổng streams Việt Nam, top 10 chiếm ~15%. Điều này cho thấy Spotify Vietnam có long-tail distribution — hàng ngàn bài hát chia sẻ 85% streams còn lại, healthy cho hệ sinh thái. Nếu 1 quốc gia có top 1 chiếm >10% → quá tập trung, rủi ro nếu nghệ sĩ đó rời nền tảng. Spotify dùng stream_share_pct làm cơ sở tính royalty cho label và nghệ sĩ.

📊 Kết quả

Chỉ sốTrước (SQL cơ bản / Export Excel)Sau (Window Functions + CTE)
Thời gian tạo weekly chart 180+ quốc gia4–6 giờ (query từng quốc gia riêng)15 phút (1 query PARTITION BY country)
So sánh rank tuần này vs tuần trướcThủ công: export 2 tuần → VLOOKUPTự động: LAG() trong cùng query
Phát hiện New Entry / viral songsCuối tuần (manual review)Real-time: LAG() IS NULL → auto-flag
Tính cumulative streams cho labelExport → Excel → running totalSUM() OVER (ROWS UNBOUNDED PRECEDING)
Stream share cho royalty calculationChạy 2 query riêng: tổng + chi tiết → chia1 query: SUM() OVER (PARTITION BY country)
Số query cần viết cho global charts180+ queries (1 per country)1 query duy nhất
Tính moving average trendExcel: AVERAGE(D2:D5) kéo xuốngSQL: AVG() OVER (ROWS 3 PRECEDING)

Window Functions giúp Spotify Content Analytics team biến 12.6 tỷ stream records/tuần thành bảng xếp hạng chính xác cho 180+ quốc gia — từ ranking per region, trend tracking WoW, phát hiện viral songs, đến tính stream share cho royalty. Tất cả chỉ cần 1 nhóm query với PARTITION BY — thay vì 180+ query riêng lẻ.

💡 Bài học cho Data Analyst Việt Nam

  1. PARTITION BY là "GROUP BY không collapse". GROUP BY gom 18 triệu dòng streams Việt Nam thành 1 dòng tổng. PARTITION BY giữ nguyên 18 triệu dòng nhưng thêm cột tổng bên cạnh — bạn vừa thấy chi tiết, vừa thấy tổng thể. Đây là khác biệt cốt lõi và quyền lực nhất của Window Functions.
  2. LAG() thay thế self-JOIN cho so sánh kỳ trước. Trước khi có Window Functions, để so sánh tuần này vs tuần trước, bạn phải self-JOIN bảng với chính nó (chậm, khó đọc). LAG(column, 1) = giá trị dòng trước — 1 dòng code thay 10 dòng JOIN.
  3. CTE giúp query phức tạp trở nên đọc được. Query 2 (so sánh rank) dùng 2 CTE: current_weekprevious_week — mỗi CTE có tên rõ ràng, logic riêng, dễ debug. Nếu viết tất cả trong 1 query lồng nhau (subquery in subquery) — không ai đọc nổi.
  4. Running total + Moving average = Trend analysis. SUM() OVER (ORDER BY date) cho running total, AVG() OVER (ROWS 3 PRECEDING) cho moving average 4 tuần. Hai giá trị này giúp phân biệt: bài hát đang tăng trưởng (running total tăng nhanh, MA tăng) hay suy giảm (running total tăng chậm lại, MA giảm).

Case Study 2: Amazon — Star Schema cho retail analytics quy mô petabyte

🏷️ Thông tin

Tiêu chíChi tiết
Công tyAmazon.com, Inc.
NgànhE-commerce / Cloud Computing — Thương mại điện tử
Quy mô310+ triệu active customer accounts, ~$640 tỷ USD revenue (2025), 12+ triệu products, 2+ triệu third-party sellers
Thị trườngGlobal — 20+ marketplace quốc gia (US, UK, DE, JP, IN, ...)
Chủ đề DA liên quanStar Schema, Fact Table vs Dimension Table, CTE, SUM() OVER, RANK(), Data Warehouse, ETL pipeline

📋 Bối cảnh

Amazon là công ty e-commerce lớn nhất thế giới, xử lý hơn 1.6 triệu packages/ngày tại thị trường Mỹ và hàng triệu đơn hàng toàn cầu. Dữ liệu kinh doanh Amazon bao gồm: thông tin sản phẩm (12+ triệu SKUs), thông tin khách hàng (310+ triệu accounts), đơn hàng (hàng tỷ records/năm), thanh toán, vận chuyển, đánh giá, quảng cáo, và seller performance.

Bộ phận Retail Analytics tại Amazon Redshift (Data Warehouse) cần trả lời hàng nghìn câu hỏi kinh doanh mỗi ngày: "Doanh thu theo category tuần này?", "Top sellers theo marketplace?", "Customer Lifetime Value theo phân khúc?", "Tồn kho bao nhiêu ngày?". Hệ thống OLTP (Online Transaction Processing — database giao dịch thời gian thực) không thể chạy những query phân tích nặng này — sẽ làm chậm checkout flow.

Giải pháp: Amazon xây dựng Data Warehouse tách biệt, dữ liệu được ETL (Extract-Transform-Load) từ systems giao dịch vào warehouse theo mô hình Star Schema — tối ưu cho truy vấn phân tích. Star Schema là kiến trúc chuẩn công nghiệp cho data warehouse, trong đó 1 fact table trung tâm (chứa metrics — revenue, quantity, cost) được bao quanh bởi nhiều dimension tables (chứa mô tả — product info, customer info, date info, geography info).

⚡ Thách thức

  1. OLTP không thể phục vụ analytics: Database giao dịch của Amazon được thiết kế cho INSERT/UPDATE nhanh (đặt hàng, thanh toán) — schema normalized (3NF) với hàng trăm bảng. Query phân tích cần JOIN 10+ bảng, scan hàng tỷ dòng → làm chậm giao dịch thực. Cần tách biệt OLTP ↔ OLAP.
  2. Schema quá phức tạp cho DA: Schema OLTP có 200+ bảng normalized — DA query 1 báo cáo đơn giản cần JOIN 8–12 bảng. Cần mô hình đơn giản hơn: Star Schema với 1 fact table + 4–6 dimension tables → DA JOIN tối đa 4–5 bảng.
  3. Tính running total và year-to-date: Ban lãnh đạo muốn báo cáo doanh thu tích lũy (YTD revenue), so sánh MoM/YoY. Cần Window Functions trên fact table: SUM() OVER (ORDER BY month) cho running total, LAG() cho so sánh kỳ trước.
  4. Ranking sản phẩm trong mỗi category: Với 12+ triệu SKUs trải trên 30+ categories — cần ranking top products in each category. Bài toán tương tự Spotify nhưng cho e-commerce: RANK() OVER (PARTITION BY category_id).
  5. Data freshness vs query performance: Data cần được ETL từ OLTP vào Data Warehouse hàng ngày/giờ. Nếu ETL chậm → DA xem data cũ. Nếu ETL quá frequent → warehouse overload.

🛠️ Giải pháp

Dữ liệu gì? (Star Schema design)

                         ┌────────────────────┐
                         │  dim_customers     │
                         ├────────────────────┤
                         │ customer_key (PK)  │
                         │ customer_id        │
                         │ customer_name      │
                         │ email              │
                         │ segment            │
                         │ signup_date        │
                         │ country            │
                         │ city               │
                         └────────┬───────────┘

┌────────────────────┐   ┌───────┴────────────────────┐   ┌────────────────────┐
│  dim_products      │   │      fact_orders           │   │  dim_dates         │
├────────────────────┤   ├────────────────────────────┤   ├────────────────────┤
│ product_key (PK)   │──>│ order_key (PK)             │<──│ date_key (PK)      │
│ product_id         │   │ product_key (FK)           │   │ full_date          │
│ product_name       │   │ customer_key (FK)          │   │ day_of_week        │
│ category           │   │ date_key (FK)              │   │ week_number        │
│ sub_category       │   │ seller_key (FK)            │   │ month              │
│ brand              │   │ marketplace_key (FK)       │   │ quarter            │
│ price              │   │ ────── MEASURES ──────     │   │ year               │
│ cost               │   │ quantity                   │   │ is_holiday          │
│ weight_kg          │   │ unit_price                 │   │ fiscal_quarter     │
└────────────────────┘   │ discount_amount            │   └────────────────────┘
                         │ net_revenue                │
┌────────────────────┐   │ shipping_cost              │   ┌────────────────────┐
│  dim_sellers       │   │ tax_amount                 │   │  dim_marketplace   │
├────────────────────┤   │ total_amount               │   ├────────────────────┤
│ seller_key (PK)    │──>│ is_prime                   │   │ mkt_key (PK)       │
│ seller_id          │   │ fulfillment_type           │   │ marketplace_id     │
│ seller_name        │   │ order_status               │   │ marketplace_name   │
│ seller_type        │   └────────────────────────────┘   │ currency           │
│ seller_rating      │                                    │ timezone           │
│ registration_date  │                                    └────────────────────┘
└────────────────────┘

Tại sao Star Schema?

Đặc điểmOLTP (Normalized 3NF)OLAP — Star Schema
Mục đíchGhi giao dịch nhanh (INSERT/UPDATE)Phân tích nhanh (SELECT/JOIN/GROUP BY)
Số bảng200+1 fact + 5–6 dimensions = 6–7 bảng
RedundancyTối thiểu (no duplicate data)Chấp nhận (dimension table denormalized)
JOIN cần thiết8–12 bảng cho 1 report2–5 bảng tối đa
Query speedChậm cho analyticsNhanh — tối ưu cho aggregate + scan
DA usabilityKhó — cần hiểu 200+ bảngDễ — fact table trung tâm, dimensions mô tả

Phân tích bằng SQL query nào?

Query 1: Doanh thu theo category + Running total YTD — CTE + SUM() OVER

sql
-- Tính doanh thu theo category theo tháng + running total tích lũy YTD
-- Star Schema: fact_orders JOIN dim_products + dim_dates

WITH monthly_revenue AS (
    SELECT
        p.category,
        d.year,
        d.month,
        SUM(f.net_revenue) AS monthly_revenue,
        SUM(f.quantity) AS monthly_units
    FROM fact_orders f
    JOIN dim_products p ON f.product_key = p.product_key
    JOIN dim_dates d ON f.date_key = d.date_key
    WHERE d.year = 2026
      AND f.order_status = 'delivered'
    GROUP BY p.category, d.year, d.month
)
SELECT
    category,
    year,
    month,
    monthly_revenue,
    monthly_units,
    SUM(monthly_revenue) OVER (
        PARTITION BY category
        ORDER BY month
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS ytd_revenue,
    LAG(monthly_revenue, 1) OVER (
        PARTITION BY category
        ORDER BY month
    ) AS prev_month_revenue,
    ROUND(
        (monthly_revenue - LAG(monthly_revenue, 1) OVER (
            PARTITION BY category ORDER BY month
        )) * 100.0 /
        NULLIF(LAG(monthly_revenue, 1) OVER (
            PARTITION BY category ORDER BY month
        ), 0), 1
    ) AS mom_growth_pct
FROM monthly_revenue
ORDER BY category, month;

Kết quả ví dụ (trích — Electronics & Books):

categoryyearmonthmonthly_revenuemonthly_unitsytd_revenueprev_month_revenuemom_growth_pct
Electronics202618,200,000,0001,250,0008,200,000,000NULLNULL
Electronics202627,500,000,0001,100,00015,700,000,0008,200,000,000-8.5
Books202611,800,000,0003,500,0001,800,000,000NULLNULL
Books202622,100,000,0004,200,0003,900,000,0001,800,000,00016.7

Insight: Electronics giảm 8.5% MoM (post-holiday) nhưng YTD vẫn dẫn đầu với $15.7B. Books tăng 16.7% MoM — có thể do back-to-school season. Running total (ytd_revenue) giúp ban lãnh đạo theo dõi tiến độ so với target năm: Electronics YTD = $15.7B, target = $98B → đạt 16% sau 2 tháng (on track). Star Schema cho phép query này chỉ cần JOIN 2 dimension tables — thay vì 8+ bảng ở OLTP schema.

Query 2: Top 5 sản phẩm mỗi category — RANK() OVER + PARTITION BY

sql
-- Xếp hạng top 5 sản phẩm theo doanh thu trong MỖI category
-- Star Schema: JOIN fact_orders → dim_products → dim_dates

WITH product_sales AS (
    SELECT
        p.category,
        p.product_name,
        p.brand,
        SUM(f.net_revenue) AS total_revenue,
        SUM(f.quantity) AS total_units,
        COUNT(DISTINCT f.customer_key) AS unique_buyers,
        RANK() OVER (
            PARTITION BY p.category
            ORDER BY SUM(f.net_revenue) DESC
        ) AS category_rank
    FROM fact_orders f
    JOIN dim_products p ON f.product_key = p.product_key
    JOIN dim_dates d ON f.date_key = d.date_key
    WHERE d.year = 2026
      AND d.month <= 2
      AND f.order_status IN ('delivered', 'shipped')
    GROUP BY p.category, p.product_name, p.brand
)
SELECT
    category,
    product_name,
    brand,
    total_revenue,
    total_units,
    unique_buyers,
    category_rank
FROM product_sales
WHERE category_rank <= 5
ORDER BY category, category_rank;

Insight: RANK() OVER (PARTITION BY category) cho phép Amazon xếp hạng sản phẩm trong từng category riêng biệt — iPhone 16 là #1 trong Electronics, không cạnh tranh xếp hạng với "Atomic Habits" #1 trong Books. Nếu 2 sản phẩm cùng doanh thu → cùng rank (RANK cho phép tie), sản phẩm tiếp theo nhảy hạng. Amazon dùng ranking này cho "Best Sellers" page trên website — cập nhật hàng giờ.

Query 3: Customer Lifetime Value phân khúc — CTE nhiều bước + Window Function

sql
-- Tính CLV (Customer Lifetime Value) theo phân khúc khách hàng
-- Star Schema: fact_orders + dim_customers + dim_dates
-- Nhiều CTE: step 1 tính metrics/customer → step 2 phân khúc → step 3 aggregate

WITH customer_metrics AS (
    -- Step 1: Tính metrics cho mỗi khách hàng
    SELECT
        c.customer_key,
        c.segment,
        c.signup_date,
        COUNT(DISTINCT f.order_key) AS total_orders,
        SUM(f.net_revenue) AS total_revenue,
        MIN(d.full_date) AS first_order_date,
        MAX(d.full_date) AS last_order_date,
        SUM(f.net_revenue) / NULLIF(COUNT(DISTINCT f.order_key), 0) AS avg_order_value
    FROM fact_orders f
    JOIN dim_customers c ON f.customer_key = c.customer_key
    JOIN dim_dates d ON f.date_key = d.date_key
    WHERE f.order_status = 'delivered'
    GROUP BY c.customer_key, c.segment, c.signup_date
),
customer_tiers AS (
    -- Step 2: Phân hạng khách hàng dựa trên CLV
    SELECT
        *,
        NTILE(5) OVER (ORDER BY total_revenue DESC) AS revenue_quintile,
        CASE
            WHEN total_revenue >= 50000000 AND total_orders >= 50 THEN 'Diamond'
            WHEN total_revenue >= 20000000 AND total_orders >= 20 THEN 'Gold'
            WHEN total_revenue >= 5000000 AND total_orders >= 10 THEN 'Silver'
            ELSE 'Bronze'
        END AS customer_tier
    FROM customer_metrics
)
-- Step 3: Tổng hợp theo tier
SELECT
    customer_tier,
    COUNT(*) AS num_customers,
    ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) AS pct_of_total_customers,
    SUM(total_revenue) AS tier_total_revenue,
    ROUND(SUM(total_revenue) * 100.0 / SUM(SUM(total_revenue)) OVER (), 1) AS pct_of_total_revenue,
    ROUND(AVG(total_revenue), 0) AS avg_clv,
    ROUND(AVG(avg_order_value), 0) AS avg_aov,
    ROUND(AVG(total_orders), 1) AS avg_orders
FROM customer_tiers
GROUP BY customer_tier
ORDER BY avg_clv DESC;

Kết quả ví dụ:

customer_tiernum_customerspct_of_total_customerstier_total_revenuepct_of_total_revenueavg_clvavg_aovavg_orders
Diamond620,0000.245,200,000,000,00035.072,903,2261,458,06568.5
Gold3,100,0001.031,000,000,000,00024.010,000,000500,00032.2
Silver18,600,0006.027,900,000,000,00021.61,500,000300,00015.8
Bronze287,680,00092.825,100,000,000,00019.487,248145,0004.2

Insight: 0.2% khách hàng Diamond tạo ra 35% tổng doanh thu — xác nhận "quy tắc Pareto" trong e-commerce. SUM() OVER () (không PARTITION BY) tính tổng toàn bộ → lấy % mỗi tier so với toàn bộ. CTE 3 bước giúp query dài nhưng logic rõ ràng: tính metrics → phân hạng → tổng hợp. Amazon dùng insight này để ưu tiên Prime benefits cho Diamond/Gold — giữ chân 1.2% khách hàng tạo 59% revenue.

Query 4: Phân tích doanh thu theo ngày trong tuần — dim_dates dimension table

sql
-- Tìm ngày nào trong tuần có doanh thu cao nhất — dùng sức mạnh dim_dates
-- Dimension table dim_dates có sẵn cột day_of_week, is_holiday → không cần tính

SELECT
    d.day_of_week,
    SUM(f.net_revenue) AS total_revenue,
    COUNT(DISTINCT f.order_key) AS total_orders,
    ROUND(AVG(f.total_amount), 0) AS avg_order_value,
    RANK() OVER (ORDER BY SUM(f.net_revenue) DESC) AS revenue_rank
FROM fact_orders f
JOIN dim_dates d ON f.date_key = d.date_key
WHERE d.year = 2026
  AND d.month IN (1, 2)
  AND f.order_status IN ('delivered', 'shipped')
GROUP BY d.day_of_week
ORDER BY revenue_rank;

Kết quả ví dụ:

day_of_weektotal_revenuetotal_ordersavg_order_valuerevenue_rank
Monday22,500,000,000,00018,200,0001,236,2641
Tuesday20,800,000,000,00016,900,0001,230,7692
Sunday19,500,000,000,00015,800,0001,234,1773
Friday18,200,000,000,00014,600,0001,246,5754
Wednesday17,800,000,000,00014,200,0001,253,5215
Thursday17,200,000,000,00013,800,0001,246,3776
Saturday16,500,000,000,00013,100,0001,259,5427

Insight: Monday dẫn đầu doanh thu (nhiều người order đầu tuần sau khi browse cuối tuần). Saturday thấp nhất (người ta đi chơi, ít browse online). Dimension table dim_dates giúp phân tích theo day_of_week, is_holiday mà không cần hàm EXTRACT hay DATEPART — thông tin đã được ETL sẵn vào dimension. Đây chính là lợi thế của Star Schema: precomputed dimensions = query đơn giản hơn.

📊 Kết quả

Chỉ sốTrước (OLTP schema)Sau (Star Schema Data Warehouse)
Số bảng cần JOIN cho 1 report8–12 bảng (normalized 3NF)2–5 bảng (1 fact + dimensions)
Thời gian query doanh thu theo category15–30 phút (full table scan OLTP)30 giây (columnar storage, pre-aggregated)
DA onboarding time4–6 tuần (học 200+ bảng)1–2 tuần (hiểu fact + 5 dimensions)
Running total / YTD calculationExport → Excel → manual formulaSUM() OVER — 1 dòng SQL
MoM/YoY comparisonSelf-JOIN phức tạpLAG() — 1 dòng SQL
Impact đến production databaseQueries nặng làm chậm checkoutZero impact — warehouse tách biệt
Customer segmentationThủ công hàng quýReal-time: CTE + NTILE → auto-segment
Phát hiện Diamond customers → retentionAd-hoc, không hệ thống0.2% customers = 35% revenue → focused retention program → churn giảm 18%

Star Schema Data Warehouse giúp Amazon Retail Analytics team chuyển từ 200+ bảng phức tạp sang mô hình 6–7 bảng trực quan — DA viết query nhanh hơn 10x, query chạy nhanh hơn 30x, và không ảnh hưởng đến hệ thống giao dịch. Window Functions trên Star Schema cho phép tính running total, MoM growth, customer segmentation — tất cả trong SQL mà không cần Excel.

💡 Bài học cho Data Analyst Việt Nam

  1. Star Schema = "sơ đồ ngôi sao" — 1 fact ở giữa, dimensions bao quanh. Khi bạn vào công ty mới, bảng đầu tiên cần hỏi là "Đâu là fact table?" — đó là bảng chứa metrics (revenue, quantity, cost). Sau đó hỏi "Dimensions nào mô tả fact?" — product, customer, date, geography. Hiểu star schema = hiểu 80% data model của công ty.
  2. Fact table chứa "sự kiện đo lường được" — Dimension chứa "mô tả". Fact: order_key, revenue, quantity (numbers). Dimension: product_name, category, customer_name (text descriptions). Rule of thumb: nếu bạn SUM/AVG/COUNT nó → nó thuộc fact. Nếu bạn GROUP BY/FILTER nó → nó thuộc dimension.
  3. CTE nhiều bước = "recipe cooking". Step 1 chuẩn bị nguyên liệu (tính raw metrics), Step 2 chế biến (phân hạng, phân loại), Step 3 trình bày (aggregate, format). Mỗi CTE có tên → like mỗi bước trong công thức nấu ăn. Ai cũng đọc hiểu, ai cũng debug được.
  4. Dimension table dim_dates là "vũ khí bí mật" của DA. Thay vì viết EXTRACT(DOW FROM date), CASE WHEN month IN (1,2,3) THEN 'Q1'... — dim_dates đã có sẵn: day_of_week, quarter, fiscal_year, is_holiday. ETL team build 1 lần, DA dùng mãi mãi.

Case Study 3: VNPay — Data Warehouse cho transaction analytics

🏷️ Thông tin

Tiêu chíChi tiết
Công tyVNPAY (Công ty CP Giải pháp Thanh toán Việt Nam)
NgànhFintech — Thanh toán điện tử
Quy mô40+ triệu người dùng active, 100,000+ điểm chấp nhận thanh toán, 60+ ngân hàng đối tác, xử lý 200+ triệu giao dịch/tháng, ~$20 tỷ USD TPV (Total Payment Volume) (2025)
Thị trườngViệt Nam — cổng thanh toán lớn nhất Đông Nam Á
Chủ đề DA liên quanData Warehouse, ETL, Star Schema, Running Total, LAG/LEAD, CTE, Window Functions cho fraud detection

📋 Bối cảnh

VNPay là cổng thanh toán điện tử lớn nhất Việt Nam, kết nối 60+ ngân hàng, phục vụ 40+ triệu người dùng active. Mỗi ngày, VNPay xử lý hơn 7 triệu giao dịch — từ thanh toán QR code tại cửa hàng, chuyển tiền liên ngân hàng, thanh toán hóa đơn điện/nước, đến nạp tiền điện thoại và mua vé xem phim.

Bộ phận Data & Analytics Center tại VNPay cần phân tích dữ liệu giao dịch để trả lời liên tục: "Tổng giá trị giao dịch (TPV) hôm nay bao nhiêu? So với hôm qua?", "Loại giao dịch nào tăng trưởng nhanh nhất?", "Merchant (điểm chấp nhận) nào có doanh thu cao nhất?", "Có giao dịch bất thường (fraud suspicion) nào không?", "Doanh thu tích lũy tháng này so với target?".

Ban đầu, VNPay phân tích trực tiếp trên OLTP database (PostgreSQL) phục vụ giao dịch real-time. Nhưng khi volume tăng lên 200+ triệu giao dịch/tháng, các query phân tích bắt đầu làm chậm hệ thống thanh toán — ảnh hưởng trực tiếp đến trải nghiệm người dùng đang quét QR code thanh toán. VNPay quyết định xây dựng Data Warehouse riêng biệt với star schema, quy trình ETL chuẩn hóa, và sử dụng SQL nâng cao (Window Functions, CTE) để phân tích.

⚡ Thách thức

  1. OLTP chịu không nổi analytics queries: Database thanh toán cần respond < 200ms cho mỗi giao dịch QR code. Khi DA chạy query SUM(amount) GROUP BY merchant trên bảng 200+ triệu dòng → database chậm → user quét QR code bị timeout → mất tiền. Critical risk.
  2. Đa dạng loại giao dịch: QR payment, bank transfer, bill payment, mobile top-up, movie ticket, airline ticket — mỗi loại có schema khác nhau. Cần chuẩn hóa vào 1 fact table thống nhất.
  3. Fraud detection cần so sánh cross-transaction: Phát hiện giao dịch bất thường yêu cầu so sánh giao dịch hiện tại với giao dịch trước đó của cùng user — cần LAG() để xem transaction trước, tính time gap, so sánh amount. GROUP BY không làm được.
  4. Running total cho settlement: VNPay thanh toán cho merchants hàng ngày/tuần (settlement). Cần tính running total giao dịch tích lũy đến cuối mỗi ngày = số tiền cần settle cho merchant. SUM() OVER (ORDER BY date) là giải pháp.
  5. ETL pipeline cho freshness: Data cần ETL từ OLTP sang warehouse đủ sớm để DA phân tích vào buổi sáng (T+1). Nhưng ETL too early (2AM) → miss late-night transactions. ETL too late (8AM) → DA không có data khi bắt đầu làm việc.

🛠️ Giải pháp

Dữ liệu gì? (Star Schema cho fintech)

                         ┌────────────────────┐
                         │  dim_merchants     │
                         ├────────────────────┤
                         │ merchant_key (PK)  │
                         │ merchant_id        │
                         │ merchant_name      │
                         │ merchant_category  │
                         │ province           │
                         │ district           │
                         │ merchant_tier      │
                         └────────┬───────────┘

┌────────────────────┐   ┌───────┴────────────────────┐   ┌────────────────────┐
│  dim_users         │   │   fact_transactions        │   │  dim_dates         │
├────────────────────┤   ├────────────────────────────┤   ├────────────────────┤
│ user_key (PK)      │──>│ txn_key (PK)               │<──│ date_key (PK)      │
│ user_id            │   │ user_key (FK)              │   │ full_date          │
│ user_name          │   │ merchant_key (FK)          │   │ day_of_week        │
│ phone_number       │   │ date_key (FK)              │   │ month              │
│ bank_partner       │   │ txn_type_key (FK)          │   │ quarter            │
│ kyc_level          │   │ ────── MEASURES ──────     │   │ year               │
│ registration_date  │   │ txn_amount                 │   │ is_weekend         │
│ user_segment       │   │ fee_amount                 │   │ is_holiday         │
└────────────────────┘   │ net_amount                 │   │ lunar_month        │
                         │ txn_status                 │   └────────────────────┘
┌────────────────────┐   │ txn_timestamp              │
│  dim_txn_types     │   │ processing_time_ms         │
├────────────────────┤   │ is_flagged_fraud           │
│ txn_type_key (PK)  │──>│ source_bank                │
│ txn_type_code      │   │ dest_bank                  │
│ txn_type_name      │   └────────────────────────────┘
│ txn_category       │
│ settlement_cycle   │
└────────────────────┘

ETL Pipeline — từ OLTP sang Data Warehouse:

┌──────────┐    Extract     ┌───────────┐    Transform    ┌──────────────┐    Load    ┌──────────────┐
│  OLTP    │  ───────────>  │  Staging  │  ────────────>  │  Clean &     │  ───────>  │   Data       │
│  (Postgres│  (5AM daily)  │   Area    │  (Map to star   │  Conform     │           │  Warehouse   │
│   200M+  │               │           │   schema keys)  │  (De-dup,    │           │  (Star Schema│
│   txn/mo)│               │           │                 │   validate)  │           │   6 tables)  │
└──────────┘               └───────────┘                 └──────────────┘           └──────────────┘

                                                                                    DA queries
                                                                                    (7AM ready)
  • Extract (5:00 AM): Pull incremental data từ OLTP — chỉ lấy giao dịch mới từ lần ETL trước
  • Transform (5:30 AM): Map merchant_id → merchant_key (surrogate key), generate date_key, standardize txn_type, de-duplicate, validate amounts
  • Load (6:30 AM): Insert vào fact_transactions và update dimension tables (SCD Type 2 cho dim_merchants — lưu lịch sử thay đổi)
  • Ready (7:00 AM): DA bắt đầu query — data T-1 (yesterday) hoàn chỉnh

Phân tích bằng SQL query nào?

Query 1: Daily TPV + Running total tích lũy — SUM() OVER + CTE

sql
-- Tổng giá trị giao dịch (TPV) theo ngày + running total tích lũy tháng
-- Mục tiêu: theo dõi tiến độ TPV so với target tháng

WITH daily_tpv AS (
    SELECT
        d.full_date,
        d.day_of_week,
        d.is_weekend,
        SUM(f.txn_amount) AS daily_tpv,
        COUNT(*) AS daily_txn_count,
        COUNT(DISTINCT f.user_key) AS daily_active_users
    FROM fact_transactions f
    JOIN dim_dates d ON f.date_key = d.date_key
    WHERE d.year = 2026
      AND d.month = 2
      AND f.txn_status = 'success'
    GROUP BY d.full_date, d.day_of_week, d.is_weekend
)
SELECT
    full_date,
    day_of_week,
    is_weekend,
    daily_tpv,
    daily_txn_count,
    daily_active_users,
    SUM(daily_tpv) OVER (
        ORDER BY full_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS mtd_cumulative_tpv,
    SUM(daily_txn_count) OVER (
        ORDER BY full_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS mtd_cumulative_txn,
    LAG(daily_tpv, 1) OVER (ORDER BY full_date) AS prev_day_tpv,
    ROUND(
        (daily_tpv - LAG(daily_tpv, 1) OVER (ORDER BY full_date)) * 100.0 /
        NULLIF(LAG(daily_tpv, 1) OVER (ORDER BY full_date), 0), 1
    ) AS dod_growth_pct
FROM daily_tpv
ORDER BY full_date;

Kết quả ví dụ (trích):

full_dateday_of_weekdaily_tpvdaily_txn_countdaily_active_usersmtd_cumulative_tpvprev_day_tpvdod_growth_pct
2026-02-14Saturday580,000,000,0005,800,0002,100,0008,120,000,000,000720,000,000,000-19.4
2026-02-15Sunday520,000,000,0005,200,0001,900,0008,640,000,000,000580,000,000,000-10.3
2026-02-16Monday780,000,000,0007,500,0002,800,0009,420,000,000,000520,000,000,00050.0
2026-02-17Tuesday750,000,000,0007,200,0002,700,00010,170,000,000,000780,000,000,000-3.8

Insight: Cuối tuần (Sat-Sun) TPV giảm 10–19% so với ngày thường — fewer business transactions. Monday spike +50% vs Sunday (back-to-work effect). MTD cumulative TPV đạt 10.17 nghìn tỷ VNĐ sau 17/28 ngày (tháng 2) = 60.7% → target tháng ~16.7 nghìn tỷ → on track. Running total giúp ban lãnh đạo theo dõi tiến độ realtime mà không cần đợi cuối tháng.

Query 2: Phân tích TPV theo loại giao dịch + tỷ trọng — SUM() OVER + Percent of Total

sql
-- Phân tích TPV theo loại giao dịch + tỷ trọng mỗi loại trong tổng
-- Window Function: SUM() OVER () = tổng không partition = tổng toàn bộ

SELECT
    tt.txn_type_name,
    tt.txn_category,
    SUM(f.txn_amount) AS total_tpv,
    COUNT(*) AS total_txns,
    COUNT(DISTINCT f.user_key) AS unique_users,
    ROUND(
        SUM(f.txn_amount) * 100.0 /
        SUM(SUM(f.txn_amount)) OVER (), 2
    ) AS tpv_share_pct,
    ROUND(
        COUNT(*) * 100.0 /
        SUM(COUNT(*)) OVER (), 2
    ) AS txn_share_pct,
    RANK() OVER (ORDER BY SUM(f.txn_amount) DESC) AS tpv_rank
FROM fact_transactions f
JOIN dim_txn_types tt ON f.txn_type_key = tt.txn_type_key
JOIN dim_dates d ON f.date_key = d.date_key
WHERE d.year = 2026
  AND d.month = 2
  AND f.txn_status = 'success'
GROUP BY tt.txn_type_name, tt.txn_category
ORDER BY tpv_rank;

Kết quả ví dụ:

txn_type_nametxn_categorytotal_tpvtotal_txnsunique_userstpv_share_pcttxn_share_pcttpv_rank
Bank TransferTransfer6,800,000,000,00042,000,0008,500,00040.7219.631
QR PaymentPayment4,200,000,000,00085,000,00015,200,00025.1539.722
Bill PaymentPayment2,500,000,000,00035,000,00012,000,00014.9716.363
E-commercePayment1,800,000,000,00028,000,0009,800,00010.7813.084
Mobile Top-upTop-up900,000,000,00018,000,0007,500,0005.398.415
OthersMisc500,000,000,0006,000,0003,000,0002.992.806

Insight: Bank Transfer chiếm 40.72% TPV nhưng chỉ 19.63% số giao dịch — vì giá trị/giao dịch cao (chuyển khoản lớn). QR Payment ngược lại: 25.15% TPV nhưng 39.72% số giao dịch — nhiều giao dịch nhỏ (mua cà phê, ăn trưa). SUM() OVER () tính tổng toàn bộ → chia ra % cho mỗi loại. VNPay dùng insight này để ưu tiên phát triển QR Payment (high frequency → high user engagement) và tối ưu fee structure cho Bank Transfer (high value → high revenue per txn).

Query 3: Fraud detection — LAG() so sánh cross-transaction

sql
-- Phát hiện giao dịch bất thường: so sánh giao dịch hiện tại với giao dịch trước
-- của CÙNG user — tìm pattern: nhiều giao dịch liên tiếp trong thời gian ngắn,
-- hoặc amount đột ngột tăng gấp 10x

WITH user_txn_sequence AS (
    SELECT
        f.txn_key,
        f.user_key,
        u.user_name,
        u.phone_number,
        f.txn_amount,
        f.txn_timestamp,
        f.merchant_key,
        LAG(f.txn_amount, 1) OVER (
            PARTITION BY f.user_key
            ORDER BY f.txn_timestamp
        ) AS prev_txn_amount,
        LAG(f.txn_timestamp, 1) OVER (
            PARTITION BY f.user_key
            ORDER BY f.txn_timestamp
        ) AS prev_txn_timestamp,
        LAG(f.merchant_key, 1) OVER (
            PARTITION BY f.user_key
            ORDER BY f.txn_timestamp
        ) AS prev_merchant_key,
        ROW_NUMBER() OVER (
            PARTITION BY f.user_key
            ORDER BY f.txn_timestamp
        ) AS txn_sequence_num
    FROM fact_transactions f
    JOIN dim_users u ON f.user_key = u.user_key
    JOIN dim_dates d ON f.date_key = d.date_key
    WHERE d.full_date = '2026-02-17'
      AND f.txn_status = 'success'
)
SELECT
    txn_key,
    user_key,
    user_name,
    txn_amount,
    txn_timestamp,
    prev_txn_amount,
    prev_txn_timestamp,
    EXTRACT(EPOCH FROM (txn_timestamp - prev_txn_timestamp)) AS seconds_since_prev,
    ROUND(txn_amount / NULLIF(prev_txn_amount, 0), 1) AS amount_ratio,
    CASE
        WHEN EXTRACT(EPOCH FROM (txn_timestamp - prev_txn_timestamp)) < 60
             AND txn_amount > 5000000
        THEN '🔴 RAPID HIGH-VALUE'
        WHEN txn_amount / NULLIF(prev_txn_amount, 0) > 10
        THEN '🟡 AMOUNT SPIKE (>10x)'
        WHEN EXTRACT(EPOCH FROM (txn_timestamp - prev_txn_timestamp)) < 30
        THEN '🟠 RAPID SEQUENCE (<30s)'
        ELSE '🟢 NORMAL'
    END AS fraud_flag
FROM user_txn_sequence
WHERE prev_txn_timestamp IS NOT NULL
  AND (
    EXTRACT(EPOCH FROM (txn_timestamp - prev_txn_timestamp)) < 60
    OR txn_amount / NULLIF(prev_txn_amount, 0) > 10
  )
ORDER BY txn_timestamp;

Insight: LAG() cho phép so sánh mỗi giao dịch với giao dịch ngay trước đó của cùng user — tính time gap (seconds_since_prev) và amount ratio. Pattern bất thường: user thực hiện 5 giao dịch trong 2 phút, mỗi giao dịch 4.9 triệu VNĐ (ngay dưới ngưỡng 5 triệu cần xác thực OTP bổ sung) → flag "RAPID SEQUENCE" → gửi fraud review team. Trung bình VNPay phát hiện ~500 giao dịch bất thường/ngày bằng pattern này, trong đó ~15% là fraud thật → chặn ~2 tỷ VNĐ/tháng.

Query 4: Merchant settlement — Running total cho đối soát

sql
-- Tính running total giao dịch tích lũy cho merchant settlement
-- Merchant cần biết: "Ngày hôm nay VNPay nợ tôi bao nhiêu (tích lũy)?"

WITH merchant_daily AS (
    SELECT
        m.merchant_key,
        m.merchant_name,
        m.merchant_category,
        d.full_date,
        SUM(f.txn_amount) AS daily_gross,
        SUM(f.fee_amount) AS daily_fee,
        SUM(f.net_amount) AS daily_net
    FROM fact_transactions f
    JOIN dim_merchants m ON f.merchant_key = m.merchant_key
    JOIN dim_dates d ON f.date_key = d.date_key
    WHERE m.merchant_key = 12345      -- VinMart (ví dụ)
      AND d.year = 2026
      AND d.month = 2
      AND f.txn_status = 'success'
    GROUP BY m.merchant_key, m.merchant_name, m.merchant_category, d.full_date
)
SELECT
    merchant_name,
    full_date,
    daily_gross,
    daily_fee,
    daily_net,
    SUM(daily_net) OVER (
        ORDER BY full_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_settlement,
    LAG(daily_net, 7) OVER (ORDER BY full_date) AS same_day_last_week,
    ROUND(
        (daily_net - LAG(daily_net, 7) OVER (ORDER BY full_date)) * 100.0 /
        NULLIF(LAG(daily_net, 7) OVER (ORDER BY full_date), 0), 1
    ) AS wow_growth_pct
FROM merchant_daily
ORDER BY full_date;

Kết quả ví dụ (VinMart):

merchant_namefull_datedaily_grossdaily_feedaily_netcumulative_settlementsame_day_last_weekwow_growth_pct
VinMart2026-02-152,800,000,00028,000,0002,772,000,00038,256,000,0002,550,000,0008.7
VinMart2026-02-163,500,000,00035,000,0003,465,000,00041,721,000,0003,200,000,0008.3
VinMart2026-02-173,200,000,00032,000,0003,168,000,00044,889,000,0002,980,000,0006.3

Insight: Cumulative settlement cho biết VNPay nợ VinMart 44.89 tỷ VNĐ tính đến 17/02. LAG(daily_net, 7) so sánh với cùng thứ tuần trước (WoW) — VinMart tăng trưởng 6–9% WoW. Running total giúp finance team VNPay biết chính xác số tiền cần chuyển cho merchant khi settle — không cần export Excel tính tay.

Query 5: User activity trend — LEAD() dự báo churn

sql
-- Phân tích user activity: khoảng cách giữa các giao dịch
-- Nếu khoảng cách ngày càng dài → user đang dần inactive → churn risk

WITH user_activity AS (
    SELECT
        f.user_key,
        u.user_name,
        u.user_segment,
        d.full_date AS txn_date,
        ROW_NUMBER() OVER (
            PARTITION BY f.user_key
            ORDER BY d.full_date
        ) AS txn_order,
        LEAD(d.full_date, 1) OVER (
            PARTITION BY f.user_key
            ORDER BY d.full_date
        ) AS next_txn_date,
        LAG(d.full_date, 1) OVER (
            PARTITION BY f.user_key
            ORDER BY d.full_date
        ) AS prev_txn_date
    FROM fact_transactions f
    JOIN dim_users u ON f.user_key = u.user_key
    JOIN dim_dates d ON f.date_key = d.date_key
    WHERE d.year = 2026
      AND d.month IN (1, 2)
      AND f.txn_status = 'success'
    GROUP BY f.user_key, u.user_name, u.user_segment, d.full_date
)
SELECT
    user_segment,
    COUNT(DISTINCT user_key) AS total_users,
    ROUND(AVG(next_txn_date - txn_date), 1) AS avg_days_between_txn,
    COUNT(CASE WHEN next_txn_date - txn_date > 14 THEN 1 END) AS gaps_over_14_days,
    COUNT(CASE WHEN next_txn_date IS NULL THEN 1 END) AS no_next_txn,
    ROUND(
        COUNT(CASE WHEN next_txn_date IS NULL THEN 1 END) * 100.0 /
        NULLIF(COUNT(DISTINCT user_key), 0), 1
    ) AS potential_churn_pct
FROM user_activity
GROUP BY user_segment
ORDER BY potential_churn_pct DESC;

Insight: LEAD() cho phép nhìn "tương lai" — biết giao dịch tiếp theo xảy ra khi nào. Nếu LEAD() trả NULL → user không có giao dịch nào tiếp theo (trong dữ liệu hiện có) → potential churn. Kết hợp LAG() (nhìn quá khứ) và LEAD() (nhìn tương lai), VNPay xây dựng churn prediction model: user có avg_days_between_txn tăng dần + last_txn > 14 ngày → gửi push notification hoặc cashback voucher. Campaign win-back dựa trên insight này recovery 12% users at-risk.

📊 Kết quả

Chỉ sốTrước (Query OLTP trực tiếp)Sau (Data Warehouse + Window Functions)
Ảnh hưởng productionQuery analytics làm chậm QR payment 300–500msZero impact — warehouse tách biệt
Thời gian daily TPV report45 phút (manual query + Excel)5 phút (automated running total query)
Fraud detectionCuối ngày (batch review)Near real-time: LAG() pattern → flag trong 15 phút
Merchant settlement accuracy±2% sai lệch (tính tay)<0.01% (running total SQL tự động)
Số giao dịch fraud bị chặn/tháng~100 (manual review)~500 flagged → ~75 confirmed → chặn ~2 tỷ VNĐ/tháng
Churn predictionKhông có (reactive)Proactive: LEAD() + LAG() → 12% user win-back
DA onboarding time6 tuần (học OLTP schema phức tạp)2 tuần (star schema 6 bảng)
ETL freshnessAd-hoc exportT+1 automated (5AM extract → 7AM ready)

Data Warehouse với Star Schema giúp VNPay Data & Analytics Center tách biệt analytics khỏi production, chuẩn hóa 200+ triệu giao dịch/tháng vào mô hình 6 bảng rõ ràng, và sử dụng Window Functions cho phân tích chuyên sâu: running total cho settlement, LAG/LEAD cho fraud detection và churn prediction, SUM() OVER cho tỷ trọng giao dịch.

💡 Bài học cho Data Analyst Việt Nam

  1. Không bao giờ chạy analytics query trên production database. Bài học #1 của VNPay: 1 query GROUP BY trên 200 triệu dòng làm chậm QR payment → user mất tiền. Data Warehouse tồn tại để bảo vệ production — và cho DA thoải mái query mà không sợ "làm sập hệ thống".
  2. ETL là "đường ống dẫn dầu" — data warehouse không tự có dữ liệu. Luôn hỏi: "Dữ liệu trong warehouse được cập nhật khi nào? T+1 hay T+0? Incremental hay full load?" — điều này ảnh hưởng trực tiếp đến kết quả phân tích. Nếu data T+1, đừng trả lời câu hỏi "hôm nay bao nhiêu?" — bạn chỉ có data hôm qua.
  3. LAG() + LEAD() = "cặp đôi quyền lực" cho fintech analytics. LAG(amount) so sánh giao dịch hiện tại với trước — phát hiện spike (fraud). LEAD(date) biết giao dịch tiếp theo khi nào — dự báo churn. Kết hợp cả hai = hiểu hành vi user theo timeline, điều mà GROUP BY không thể làm.
  4. Star Schema cho fintech: Fact = giao dịch, Dimensions = user + merchant + date + txn_type. Mọi startup fintech Việt Nam (MoMo, ZaloPay, VNPay) đều cần mô hình này. Nếu bạn vào công ty fintech, hỏi ngay: "Fact table nào chứa transactions? Dimension nào mô tả merchants?" → bạn nắm 80% data model trong ngày đầu tiên.

📝 So sánh & tổng hợp

Tiêu chíSpotifyAmazonVNPay
NgànhMusic StreamingE-commerceFintech — Thanh toán điện tử
Quy mô dữ liệu~12.6 tỷ streams/tuần~hàng tỷ orders/năm (petabyte)~200 triệu giao dịch/tháng
Data ArchitectureAggregate tables + Window FunctionsStar Schema Data Warehouse (Redshift)Star Schema Data Warehouse + ETL pipeline
Fact table chínhweekly_streamsfact_ordersfact_transactions
Dimension tablestracks, artists, countriesdim_products, dim_customers, dim_dates, dim_sellers, dim_marketplacedim_users, dim_merchants, dim_dates, dim_txn_types
Window Function nổi bậtROW_NUMBER, DENSE_RANK, LAG, SUM() OVERRANK, SUM() OVER (running total), NTILE, LAGSUM() OVER (running total), LAG, LEAD, ROW_NUMBER
CTE dùng choWeekly ranking + trend comparisonCustomer CLV multi-step + category analysisDaily TPV + fraud detection sequence
Business question chínhTop songs per region per week, viral detectionRevenue by category (YTD), customer segmentation, product rankingTPV tracking, fraud detection, merchant settlement, churn prediction
Kết quả nổi bật180+ country charts trong 1 query, +200–400 viral detections/tuần10x faster query, customer tiers: 0.2% = 35% revenue, churn -18%Chặn ~2 tỷ VNĐ fraud/tháng, settlement accuracy < 0.01%, 12% churn win-back

Điểm chung của cả 3 case study:

  • Đều sử dụng Window Functions để giải quyết bài toán mà GROUP BY không làm được: ranking per group (PARTITION BY), so sánh dòng trước/sau (LAG/LEAD), running total (SUM OVER), và percent of total.
  • Đều chứng minh Star Schema là kiến trúc chuẩn cho analytics — từ music streaming (aggregate tables giống mini star schema) đến e-commerce (classic star schema) đến fintech (star schema + ETL). Fact table chứa metrics, dimension tables chứa mô tả — mọi query phân tích xoay quanh JOIN fact ↔ dimensions.
  • Đều dùng CTE để viết query "sạch" — chia query dài thành từng bước có tên, dễ đọc, dễ debug. Không ai viết subquery lồng 5 tầng khi làm việc nhóm — CTE là chuẩn team collaboration.
  • Đều cho thấy Window Functions không thay thế GROUP BY — chúng bổ sung. GROUP BY tổng hợp (tổng revenue per category), Window Functions bổ sung thêm thông tin so sánh trên mỗi dòng (rank, running total, prev value). Kết hợp cả hai = phân tích đầy đủ.
  • Đều nhấn mạnh: Data Warehouse tách biệt khỏi production database là bắt buộc ở quy mô lớn. Analytics queries trên OLTP = rủi ro kinh doanh (chậm checkout, chậm payment). Data Warehouse = an toàn + tối ưu cho DA.

🤔 Câu hỏi thảo luận

  1. ROW_NUMBER vs RANK vs DENSE_RANK: Trong case Spotify, nếu 2 bài hát cùng 15.2 triệu streams ở Việt Nam, ROW_NUMBER, RANK, và DENSE_RANK sẽ cho kết quả rank khác nhau như thế nào? Hãy viết ví dụ cụ thể với 5 bài hát (trong đó 2 bài tie) — liệt kê kết quả rank cho cả 3 hàm.

  2. Star Schema design: Nếu bạn thiết kế star schema cho chuỗi cà phê Highland Coffee (có ~600 cửa hàng toàn quốc), đâu là fact table? Dimensions nào cần có? Hãy vẽ sơ đồ star schema với tên bảng và cột chính, xác định rõ measures (trong fact) và attributes (trong dimensions).

  3. LAG() cho fraud detection: VNPay dùng LAG() để so sánh giao dịch hiện tại với giao dịch trước. Nếu bạn muốn phát hiện pattern "3 giao dịch liên tiếp trong 5 phút", bạn cần dùng LAG() với tham số gì? (Gợi ý: LAG(column, 2) → quay lại 2 dòng). Hãy viết pseudo-SQL.

  4. Running total ứng dụng: Một startup Việt Nam có target doanh thu tháng 2/2026 là 5 tỷ VNĐ. Bạn có bảng daily_sales(sale_date, revenue). Hãy viết SQL tính running total revenue tích lũy theo ngày + cột pct_of_target (% so với target 5 tỷ) + cột days_remaining bằng Window Functions.

  5. ETL freshness vs accuracy: VNPay ETL data T+1 (5AM). Nếu sếp hỏi lúc 3PM hôm nay: "TPV hôm nay bao nhiêu rồi?" — bạn trả lời thế nào? Data warehouse chỉ có data đến hôm qua. Bạn có nên query OLTP trực tiếp không? Giải thích trade-off giữa freshness và safety.


📚 Nguồn tham khảo

  1. Spotify Engineering Blog. "How Spotify Generates Its Weekly Charts." — Kiến trúc data pipeline phục vụ global weekly charts.
  2. Amazon Web Services (AWS). "Amazon Redshift — Data Warehouse Service." — Kiến trúc Redshift star schema cho analytics petabyte-scale.
  3. VNPay. "Báo cáo hoạt động thanh toán điện tử Việt Nam." — Thông tin về hệ sinh thái thanh toán VNPay và quy mô giao dịch.
  4. Kimball, R. & Ross, M. (2013). The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling. 3rd Edition. Wiley. — "Kinh thánh" về star schema, fact vs dimension, slowly changing dimensions.
  5. Molinaro, A. & Graaf, R. (2020). SQL Cookbook. O'Reilly Media. — Patterns SQL nâng cao: window functions, CTE, analytical queries.
  6. DataLemur (2025). "Top SQL Interview Questions — Window Functions." — Bộ câu hỏi phỏng vấn DA phổ biến nhất: ROW_NUMBER, RANK, LAG, running total.
  7. Inmon, W.H. (2005). Building the Data Warehouse. 4th Edition. Wiley. — Kiến trúc data warehouse chuẩn, ETL process, data quality.
  8. Mode Analytics SQL Tutorial. mode.com/sql-tutorial/sql-window-functions — Tutorial tương tác Window Functions với datasets thực tế.
  9. CafeBiz, VnExpress, TechInAsia — Các bài viết về VNPay, MoMo, ZaloPay và hệ sinh thái fintech Việt Nam.
  10. PostgreSQL Documentation — Window Functions. postgresql.org/docs/current/tutorial-window.html — Tài liệu chính thức PostgreSQL về Window Functions.

🔗 Xem thêm Buổi 6

📘 Nội dung chính📝 Blog🏆 Tiêu chuẩn🛠 Workshop🎮 Mini Game