Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Slowly Changing Dimensions (SCD): theo dõi lịch sử

SQL and Data Analysis cho BA Bài 48/60

Mở đầu — vì sao bài này quan trọng

Hãy bắt đầu bằng một câu hỏi tưởng chừng đơn giản mà rất nhiều BA trả lời sai: "Doanh thu theo thành phố tháng 1 là bao nhiêu?"

Bạn viết một query GROUP BY thành phố, ra một con số đẹp, gửi cho sếp. Nhưng có một cái bẫy ngầm. Khách hàng Nguyễn Văn A đặt một đơn 5 triệu vào tháng 1, lúc đó anh ấy đang sống ở TP.HCM. Đến tháng 6, anh chuyển ra Hà Nội và cập nhật địa chỉ trong hệ thống. Khi bạn chạy báo cáo vào tháng 7, query JOIN bảng orders với bảng customers để lấy thành phố — và nó lấy địa chỉ hiện tại của khách: Hà Nội. Thế là đơn hàng tháng 1 của anh A bỗng dưng "biến" thành doanh thu Hà Nội, dù tại thời điểm mua hàng anh ấy là khách TP.HCM.

Con số của bạn sai. Và cái sai này không phải lỗi cú pháp SQL — query chạy hoàn hảo, không báo lỗi gì cả. Nó sai vì bạn không hiểu một khái niệm nền tảng của data warehouse: dữ liệu chiều (dimension) thay đổi theo thời gian, và bạn phải quyết định mình muốn nhìn theo giá trị lúc đó hay giá trị bây giờ.

Đây chính là bài toán Slowly Changing Dimensions (SCD) — "các chiều dữ liệu thay đổi chậm". Đây là một trong những khái niệm phân biệt một BA biết viết SQL với một BA hiểu dữ liệu. Nó xuất hiện ở khắp nơi: địa chỉ khách, gói cước, phân khúc khách hàng, giá sản phẩm, tên phòng ban của nhân viên, hạng thành viên. Bất cứ thuộc tính mô tả nào có thể đổi giá trị theo thời gian đều là một SCD tiềm tàng. Nếu bạn không xử lý đúng, mọi báo cáo lịch sử của bạn đều có nguy cơ "viết lại lịch sử".

Khái niệm cốt lõi

Dimension là gì, và "thay đổi chậm" nghĩa là gì

Trong mô hình kho dữ liệu, ta phân biệt fact (sự kiện, thường là giao dịch — đơn hàng, cú click, lượt nạp tiền) và dimension (chiều mô tả — khách hàng, sản phẩm, cửa hàng, nhân viên). Fact là cái "đo lường được", dimension là cái "lọc và nhóm theo".

"Slowly changing" nghĩa là các thuộc tính của dimension thay đổi, nhưng không thường xuyên: khách đổi địa chỉ vài năm một lần, sản phẩm đổi danh mục thi thoảng, nhân viên đổi phòng ban dăm lần trong sự nghiệp. Khác với fact thay đổi liên tục, dimension đổi chậm — nhưng chính vì đổi chậm nên người ta dễ quên rằng nó đổi, và đó là nguồn gốc của lỗi.

Ba loại SCD bạn buộc phải nắm

Ralph Kimball — cha đẻ của mô hình dimensional — phân loại nhiều kiểu, nhưng với một BA, ba loại sau là đủ dùng 95% trường hợp.

SCD Type 1 — Ghi đè (Overwrite). Khi giá trị đổi, bạn cập nhật thẳng vào bảng, xóa giá trị cũ. Bảng customers chỉ có một dòng cho mỗi khách, cột city luôn là giá trị mới nhất.

UPDATE customers SET city = 'Ha Noi' WHERE customer_id = 101;

Ưu điểm: đơn giản, bảng gọn. Nhược điểm chí mạng: mất toàn bộ lịch sử. Bạn không bao giờ biết được trước đây khách ở đâu. Type 1 phù hợp khi giá trị cũ thực sự vô nghĩa — ví dụ sửa lỗi gõ sai tên ("Nguyentr Van A" thành "Nguyen Van A"), hoặc khi không ai quan tâm lịch sử.

SCD Type 2 — Thêm dòng mới (Add new row). Đây là loại quan trọng nhất và là "vũ khí chính" của BA. Thay vì ghi đè, bạn giữ dòng cũ và thêm một dòng mới cho giá trị mới. Mỗi dòng có khoảng thời gian hiệu lực của nó. Bảng giờ trông như sau:

customer_skcustomer_idcityvalid_fromvalid_tois_current
5001101TP.HCM2025-01-012025-05-31
5002101Ha Noi2025-06-019999-12-311
Lưu ý hai khái niệm:
  • customer_idnatural key (business key) — định danh thật của khách, không đổi.
  • customer_sksurrogate key — khóa nhân tạo, mỗi dòng-phiên bản một giá trị riêng. Đây là cái mà bảng fact sẽ tham chiếu tới.
  • valid_from / valid_to định nghĩa khoảng thời gian dòng này "có hiệu lực". is_current = 1 đánh dấu dòng hiện hành.
Với Type 2, lịch sử được bảo toàn hoàn hảo. Đơn hàng tháng 1 trỏ tới customer_sk = 5001 (TP.HCM), đơn tháng 7 trỏ tới 5002 (Hà Nội). Báo cáo theo thành phố luôn đúng theo thời điểm.

SCD Type 3 — Cột giá trị trước đó (Add new column). Bạn thêm một cột để lưu giá trị liền trước. Ví dụ current_cityprevious_city. Loại này chỉ giữ được một bước lịch sử, ít dùng, phù hợp khi business chỉ quan tâm "trước và sau" một lần thay đổi lớn (ví dụ tái cơ cấu phòng ban một lần).

"As-of-then" vs "As-of-now" — câu hỏi cốt lõi bạn phải tự đặt

Mỗi lần viết báo cáo có dính dimension, hãy tự hỏi: tôi muốn thuộc tính tại thời điểm sự kiện xảy ra (as-of-then / point-in-time) hay tại thời điểm hiện tại (as-of-now / current)?

  • "Doanh thu theo thành phố lúc khách mua" → as-of-then → JOIN theo phiên bản hợp lệ tại ngày đặt hàng.
  • "Trong số khách đã từng mua, hiện giờ bao nhiêu người sống ở Hà Nội" → as-of-now → JOIN theo dòng is_current = 1.
Cả hai đều hợp lệ, nhưng trả lời câu hỏi khác nhau. Sai lầm phổ biến là dùng câu trả lời as-of-now cho một câu hỏi vốn cần as-of-then.

Tình huống thực tế

Ví dụ 1 — Tiki và bài toán phân khúc khách hàng

Giả định một đội phân tích tại Tiki theo dõi doanh thu theo hạng thành viên (TikiNOW Member). Khách hàng Trần Thị B vào tháng 3/2025 còn là khách thường, mua 2 triệu/tháng. Tháng 5 cô nâng cấp lên TikiNOW, sức mua tăng vọt lên 8 triệu/tháng.

Đội marketing hỏi: "Khách trước khi lên TikiNOW chi tiêu trung bình bao nhiêu, sau khi lên thì bao nhiêu? Việc nâng cấp có thực sự tăng chi tiêu không?"

Nếu bảng customers dùng Type 1 (chỉ giữ hạng hiện tại là TikiNOW), bạn không thể trả lời. Mọi giao dịch của cô B đều bị gắn nhãn "TikiNOW", kể cả những đơn từ hồi còn là khách thường. Bạn sẽ kết luận sai rằng khách thường chi tiêu cao — vì đám đông "khách thường" đã bị rút mất những người sau này lên hạng.

Với Type 2, mỗi đơn hàng trỏ tới phiên bản khách đúng tại thời điểm đó. Bạn nhóm doanh thu theo membership_tier của phiên bản hợp lệ, và thấy rõ: trước nâng cấp 2 triệu, sau nâng cấp 8 triệu. Bài học: câu hỏi "trước/sau khi đổi trạng thái" chỉ trả lời được khi dimension lưu lịch sử bằng Type 2.

Ví dụ 2 — Grab và bài toán tài xế đổi thành phố hoạt động

Một analyst ở Grab phân tích hiệu suất theo thành phố. Tài xế Lê Văn C chạy ở Đà Nẵng nửa đầu năm, rồi chuyển vào TP.HCM. Bảng drivers được thiết kế Type 1, cột operating_city luôn là thành phố mới nhất.

Tháng 8, sếp yêu cầu báo cáo "tổng số cuốc xe theo thành phố theo từng tháng". Analyst JOIN trips với drivers theo driver_id, GROUP BY operating_city và tháng. Kết quả: toàn bộ cuốc xe của anh C từ tháng 1 đến tháng 6 — vốn diễn ra ở Đà Nẵng — bị tính vào TP.HCM, vì đó là operating_city hiện tại của anh.

Hệ quả: Đà Nẵng bị thiếu cuốc, TP.HCM bị thừa cuốc trong các tháng đầu năm. Đội vận hành Đà Nẵng nhìn báo cáo tưởng nhu cầu giảm, cắt giảm khuyến mãi tài xế — một quyết định dựa trên số liệu sai.

Cách khắc phục đúng nhất là chuyển drivers sang Type 2 với valid_from/valid_to, rồi JOIN theo điều kiện ngày cuốc xe nằm trong khoảng hiệu lực. Bài học: với phân tích theo địa lý + thời gian, dimension địa lý gần như luôn cần Type 2. Một thiết kế Type 1 sẽ âm thầm bóp méo mọi báo cáo lịch sử.

Ví dụ 3 — MoMo và giá gói dịch vụ thay đổi

Một fintech kiểu MoMo bán gói "Ví Trả Sau" với hạn mức và phí thay đổi theo từng đợt chính sách. Tháng 1 phí gói là 11.000đ/tháng, tháng 4 tăng lên 22.000đ. Khi đối soát doanh thu phí, đội tài chính cần biết mỗi giao dịch thu phí áp dụng mức phí nào tại thời điểm thu.

Nếu bảng service_plans ghi đè (Type 1) sang 22.000đ, thì khi tính lại doanh thu phí quý 1, bạn sẽ nhân nhầm số thuê bao tháng 1 với 22.000đ thay vì 11.000đ — thổi phồng doanh thu và lệch với sổ sách kế toán thực tế. Type 2 cho service_plans (mỗi mức phí một dòng với khoảng hiệu lực) giải quyết gọn: JOIN giao dịch với phiên bản gói hợp lệ tại ngày thu phí.

Bài học: không chỉ khách hàng — giá, phí, chính sách cũng là dimension cần theo dõi lịch sử. Đối soát tài chính là nơi sai SCD bị phát hiện đau đớn nhất.

Hướng dẫn từng bước

Giả sử bạn có bảng dim_customer thiết kế Type 2 như trên và bảng fact_orders(order_id, customer_id, order_date, amount). Đây là cách viết query đúng cho từng loại câu hỏi.

Bước 1 — Xác định bạn cần as-of-then hay as-of-now. Viết câu hỏi ra giấy. "Lúc mua" → as-of-then. "Hiện giờ" → as-of-now.

Bước 2 — Truy vấn as-of-then (point-in-time JOIN). Đây là JOIN theo natural key cộng điều kiện ngày sự kiện rơi vào khoảng hiệu lực:

SELECT
    c.city,
    DATE_TRUNC('month', o.order_date) AS thang,
    SUM(o.amount) AS doanh_thu
FROM fact_orders o
JOIN dim_customer c
    ON o.customer_id = c.customer_id
    AND o.order_date >= c.valid_from
    AND o.order_date <  c.valid_to        -- nửa mở: [from, to)
GROUP BY c.city, DATE_TRUNC('month', o.order_date)
ORDER BY thang, c.city;

Đây là pattern quan trọng nhất của cả bài. Điều kiện order_date >= valid_from AND order_date < valid_to đảm bảo mỗi đơn ghép đúng với một và chỉ một phiên bản khách. Đơn tháng 1 ghép phiên bản TP.HCM, đơn tháng 7 ghép phiên bản Hà Nội. Tự động, chính xác.

Nếu data warehouse của bạn đã gắn sẵn customer_sk (surrogate key) vào fact lúc nạp dữ liệu (cách làm chuẩn của ETL), thì còn đơn giản hơn — chỉ cần JOIN thẳng theo customer_sk, không cần điều kiện ngày:

SELECT c.city, SUM(o.amount)
FROM fact_orders o
JOIN dim_customer c ON o.customer_sk = c.customer_sk
GROUP BY c.city;

Bước 3 — Truy vấn as-of-now (chỉ lấy phiên bản hiện hành).

SELECT c.city, COUNT(DISTINCT o.customer_id) AS so_khach
FROM fact_orders o
JOIN dim_customer c
    ON o.customer_id = c.customer_id
    AND c.is_current = 1
GROUP BY c.city;

Bước 4 — Truy vấn "as-of một ngày bất kỳ". Muốn xem chân dung khách tại ngày 2025-03-15? Thay ngày cố định vào điều kiện khoảng hiệu lực:

SELECT *
FROM dim_customer
WHERE DATE '2025-03-15' >= valid_from
  AND DATE '2025-03-15' <  valid_to;

Bước 5 — Tự xây Type 2 bằng window function (khi bạn chỉ có log thay đổi). Đôi khi bạn không có bảng dim sẵn, mà chỉ có một bảng customer_changes(customer_id, city, changed_at) ghi mỗi lần khách đổi địa chỉ. Bạn dùng LEAD() để tự suy ra valid_to:

SELECT
    customer_id,
    city,
    changed_at AS valid_from,
    COALESCE(
        LEAD(changed_at) OVER (PARTITION BY customer_id ORDER BY changed_at),
        DATE '9999-12-31'
    ) AS valid_to
FROM customer_changes;

LEAD lấy thời điểm thay đổi kế tiếp làm điểm kết thúc hiệu lực của dòng hiện tại; dòng cuối cùng (không có thay đổi sau nó) nhận 9999-12-31 nghĩa là "còn hiệu lực đến vô tận". Đây là một kỹ thuật cực hữu dụng để "dựng" SCD Type 2 on-the-fly từ dữ liệu thô.

Lỗi thường gặp & mẹo

Lỗi 1 — JOIN dimension hiện tại cho báo cáo lịch sử. Đây là lỗi kinh điển đã mô tả ở đầu bài. Quy tắc: khi báo cáo có chiều thời gian VÀ một thuộc tính dimension có thể đổi, hãy mặc định nghi ngờ và hỏi "thuộc tính này có Type 2 không?".

Lỗi 2 — Khoảng thời gian chồng lấn hoặc hở. Nếu bạn để valid_to của dòng cũ bằng valid_from của dòng mới và dùng BETWEEN (vốn bao gồm cả hai đầu), một đơn rơi đúng ngày giao sẽ ghép với cả hai phiên bản → nhân đôi doanh thu. Mẹo: luôn dùng khoảng nửa mở >= valid_from AND < valid_to, và đảm bảo valid_to dòng cũ = valid_from dòng mới (không chồng, không hở).

Lỗi 3 — Quên is_current khi chỉ cần dòng mới nhất. Nếu bạn JOIN theo customer_id mà không lọc is_current = 1, mỗi khách Type 2 có nhiều dòng → kết quả nhân lên theo số phiên bản. Báo cáo "danh sách khách hiện tại" bỗng có khách xuất hiện 3-4 lần.

Lỗi 4 — Dùng natural key làm khóa JOIN với fact mà tưởng là duy nhất. Trong bảng Type 2, customer_id không còn duy nhất. Khóa duy nhất là customer_sk. Nhầm hai cái này là nguồn của vô số bug COUNT/SUM bị thổi phồng.

Mẹo 5 — Hỏi đội data engineer dimension nào là Type mấy. Là BA, bạn không phải người thiết kế ETL, nhưng bạn phải biết bảng khách/sản phẩm/giá là Type 1 hay Type 2. Một câu hỏi đúng lúc — "bảng này có giữ lịch sử địa chỉ không?" — cứu bạn khỏi cả tuần báo cáo sai.

Mẹo 6 — 9999-12-31 là quy ước, hãy nhất quán. Nhiều warehouse dùng NULL cho valid_to của dòng hiện hành thay vì 9999-12-31. Nếu vậy, điều kiện JOIN phải xử lý NULL: (o.order_date < c.valid_to OR c.valid_to IS NULL). Biết trước quy ước của hệ thống mình.

Bài tập thực hành

Cho hai bảng:

dim_customer(customer_sk, customer_id, segment, city, valid_from, valid_to, is_current) — Type 2. fact_orders(order_id, customer_id, customer_sk, order_date, amount).

  • As-of-then cơ bản: Viết query tính tổng doanh thu theo city cho riêng quý 1/2025, dùng đúng thành phố của khách tại thời điểm đặt hàng. (Gợi ý: JOIN theo customer_sk hoặc theo điều kiện khoảng hiệu lực.)
  • So sánh as-of-then vs as-of-now: Viết hai query cho cùng câu hỏi "doanh thu theo segment năm 2025" — một bản dùng segment lúc mua, một bản dùng segment hiện tại (is_current = 1). Giải thích bằng lời tại sao hai con số khác nhau và mỗi con số trả lời câu hỏi nghiệp vụ nào.
  • Đếm khách đã đổi thành phố: Viết query tìm những customer_idnhiều hơn một thành phố trong lịch sử (tức đã từng chuyển nơi ở). (Gợi ý: GROUP BY customer_id HAVING COUNT(DISTINCT city) > 1.)
  • Tự dựng Type 2 từ log: Cho bảng tier_changes(customer_id, tier, changed_at), dùng LEAD() để tạo ra cấu trúc Type 2 với valid_from, valid_to, và một cột is_current (gợi ý: dòng có valid_to = '9999-12-31' là current).
  • As-of một ngày: Viết query trả về chân dung toàn bộ khách hàng đúng như tại ngày 30/06/2025 (mỗi khách một dòng, đúng phiên bản hiệu lực ngày đó).
Hãy thử trên môi trường MySQL hoặc PostgreSQL của bạn. Tự tạo vài dòng dữ liệu có khách đổi thành phố giữa năm, rồi kiểm tra xem con số as-of-then có khác as-of-now không — đó là khoảnh khắc khái niệm này "thấm".

Tóm tắt

Slowly Changing Dimensions là cách kho dữ liệu xử lý sự thật rằng các thuộc tính mô tả — địa chỉ, hạng thành viên, giá, phòng ban — thay đổi theo thời gian. Ba điều cần nhớ:

  • Ba loại chính: Type 1 ghi đè (mất lịch sử), Type 2 thêm dòng mới với valid_from/valid_to/is_current (giữ trọn lịch sử — quan trọng nhất với BA), Type 3 thêm cột giá trị trước (giữ một bước).
  • Câu hỏi cốt lõi mỗi lần viết báo cáo: mình cần thuộc tính lúc sự kiện xảy ra (as-of-then) hay hiện tại (as-of-now)? Hai cái trả lời hai câu hỏi nghiệp vụ khác nhau.
  • Pattern JOIN point-in-time: ghép fact với phiên bản dimension hợp lệ bằng order_date >= valid_from AND order_date < valid_to (khoảng nửa mở), hoặc JOIN theo customer_sk nếu ETL đã gắn sẵn. Đừng bao giờ JOIN bừa theo natural key rồi GROUP BY thuộc tính có thể đổi — đó là cách "viết lại lịch sử" mà không hay biết.
Nắm vững SCD, bạn sẽ không bao giờ giao một báo cáo lịch sử sai chỉ vì khách hàng đã chuyển nhà.