Học Phân Tích Qua Case Studies

Unsubcribe Analysis – Pinterest (Part 02)

Inline Table-Valued Function (iTVF) Là Gì?

  • iTVF là một hàm trong SQL Server trả về bảng (table), giống View nhưng nhận tham số. Gọi nó bằng SELECT … FROM tên_hàm(tham_số), y hệt đọc 1 bảng bình thường.
  • Gọi là “inline” vì SQL Server dán nội dung hàm thẳng vào câu query gọi nó, nên KHÔNG chậm hơn viết CTE trực tiếp.

Cú pháp:

       CREATE FUNCTION tên_hàm (@tham_số KIỂU)
       RETURNS TABLE
       AS
       RETURN ( SELECT ... FROM ... WHERE ... @tham_số ... );

Ví dụ cách sử dụng:

   -- Lấy toàn bộ user kèm user_state tại ngày 01/05/2026:
   SELECT * FROM dbo.fn_user_state('2026-05-01');

   -- Chỉ lấy user core:
   SELECT * FROM dbo.fn_user_state('2026-05-01') WHERE user_state = 'core';

   -- Đổi sang tháng khác, chỉ cần đổi tham số:
   SELECT * FROM dbo.fn_user_state('2026-06-01');

   -- JOIN 2 hàm với nhau:
   SELECT us.user_state, pw.active_days
   FROM dbo.fn_user_state('2026-05-01') us
   JOIN dbo.fn_unsub_post_window_active('2026-05-01') pw
     ON pw.user_id = us.user_id;

Hàm 1: fn_user_state

/*
   ĐẦU VÀO: 1 mốc thời gian @T_REF (ví dụ '2026-05-01').
   ĐẦU RA : 1 dòng/user gồm:
       user_id, signup_date, active_days_28d,
       active_days_prev_28d, had_before, user_state

   QUY TẮC PHÂN LOẠI:
       new         — user mới đăng ký trong cửa sổ 28 ngày
       core        — active >= 20/28 ngày
       casual      — active 8..19/28 ngày
       resurrected — active 1..7 ngày, 28 ngày liền trước = 0, và đã có hoạt động trước đó nữa
       marginal    — active 1..7 ngày (còn lại)
       dormant     — active = 0 nhưng từng có hoạt động

   CÁCH GỌI:
       SELECT * FROM dbo.fn_user_state('2026-05-01');
   ----------------------------------------------------------------- */
CREATE OR ALTER FUNCTION dbo.fn_user_state (@T_REF DATE)
RETURNS TABLE
AS
RETURN (
    WITH state_window AS (   -- đếm ngày active 28 ngày trước @T_REF
        SELECT u.user_id, u.signup_date,
               COUNT(DISTINCT a.activity_date) AS active_days_28d
        FROM dbo.users u
        LEFT JOIN dbo.user_activity a
            ON a.user_id = u.user_id
           AND a.activity_date >= DATEADD(DAY, -28, @T_REF)
           AND a.activity_date <  @T_REF
        GROUP BY u.user_id, u.signup_date
    ),
    prev_window AS (         -- đếm ngày active 28 ngày liền trước nữa
        SELECT u.user_id,
               COUNT(DISTINCT a.activity_date) AS active_days_prev_28d
        FROM dbo.users u
        LEFT JOIN dbo.user_activity a
            ON a.user_id = u.user_id
           AND a.activity_date >= DATEADD(DAY, -56, @T_REF)
           AND a.activity_date <  DATEADD(DAY, -28, @T_REF)
        GROUP BY u.user_id
    ),
    before_window AS (       -- kiểm tra trước đó có hoạt động không (cho resurrected/dormant)
        SELECT u.user_id,
               CASE WHEN MAX(a.activity_date) IS NOT NULL THEN 1 ELSE 0 END AS had_before
        FROM dbo.users u
        LEFT JOIN dbo.user_activity a
            ON a.user_id = u.user_id
           AND a.activity_date < DATEADD(DAY, -28, @T_REF)
        GROUP BY u.user_id
    )
    SELECT
        s.user_id, s.signup_date, s.active_days_28d,
        p.active_days_prev_28d, b.had_before,
        CASE
            WHEN s.signup_date >= DATEADD(DAY, -28, @T_REF) THEN 'new'
            WHEN s.active_days_28d >= 20 THEN 'core'
            WHEN s.active_days_28d BETWEEN 8 AND 19 THEN 'casual'
            WHEN s.active_days_28d BETWEEN 1 AND 7
                 AND p.active_days_prev_28d = 0
                 AND b.had_before = 1 THEN 'resurrected'
            WHEN s.active_days_28d BETWEEN 1 AND 7 THEN 'marginal'
            WHEN s.active_days_28d = 0 AND b.had_before = 1 THEN 'dormant'
            ELSE 'dormant'
        END AS user_state
    FROM state_window  s
    JOIN prev_window   p ON p.user_id = s.user_id
    JOIN before_window b ON b.user_id = s.user_id
);
GO

Hàm 2: fn_benchmark_users

/* 
   ĐẦU VÀO: @T_REF.
   ĐẦU RA : danh sách user_id ĐÃ NHẬN ÍT NHẤT 1 EMAIL trong cửa sổ 28 ngày trước @T_REF. Đây là tập so sánh "benchmark" cho phân tích cost-of-unsubscribe.
   ----------------------------------------------------------------- */
CREATE OR ALTER FUNCTION dbo.fn_benchmark_users (@T_REF DATE)
RETURNS TABLE
AS
RETURN (
    SELECT DISTINCT s.user_id
    FROM dbo.email_sends s
    WHERE s.sent_at >= DATEADD(DAY, -28, @T_REF)
      AND s.sent_at <  @T_REF
);
GO

Hàm 3: fn_active_days_window

/* 
   ĐẦU VÀO: @start, @end (DATE) — cửa sổ đo (bao gồm 2 đầu).
   ĐẦU RA : 1 dòng/user gồm user_id, active_days (số ngày phân biệt user có hoạt động trong cửa sổ).

   LƯU Ý: trả về TẤT CẢ user (kể cả 0 ngày) để dễ join với các tập con khác.

   CÁCH GỌI:
       SELECT * FROM dbo.fn_active_days_window('2026-05-02','2026-05-08');
   ----------------------------------------------------------------- */
CREATE OR ALTER FUNCTION dbo.fn_active_days_window (@start DATE, @end DATE)
RETURNS TABLE
AS
RETURN (
    SELECT
        u.user_id,
        COUNT(DISTINCT a.activity_date) AS active_days
    FROM dbo.users u
    LEFT JOIN dbo.user_activity a
        ON a.user_id = u.user_id
       AND a.activity_date >= @start
       AND a.activity_date <= @end
    GROUP BY u.user_id
);
GO

Hàm 4: fn_unsub_post_window_active (PER-USER WINDOW)

/* 
   ĐẦU VÀO: @T_REF (DATE).
   ĐẦU RA : 1 dòng/user đã unsubscribe trong [T_REF-28, T_REF) gồm user_id, unsub_date, active_days.

   active_days đếm trong cửa sổ RIÊNG của từng user: [unsub_date + 1, unsub_date + 7]

   OUTER APPLY là gì?

   BÀI TOÁN: Mỗi user có unsub_date khác nhau, cần đếm active_days trong cửa sổ [unsub_date+1, unsub_date+7] — cửa sổ KHÁC NHAU cho
   mỗi dòng. LEFT JOIN bình thường KHÔNG làm được điều này vì:

   APPLY giải quyết: chạy subquery CHO TỪNG DÒNG bên trái,    truyền giá trị của dòng đó vào subquery.                    
                                                             
     FROM unsubs u                                                
     OUTER APPLY (                                                
        SELECT COUNT(DISTINCT a.activity_date) AS active_days    
        FROM user_activity a                                     
         WHERE a.user_id = u.user_id              ← biết user_id 
           AND a.activity_date > u.unsub_date     ← biết ngày   
           AND a.activity_date <= DATEADD(DAY, 7, u.unsub_date)  
     ) ad                                                         
                                                                  
     → Mỗi user có cửa sổ riêng, subquery nhìn thấy unsub_date

   SO SÁNH CROSS APPLY vs OUTER APPLY:
   ┌──────────────┬────────────────────────────────────────────────┐
   │ Loại         │ Hành vi khi subquery trả về 0 dòng             │
   ├──────────────┼────────────────────────────────────────────────┤
   │ CROSS APPLY  │ Loại bỏ dòng bên trái (giống INNER JOIN)       │
   │ OUTER APPLY  │ Giữ dòng bên trái, cột phải = NULL             │
   │              │ (giống LEFT JOIN)                              │
   └──────────────┴────────────────────────────────────────────────┘

   Tại sao dùng OUTER (không phải CROSS)?
   → Nếu user churn hoàn toàn (0 ngày active), CROSS APPLY sẽ loại bỏ họ khỏi kết quả → mất dữ liệu quan trọng nhất
     OUTER APPLY giữ lại, trả active_days = NULL → ISNULL(...,0) chuyển thành 0 → đếm đúng số user churn.

   CÁCH GỌI:
       SELECT * FROM dbo.fn_unsub_post_window_active('2026-05-01');
   ----------------------------------------------------------------- */
CREATE OR ALTER FUNCTION dbo.fn_unsub_post_window_active (@T_REF DATE)
RETURNS TABLE
AS
RETURN (
    WITH unsubs AS (
        SELECT user_id, MIN(CAST(changed_at AS DATE)) AS unsub_date
        FROM dbo.email_subscription_log
        WHERE action = 'unsubscribe'
          AND changed_at >= DATEADD(DAY, -28, @T_REF)
          AND changed_at <  @T_REF
        GROUP BY user_id
    )
    SELECT
        u.user_id,
        u.unsub_date,
        ISNULL(ad.active_days, 0) AS active_days
    FROM unsubs u
    OUTER APPLY (                          -- chạy cho từng dòng unsubs
        SELECT COUNT(DISTINCT a.activity_date) AS active_days
        FROM dbo.user_activity a
        WHERE a.user_id = u.user_id        -- tham chiếu dòng bên trái
          AND a.activity_date >  u.unsub_date                    -- cửa sổ PER-USER
          AND a.activity_date <= DATEADD(DAY, 7, u.unsub_date)   -- [unsub+1, unsub+7]
    ) ad
);
GO

Leave a Comment