Học SQL Qua Challenges

SQL Challenge – As-of User State Classification in a Freemium Product

Bối Cảnh

Phòng Growth của một sản phẩm freemium cần biết tại một thời điểm chốt sổ (snapshot date), mỗi user đang ở trạng thái vòng đời nào. Bài toán định danh 7 trạng thái dựa trên 2 luồng: usage (sử dụng)monetization (trả phí).

Trạng thái này là Metric thô nhất — mọi KPI/KR/Objective phía trên đều dựng từ con số này.

  • Snapshot date (ngày chốt): Mốc thời gian để “chụp” trạng thái user. Mọi tính toán thời gian đều so với mốc này.
  • Activation (kích hoạt): User thực hiện hành động lõi lần đầu sau đăng ký.
  • Grace period (ân hạn): Khoảng thời gian sau khi hết hạn thanh toán mà vẫn coi user là đang trả phí.

Dữ Liệu Đầu Vào

Trạng thái user không lưu sẵn — phải suy ra từ hành vi thô trong 3 bảng:

Bảng users
CộtÝ nghĩa
user_idKhóa chính.
signup_dateNgày đăng ký.
Bảng usage_events
CộtÝ nghĩa
event_idKhóa chính.
user_idKhóa ngoại.
event_dateNgày dùng tính năng lõi.
Bảng payments
CộtÝ nghĩa
payment_idKhóa chính.
user_idKhóa ngoại.
paid_atNgày giao dịch.
period_startNgày bắt đầu chu kỳ thanh toán.
period_endNgày kết thúc chu kỳ thanh toán.
amount_centsSố tiền (> 0).

Câu Hỏi Nghiệp Vụ

Tại snapshot date (mặc định 2024-04-30), phân loại mỗi user vào đúng một trong 7 trạng thái.

4 ngưỡng thời gian (threshold)
NgưỡngGiá trị
Activation window (đăng ký → hành động lõi đầu tiên)7 ngày
Active → Inactive (không hoạt động)> 14 ngày
Inactive → Churned (không hoạt động)> 30 ngày
Paying → Lapsed (hết hạn + ân hạn)grace 14 ngày sau period_end
Định nghĩa 7 trạng thái
Tên đầy đủĐiều kiện
NSNew Sign-upChưa có hành động lõi và đăng ký ≤ 7 ngày.
AFActive FreeKhông trả phí, lần dùng gần nhất ≤ 14 ngày.
IFInactive FreeKhông trả phí, lần dùng gần nhất 15–30 ngày.
CFChurned Free(Chưa hành động lõi & đăng ký > 7 ngày) hoặc (Không trả phí, lần dùng > 30 ngày, chưa từng trả phí).
APActive PayingĐang trả phí, lần dùng gần nhất ≤ 14 ngày.
IPInactive PayingĐang trả phí, nhưng lần dùng > 14 ngày hoặc chưa từng có hành động lõi.
CPChurned PayingKhông trả phí, lần dùng > 30 ngày, từng trả phí.

Output Mong Đợi

Cột outputÝ nghĩa
user_idĐịnh danh user.
signup_dateNgày đăng ký.
days_since_signupSố ngày từ đăng ký tới snapshot.
last_usage_dateNgày dùng gần nhất; NULL nếu chưa từng.
days_since_last_usageSố ngày từ lần dùng gần nhất tới snapshot; NULL nếu chưa từng.
ever_paid1 nếu từng trả phí, ngược lại 0.
is_paying1 nếu đang trong vùng phủ thanh toán (+ grace), ngược lại 0.
user_stateMột trong: NS, AF, IF, CF, AP, IP, CP.

Ví Dụ Minh Họa

Snapshot date = 2024-04-30

user_iddays_since_signuplast_usage_datedays_since_last_usageever_paidis_payinguser_state
12NULLNULL00NS
220NULLNULL00CF
51202024-03-204100CF
83342024-03-154610CP
71202024-04-052511IP
1110NULLNULL11IP
101512024-04-28211AP
121512024-04-28210AF
  • User 5 (CF) vs User 8 (CP): Cùng ngừng dùng > 30 ngày & không trả phí, chỉ khác ever_paid. Tách được hao hụt tự nhiên (CF) và mất doanh thu (CP).
  • User 7 (IP) vs User 11 (IP): User 7 từng dùng rồi ngừng; User 11 chưa từng dùng. Cùng trạng thái IP nhưng last_usage_date khác biệt, cần can thiệp khác nhau.
  • User 10 (AP) vs User 12 (AF): User 10 hết hạn 2024-04-16 (cách snapshot 14 ngày) -> còn paying -> AP. User 12 hết hạn 2024-04-15 (cách snapshot 15 ngày) -> hết paying -> AF. Chỉ lệch 1 ngày đã lật trạng thái.

Tư Duy Xử Lý

Decision Tree

Thứ tự đánh giá CASE WHEN là bất biến. Nhánh hẹp hơn phải đặt trên nhánh rộng hơn.

                       ┌─────────────────────────────┐
                       │  Bắt đầu: 1 dòng / user     │
                       └──────────────┬──────────────┘
                                      │
                                is_paying = 1?  
                          ┌───────────┴───────────┐
                       CÓ │                       │ KHÔNG
                          ▼                       ▼
              dlu IS NOT NULL              last_usage IS NULL ?
              AND dlu <= 14 ?              (chưa từng hành động lõi)
              ┌─────────┴────────┐         ┌──────────┴──────────┐
           CÓ │                  │ KHÔNG CÓ│                     │ KHÔNG
              ▼                  ▼         ▼                     ▼
         ┌────────┐         ┌────────┐  dss <= 7 ?         bậc thang dlu
         │   AP   │         │   IP   │  ┌─────┴─────┐      (làn free có usage)
         └────────┘         └────────┘  ▼           ▼      ┌──────┬──────┬───────┐
                                        ┌────┐   ┌────┐   dlu    dlu    dlu>30
                                        │ NS │   │ CF │   ≤14    15-30    │
                                        └────┘   └────┘    ▼       ▼      ▼
                                                                ┌────┐ ┌────┐  ever_paid?
                                                                │ AF │ │ IF │  ┌────┴────┐
                                                                └────┘ └────┘=1│         │=0
                                                                               ▼         ▼
                                                                              ┌────┐   ┌────┐
                                                                              │ CP │   │ CF │
                                                                              └────┘   └────┘

(Ký hiệu: dlu = days_since_last_usage, dss = days_since_signup)

Bảng ánh xạ điều kiện → nhánh CASE
#Điều kiện (đọc từ trên xuống, dừng ở dòng đầu thỏa)Trạng thái
1is_paying=1 AND dlu IS NOT NULL AND dlu<=14AP
2is_paying=1 (còn lại)IP
3dlu IS NULL AND dss<=7NS
4dlu IS NULL (còn lại)CF
5dlu<=14AF
6dlu<=30IF
7ever_paid=1CP
8ELSECF
Kiến trúc 3 tầng (Tái sử dụng & Tối ưu)

Đóng gói logic phân loại vào hàm inline TVF (iTVF) dbo.fn_user_state(@snapshot) thay vì viết CASE trực tiếp:

  • Single source of truth: Sửa ngưỡng/trạng thái chỉ sửa ở 1 nơi.
  • DRY không mất hiệu năng: iTVF được optimizer nội tuyến (inlined) vào query plan, chạy nhanh như viết CASE trực tiếp.
  • Cú pháp gọi hàm cho nhiều snapshot cùng lúc dễ dàng phục vụ phân tích chuỗi thời gian.
Insight (Định vị Metric)
  • Challenge này dừng ở tầng Metric (con số thô). Từ đây có thể tổng hợp lên KPI (Tỷ lệ paying, tỷ lệ active) và KR (Mục tiêu chuyển đổi).
  • Action gắn với chỉ số: Đếm chính xác từng trạng thái là nền tảng để đo xác suất chuyển đổi (transition probability) ở các challenge sau. Biết được CP (mất doanh thu) hay IP (trả tiền quán tính) để có chiến lược can thiệp đúng.

Tầng Trung Gian (View)

Mục đích: Gom & chuẩn hóa từ OLTP về grain 1 dòng/user.

View này làm gì?
  • Gom 3 bảng OLTP (users, usage_events, payments) về đúng 1 dòng mỗi user, giữ lại các mốc thời gian thô (raw features) cần thiết để phân loại trạng thái.
  • Hiểu nhanh: giống như “phiếu tổng kết cá nhân” — mỗi user một dòng, ghi ngày đăng ký, lần dùng gần nhất, chu kỳ trả gần nhất, đã từng trả tiền chưa.
IF OBJECT_ID('dbo.v_user_state_features', 'V') IS NOT NULL
    DROP VIEW dbo.v_user_state_features;
GO

CREATE VIEW dbo.v_user_state_features
AS
    SELECT
        u.user_id,
        u.signup_date,
        /*------------------------------------------------------------------
          Cột: last_usage_date
          MAX(event_date) từ bảng usage_events, gom theo user_id.
          Ý nghĩa: Lần CUỐI CÙNG user dùng tính năng lõi.
        ------------------------------------------------------------------*/
        ue.last_usage_date,
        /*------------------------------------------------------------------
          Cột: last_period_end
          MAX(period_end) từ bảng payments, gom theo user_id.
          Ý nghĩa: Ngày KẾT THÚC chu kỳ thanh toán gần nhất.
        ------------------------------------------------------------------*/
        pm.last_period_end,
        /*------------------------------------------------------------------
          Cột: ever_paid
          Kiểm tra xem user có dòng nào trong bảng payments hay không.
          Ý nghĩa: Cờ nhị phân — user TỪNG trả phí ít nhất 1 lần hay chưa.
        ------------------------------------------------------------------*/
        CASE WHEN pm.user_id IS NOT NULL
             THEN 1 ELSE 0 END AS ever_paid
    FROM dbo.users AS u
    /*--------------------------------------------------------------------
      Pre-aggregate #1: usage_events → 1 dòng/user
      ──────────────────────────────────────────────
      Bảng gốc usage_events có grain: 1 dòng / 1 lần dùng tính năng lõi.
      → 1 user có THỂ có nhiều dòng (dùng nhiều lần).
      
      Subquery gom: GROUP BY user_id, lấy MAX(event_date).
      Kết quả: mỗi user tối đa 1 dòng, cột last_usage_date = lần dùng gần nhất.
    --------------------------------------------------------------------*/
    LEFT JOIN (
        SELECT user_id, MAX(event_date) AS last_usage_date
        FROM dbo.usage_events
        GROUP BY user_id
    ) AS ue ON ue.user_id = u.user_id
    /*--------------------------------------------------------------------
      Pre-aggregate #2: payments → 1 dòng/user
      ──────────────────────────────────────────
      Bảng gốc payments có grain: 1 dòng / 1 chu kỳ thanh toán.
      → 1 user có THỂ có nhiều dòng (trả nhiều tháng/năm).
      
      Subquery gom: GROUP BY user_id, lấy MAX(period_end).
      Kết quả: mỗi user tối đa 1 dòng, cột last_period_end = 
               ngày kết thúc chu kỳ GẦN NHẤT.
    --------------------------------------------------------------------*/
    LEFT JOIN (
        SELECT user_id, MAX(period_end) AS last_period_end
        FROM dbo.payments
        GROUP BY user_id
    ) AS pm ON pm.user_id = u.user_id;
GO
  
    SELECT COUNT(*) FROM dbo.v_user_state_features;
    SELECT * FROM dbo.v_user_state_features ORDER BY user_id;
GO

Tầng Ngữ Nghĩa (Semantic Layer)

  • Định nghĩa logic phân loại 7 trạng thái dưới dạng Inline.
  • table-valued function (iTVF): Mọi nơi cần phân loại đều gọi hàm này, không lặp lại code.
Vì sao phải viết logic phân loại trong hàm (không viết Case thẳng trong solution)?

Single source of truth: Sửa luật phân loại chỉ sửa 1 nơi.

  • Ví dụ nếu viết Case ở 3 nơi: report tuần, dashboard, pipeline đêm → cần thêm trạng thái “Dormant” → quên sửa 1 trong 3 nơi → 3 nguồn cho ra 3 kết quả khác nhau → khác dữ liệu.
  • Đóng gói vào hàm → mọi nơi gọi cùng 1 hàm → sửa 1 lần, cập nhật khắp nơi.
IF OBJECT_ID('dbo.fn_user_state', 'IF')    IS NOT NULL DROP FUNCTION dbo.fn_user_state;
IF OBJECT_ID('dbo.fn_user_state_ex', 'IF') IS NOT NULL DROP FUNCTION dbo.fn_user_state_ex;
GO

/*----------------------------------------------------------------------------
    HÀM 1: fn_user_state_ex — 5 THAM SỐ                     
  
    @snapshot : Ngày chốt sổ. Mọi DATEDIFF đều so với mốc này.
    @aw       : Activation Window — số ngày tối đa từ đăng ký đến hành động lõi
                đầu tiên. Quá @aw ngày mà chưa dùng → coi là CF (never activates).
    @inact    : Ngưỡng Active → Inactive — bao nhiêu ngày không dùng thì
                chuyển từ Active sang Inactive.
    @churn    : Ngưỡng Inactive → Churned — bao nhiêu ngày không dùng thì
                chuyển từ Inactive sang Churned.
    @grace    : Grace Period — số ngày ân hạn sau period_end mà vẫn coi user
                đang trả phí. Biên đóng: <= @grace vẫn paying.    
  
    Bước 1 (CTE feats): Quy đổi feature thô thành số ngày + cờ nhị phân.
    Bước 2 (CASE WHEN): Áp luật theo thứ tự ưu tiên — nhánh HẸP trước, RỘNG sau.
----------------------------------------------------------------------------*/
CREATE FUNCTION dbo.fn_user_state_ex
(
    @snapshot DATE,    -- Ngày chốt sổ (snapshot date)
    @aw       INT,     -- Activation Window (đăng ký → hành động lõi đầu)
    @inact    INT,     -- Ngưỡng Active → Inactive (usage)
    @churn    INT,     -- Ngưỡng Inactive → Churned (usage)
    @grace    INT      -- Số ngày ân hạn sau period_end
)
RETURNS TABLE         -- iTVF: chỉ RETURNS TABLE, không khai báo cấu trúc cột
AS
RETURN                -- Ngay lập tức RETURN 1 câu SELECT duy nhất (bắt buộc iTVF)
(
    /*======================================================================
      BƯỚC 1: CTE "feats" — Quy đổi feature thô thành số ngày + cờ
      Đầu vào: v_user_state_features (raw features từ 03_transform.sql)
        - signup_date, last_usage_date, last_period_end, ever_paid
      Đầu ra: thêm các cột đã tính toán:
        - days_since_signup, days_since_last_usage, is_paying    
    ======================================================================*/
    WITH feats AS
    (
        SELECT
            f.user_id,
            f.signup_date,
            DATEDIFF(day, f.signup_date, @snapshot) AS days_since_signup,
            f.last_usage_date,
            CASE WHEN f.last_usage_date IS NOT NULL
                 THEN DATEDIFF(day, f.last_usage_date, @snapshot) END AS days_since_last_usage,
            f.ever_paid,
            /*------------------------------------------------------------
              is_paying = 1 nếu user CÒN trong vùng phủ thanh toán + ân hạn.
              Công thức: DATEDIFF(day, last_period_end, @snapshot) <= @grace
              Giải thích:
                - last_period_end = ngày KẾT THÚC chu kỳ gần nhất.
                - Nếu snapshot CÒN TRONG hoặc NGAY TRÊN BIÊN ân hạn 
                  → user vẫn được coi là đang trả phí.
                - Ví dụ: period_end = 2024-04-16, snapshot = 2024-04-30
                  → DATEDIFF = 14, grace = 14 → 14 <= 14 → is_paying = 1 (còn paying)
                - Ví dụ: period_end = 2024-04-15, snapshot = 2024-04-30
                  → DATEDIFF = 15, grace = 14 → 15 > 14 → is_paying = 0 (hết paying)
            ------------------------------------------------------------*/
            CASE WHEN f.last_period_end IS NOT NULL
                  AND DATEDIFF(day, f.last_period_end, @snapshot) <= @grace
                 THEN 1 ELSE 0 END AS is_paying
        FROM dbo.v_user_state_features AS f
    )
    /*======================================================================
      BƯỚC 2: Áp luật phân loại — khối CASE theo thứ tự ưu tiên
      
      "Hẹp hơn" = nhiều điều kiện AND → ít user rơi vào → đánh giá TRƯỚC.
      "Rộng hơn" = ít điều kiện → nhiều user rơi vào → đánh giá SAU.
      Xem lại Decision Tree
    ======================================================================*/
    SELECT
        user_id,
        signup_date,
        days_since_signup,
        last_usage_date,
        days_since_last_usage,
        ever_paid,
        is_paying,
        CASE
            WHEN is_paying = 1 
                 AND days_since_last_usage IS NOT NULL
                 AND days_since_last_usage <= @inact THEN 'AP'
            WHEN is_paying = 1 THEN 'IP'
            WHEN days_since_last_usage IS NULL
                 AND days_since_signup <= @aw THEN 'NS'
            WHEN days_since_last_usage IS NULL THEN 'CF'
            WHEN days_since_last_usage <= @inact THEN 'AF'
            WHEN days_since_last_usage <= @churn THEN 'IF'
            WHEN ever_paid = 1 THEN 'CP'
            ELSE 'CF'
        END AS user_state
    FROM feats
);
GO

/*----------------------------------------------------------------------------
    HÀM 2: fn_user_state                    
----------------------------------------------------------------------------*/
CREATE FUNCTION dbo.fn_user_state (@snapshot DATE)
RETURNS TABLE
AS
RETURN
(
    SELECT user_id, signup_date, days_since_signup, last_usage_date,
           days_since_last_usage, ever_paid, is_paying, user_state
    FROM dbo.fn_user_state_ex(@snapshot, 7, 14, 30, 14)
    --                        ↑          ↑    ↑    ↑
    --                        aw       inact churn grace
    --                        7         14    30    14   ← 4 ngưỡng CHUẨN của challenge
);
GO

Solution Cuối Cùng

Phân loại toàn bộ user tại snapshot chuẩn

SELECT
    user_id,
    signup_date,
    days_since_signup,
    last_usage_date,
    days_since_last_usage,
    ever_paid,
    is_paying,
    user_state
FROM dbo.fn_user_state('2024-04-30')     -- Chỉ truyền 1 tham số: ngày chốt sổ
ORDER BY user_id;                          -- Sắp xếp để dễ kiểm tra từng user
GO
WHAT-IF — Phân tích ngưỡng nhạy cảm
  • Nếu nới ngưỡng inactive từ 14 ngày lên 21 ngày thì bao nhiêu user chuyển từ IF sang AF?
  • Chỉ cần thay tham số hàm fn_user_state_ex: Truyền @inact = 21 (thay vì 14), giữ nguyên 3 ngưỡng còn lại.
    • Nếu logic phân loại viết rải rác ở nhiều nơi → không thể what-if nhanh.
    • Nhờ iTVF → đổi 1 tham số → có kết quả ngay.
SELECT user_state, COUNT(*) AS n_users
FROM dbo.fn_user_state_ex('2024-04-30', 7, 21, 30, 14)
--                                      ↑    ↑   ↑   ↑
--                                     aw  inact churn grace
--                                      7    21*  30   14   ← chỉ đổi inact từ 14 → 21
GROUP BY user_state
ORDER BY user_state;
GO
Tham số hóa Snapshot – So sánh trạng thái giữa nhiều ngày chốt
  • 1 snapshot cho biết “bức tranh tại 1 thời điểm” (as-of state).
  • 2 snapshot liên tiếp cho biết “ai chuyển từ trạng thái nào sang nào” → đây chính là ma trận chuyển trạng thái (transition matrix)
  • Cross Apply:
    • mệnh đề VALUES tạo 1 bảng dẫn xuất d với 2 dòng (2 snapshot_date).
    • Cross Apply gọi fn_user_state cho mỗi dòng của d → tương đương UNION ALL 2 câu truy vấn riêng biệt nhưng ngắn gọn hơn.
    • fn_user_state là iTVF → optimizer inline vào từng CROSS APPLY, không tạo bảng tạm, không overhead gọi hàm.
SELECT d.snapshot_date, s.user_state, COUNT(*) AS n_users
FROM (VALUES (CAST('2024-04-30' AS DATE))
             (CAST('2024-05-31' AS DATE))) AS d(snapshot_date)
CROSS APPLY dbo.fn_user_state(d.snapshot_date) AS s
GROUP BY d.snapshot_date, s.user_state
ORDER BY d.snapshot_date, s.user_state;
GO

Leave a Comment