Data Modeling 4 — Dimensions & SCD (Slowly Changing Dimensions)
Data Modeling 4 — Dimensions & SCD (Slowly Changing Dimensions)
Ở bài Dimensional & Kimball chúng ta đã dựng sơ đồ sao: fact ở trung tâm, các dimension bao quanh. Ở bài Fact tables chúng ta đã mổ xẻ bảng sự kiện — nơi lưu số đo. Còn bài này trả lời một câu hỏi tưởng nhỏ mà là gốc rễ của nghề mô hình hoá dữ liệu: khi một thuộc tính mô tả thay đổi theo thời gian thì kho dữ liệu phải làm gì?
Ví dụ đời thường. Chị Lan là khách hàng hạng Bạc. Tháng sau chị lên Vàng. Nếu kho dữ liệu chỉ đơn giản ghi đè "Bạc" thành "Vàng", thì mọi báo cáo doanh thu của năm ngoái đột nhiên nói rằng "hồi đó chị Lan đã là Vàng" — sai sự thật. Ngược lại, nếu ta biết cách giữ lịch sử, ta có thể trả lời chính xác: tại thời điểm giao dịch tháng 3, chị Lan thuộc hạng nào? Đó chính là bài toán Slowly Changing Dimension (SCD) — chiều thay đổi chậm.
Bài này đi từ khái niệm dimension và surrogate key, rồi tới đầy đủ các kiểu SCD (Type 0–6), sau đó là các dạng dimension đặc biệt (conformed, role-playing, junk, degenerate, hierarchy, outrigger, bridge). Toàn bộ DDL là minh hoạ để nắm tư duy; sandbox của chúng ta là PostgreSQL.
Nhắc lại: dimension là gì
Dimension là bảng mô tả ngữ cảnh của một sự kiện: ai, cái gì, ở đâu, khi nào, như thế nào. Nếu fact trả lời "bao nhiêu" (số tiền, số lượng), thì dimension trả lời "về ai/cái gì". Bảng chiều điển hình rộng (nhiều cột), ít dòng (so với fact), và toàn bộ giá trị là thuộc tính mô tả dùng để lọc, nhóm, gán nhãn trong báo cáo: tên khách, giới tính, tỉnh/thành, hạng thẻ, ngành hàng, tên sản phẩm...
Chất lượng của một mô hình phân tích phụ thuộc gần như hoàn toàn vào chất lượng dimension: câu GROUP BY và WHERE của người dùng cuối gần như luôn chạm vào cột của dimension, chứ hiếm khi chạm cột của fact.
Surrogate key — vì sao không dùng thẳng mã nghiệp vụ
Mỗi dòng dimension có hai loại khoá:
- Natural key / business key: mã do hệ thống nguồn cấp —
customer_id = 'KH001', số CMND, mã sản phẩm nhà cung cấp. - Surrogate key: một số nguyên vô nghĩa do kho dữ liệu tự sinh (
1, 2, 3, ...), không mang ý nghĩa nghiệp vụ, chỉ để nhận diện một dòng vật lý trong bảng chiều.
Fact luôn tham chiếu tới dimension qua surrogate key, không qua business key. Ba lý do:
- Bắt buộc để làm SCD Type 2. Khi chị Lan lên hạng, ta tạo một dòng mới mô tả "chị Lan hạng Vàng" trong khi vẫn giữ dòng cũ "chị Lan hạng Bạc". Cả hai dòng có cùng business key
KH001nhưng khác surrogate key. Nếu fact trỏ theo business key thì không phân biệt nổi hai phiên bản; trỏ theo surrogate key thì mỗi giao dịch "khoá cứng" vào đúng phiên bản đang có hiệu lực khi nó xảy ra. - Cách ly khỏi nguồn. Đổi hệ nguồn, gộp hai hệ thống, hay nguồn dùng chuỗi ký tự dài làm khoá — kho vẫn ổn định vì có khoá riêng của mình. Còn quản lý được cả unknown member (xem cuối bài).
- Hiệu năng. Join và index trên số nguyên nhỏ nhanh và gọn hơn nhiều so với chuỗi ghép nhiều cột.
-- Bảng chiều khách hàng dùng surrogate key
CREATE TABLE dim_customer (
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- surrogate key
customer_id VARCHAR(20) NOT NULL, -- natural/business key từ hệ nguồn
full_name VARCHAR(200),
tier VARCHAR(20), -- hạng khách: Bạc / Vàng / Kim cương
province VARCHAR(100)
);
Slowly Changing Dimensions — bản đồ các kiểu
"Slowly changing" nghĩa là thuộc tính thay đổi chậm và không đều (khách đổi địa chỉ vài năm một lần, đổi hạng vài tháng một lần) — khác với fact thay đổi liên tục. Câu hỏi cốt lõi: khi giá trị đổi, giữ hay bỏ lịch sử, và giữ ở đâu? Kimball chuẩn hoá thành các "Type".
Type 0 — giữ nguyên, không đổi
Thuộc tính không bao giờ được cập nhật sau khi ghi lần đầu. Giá trị gốc là "sự thật vĩnh viễn". Dùng cho: ngày mở tài khoản, ngày sinh, mã số thuế gốc — những thứ mà mọi thay đổi phía nguồn đều bị phớt lờ có chủ đích. Đây là kiểu "bảo tồn giá trị ban đầu".
Type 1 — ghi đè (overwrite)
Cập nhật đè thẳng lên giá trị cũ; không giữ lịch sử. Sau khi ghi đè, kho không còn biết giá trị trước từng là gì.
UPDATE dim_customer
SET province = 'Hà Nội'
WHERE customer_id = 'KH001'; -- giá trị cũ 'Hải Phòng' biến mất vĩnh viễn
Ưu điểm: đơn giản, bảng gọn, không nở dòng. Dùng khi: sửa lỗi dữ liệu (nhập sai tên rồi sửa lại — ta không muốn "lịch sử của một lỗi"), hoặc thuộc tính mà bản chất nghiệp vụ chỉ quan tâm giá trị mới nhất. Nhược điểm chí mạng: mất khả năng phân tích theo thời điểm.
Type 2 — thêm dòng mới có phiên bản (phổ biến nhất)
Đây là kiểu quan trọng nhất và là mặc định khi cần giữ lịch sử đầy đủ. Nguyên tắc: không sửa dòng cũ, thêm dòng mới. Mỗi dòng là một phiên bản của thực thể, kèm ba cột kỹ thuật:
valid_from/valid_to: khoảng thời gian phiên bản đó có hiệu lực.is_current: cờ đánh dấu phiên bản hiện hành (tiện lọc nhanh).customer_sk: surrogate key mới cho mỗi phiên bản (cùngcustomer_id).
Khi chị Lan lên hạng: đóng dòng cũ (đặt valid_to, is_current = FALSE) và chèn một dòng mới có surrogate key mới. Kết quả: cùng một khách có nhiều dòng, mỗi dòng ứng với một quãng đời trạng thái.
Sơ đồ trên: cùng customer_id = KH001 nhưng ba dòng vật lý (sk = 1, 2, 3), ba quãng thời gian nối tiếp không chồng lấn. Fact của một giao dịch tháng 5/2024 sẽ trỏ vào sk = 2 (Vàng) — vì đó là phiên bản đang hiệu lực khi giao dịch xảy ra. Đây chính là cơ chế cho phép truy vấn "as-of" (theo thời điểm).
CREATE TABLE dim_customer_scd2 (
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id VARCHAR(20) NOT NULL, -- business key, KHÔNG unique
full_name VARCHAR(200),
tier VARCHAR(20),
province VARCHAR(100),
valid_from TIMESTAMP NOT NULL,
valid_to TIMESTAMP, -- NULL = còn hiệu lực
is_current BOOLEAN NOT NULL DEFAULT TRUE
);
-- Bước 1: đóng phiên bản cũ khi phát hiện thay đổi
UPDATE dim_customer_scd2
SET valid_to = '2024-03-15 00:00:00', is_current = FALSE
WHERE customer_id = 'KH001' AND is_current = TRUE;
-- Bước 2: chèn phiên bản mới
INSERT INTO dim_customer_scd2
(customer_id, full_name, tier, province, valid_from, valid_to, is_current)
VALUES
('KH001', 'Nguyễn Thị Lan', 'Vàng', 'Hà Nội', '2024-03-15 00:00:00', NULL, TRUE);
Truy vấn as-of — trạng thái khách tại một mốc bất kỳ:
-- Chị Lan thuộc hạng nào vào ngày 2024-05-10?
SELECT customer_id, tier
FROM dim_customer_scd2
WHERE customer_id = 'KH001'
AND '2024-05-10' >= valid_from
AND ('2024-05-10' < valid_to OR valid_to IS NULL);
Và khi build fact, ta join theo thời điểm sự kiện để "chốt" đúng phiên bản, rồi lưu customer_sk vào fact. Từ đó về sau mọi báo cáo chỉ cần join thẳng surrogate key.
Cần lưu ý cái giá: bảng nở dòng, ETL phức tạp hơn (phải phát hiện thay đổi, đóng dòng cũ, mở dòng mới), và người viết truy vấn phải nhớ lọc is_current = TRUE khi chỉ muốn ảnh chụp hiện tại. Trong dbt, toàn bộ vòng đời này được tự động hoá bằng snapshots — xem dbt Sources & snapshots.
Type 3 — thêm cột (lịch sử giới hạn)
Giữ lịch sử bằng cách thêm cột previous_* bên cạnh giá trị hiện tại, thay vì thêm dòng. Chỉ nhớ được một (hoặc vài) mốc thay đổi gần nhất.
ALTER TABLE dim_customer ADD COLUMN previous_tier VARCHAR(20);
-- Khi đổi hạng: dời hạng cũ sang previous_tier, ghi hạng mới vào tier
Dùng khi cần so sánh "trước và sau" một sự kiện có kế hoạch (ví dụ tái cấu trúc vùng bán hàng: muốn xem doanh số theo cả vùng cũ lẫn vùng mới), nhưng không cần toàn bộ dòng lịch sử. Nhược điểm: không mở rộng được — mỗi lần đổi lại đè lên "previous", nên chỉ giữ được lịch sử nông.
Type 4 — bảng lịch sử riêng / mini-dimension
Tách phần hay đổi ra bảng riêng. Hai biến thể thường gặp:
- History table: bảng chiều chính giữ trạng thái hiện tại (Type 1), toàn bộ phiên bản lịch sử đẩy sang một bảng phụ.
- Mini-dimension: nhóm các thuộc tính đổi nhanh (fast-changing) như khoảng thu nhập, nhóm điểm tín dụng, phân khúc marketing — thành một dimension nhỏ riêng, và fact trỏ tới cả dimension khách (đổi chậm) lẫn mini-dimension (đổi nhanh). Cách này tránh cho dimension chính nở dòng bùng nổ vì những thuộc tính thay đổi liên tục.
Type 6 — hybrid (1 + 2 + 3 = 6)
Kết hợp cả ba: nền là Type 2 (mỗi thay đổi tạo dòng mới có phiên bản), nhưng thêm một cột kiểu Type 3 (current_tier) được cập nhật theo kiểu Type 1 trên tất cả các dòng của cùng khách. Nhờ đó bạn có được cả hai góc nhìn cùng lúc:
- Giá trị tại thời điểm sự kiện — đọc cột phiên bản (
tier) của dòng ứng với giao dịch. - Giá trị hiện hành hôm nay — đọc cột
current_tier, luôn phản ánh trạng thái mới nhất, dù bạn đang đứng ở dòng lịch sử nào.
Tên "Type 6" đến từ chính phép cộng 1 + 2 + 3.
Bảng so sánh nhanh
| Type | Cơ chế | Giữ lịch sử? | Bảng nở? | Khi nào dùng |
|---|---|---|---|---|
| 0 | Không đổi | Giữ giá trị gốc | Không | Thuộc tính bất biến (ngày mở, ngày sinh) |
| 1 | Ghi đè | Không | Không | Sửa lỗi; chỉ cần giá trị mới nhất |
| 2 | Thêm dòng + phiên bản | Đầy đủ | Có | Mặc định khi cần lịch sử (hạng khách, địa chỉ) |
| 3 | Thêm cột previous | Giới hạn (1–vài mốc) | Không | So sánh "trước/sau" một thay đổi có kế hoạch |
| 4 | Bảng lịch sử / mini-dim | Đầy đủ (tách ra) | Bảng phụ | Thuộc tính đổi nhanh; tránh nở dim chính |
| 6 | 1+2+3 hybrid | Đầy đủ + snapshot hiện tại | Có | Cần cả "as-of" lẫn "hiện tại" trong một truy vấn |
Nguyên tắc chọn: với thuộc tính cần phân tích theo thời điểm, mặc định Type 2. Với thuộc tính chỉ là sửa lỗi hoặc không ai quan tâm lịch sử, Type 1. Trong thực tế một bảng chiều thường trộn nhiều Type trên các cột khác nhau: tier là Type 2, email là Type 1, date_of_birth là Type 0.
Các dạng dimension đặc biệt
Conformed dimension — dùng chung nhất quán
Một conformed dimension là dimension được thiết kế để dùng chung y hệt trên nhiều bảng fact / nhiều bộ phận: cùng cấu trúc, cùng khoá, cùng ý nghĩa thuộc tính. Nhờ đó doanh số (fact bán hàng) và tồn kho (fact kho) có thể nhóm theo cùng dim_product, cho ra con số so sánh được. Đây là xương sống của kiến trúc bus matrix trong Kimball: "conform" một lần, tái dùng khắp nơi. Chệch chuẩn ở đây là nguồn cơn của những báo cáo "cùng chỉ tiêu, hai con số khác nhau".
Role-playing dimension — một chiều đóng nhiều vai
Cùng một dimension vật lý xuất hiện nhiều lần trong một fact với vai trò khác nhau. Kinh điển là dim_date: một đơn hàng có order_date, ship_date, delivery_date — cả ba đều là ngày, đều trỏ về dim_date, nhưng ý nghĩa khác nhau. Ta không tạo ba bảng ngày; thay vào đó tạo view/alias để mỗi vai có tên cột rõ ràng.
CREATE VIEW dim_ship_date AS SELECT * FROM dim_date; -- alias vai "ngày giao"
-- fact_orders join dim_date AS order_date và dim_ship_date AS ship_date
Junk dimension — gom cờ/flag lặt vặt
Trong nghiệp vụ có nhiều cột cờ nhị phân hoặc mã ngắn rời rạc: is_gift, is_online, payment_type, channel. Nhét thẳng vào fact thì fact phình cột; tạo mỗi cái một dimension thì thừa thãi. Giải pháp: gom tất cả vào một junk dimension — mỗi tổ hợp giá trị thành một dòng, fact chỉ giữ một khoá duy nhất trỏ tới đó.
Degenerate dimension — mã nằm ngay trong fact
Đôi khi một "khoá dimension" không có thuộc tính mô tả nào đi kèm — điển hình là số hoá đơn, mã đơn hàng. Nó hữu ích để gom các dòng của cùng một giao dịch, nhưng chẳng có bảng chiều nào để trỏ tới. Khi đó ta để mã đó ngay trong fact table như một cột, không tạo bảng riêng — gọi là degenerate dimension (dimension "suy biến").
Hierarchy — phân cấp, parent-child & snowflake
Thuộc tính chiều thường có cấp bậc: sản phẩm → ngành hàng → nhóm ngành; địa lý → quận → tỉnh → vùng. Hai cách xử lý:
- Phẳng (denormalized) trong sao: nhét mọi cấp thành các cột của cùng một dimension — nhanh, dễ truy vấn, kiểu Kimball ưa dùng.
- Snowflake (chuẩn hoá): tách mỗi cấp thành bảng riêng nối bằng khoá — gọn dung lượng nhưng nhiều join hơn (xem thêm ở Kimball).
- Parent-child (đệ quy): cấu trúc cây sâu không cố định (sơ đồ tổ chức, cây tài khoản kế toán) — mỗi dòng trỏ tới dòng cha; thường cần bridge table phân cấp để duyệt hiệu quả.
Outrigger
Một outrigger là dimension nhỏ được nối vào một dimension khác (chứ không nối trực tiếp vào fact). Ví dụ dim_customer có một khoá trỏ tới dim_date để mô tả "ngày trở thành khách VIP". Dùng dè dặt: lạm dụng outrigger biến sao thành bông tuyết rối rắm.
Multivalued & bridge
Có khi một fact ứng với nhiều giá trị của một chiều: một tài khoản ngân hàng có nhiều chủ sở hữu; một bài báo có nhiều tác giả. Quan hệ nhiều-nhiều này không nhét thẳng vào một cột khoá được; ta chèn một bridge table giữa fact và dimension, kèm trọng số phân bổ để tránh đếm trùng khi cộng số đo (xem Fact tables).
Use case thực tế: giữ lịch sử hạng khách bằng SCD2
Ngân hàng bán lẻ có chương trình khách hàng thân thiết với các hạng Bạc / Vàng / Kim cương (chi tiết ở Loyalty tiers & benefits). Câu hỏi kinh doanh: "Doanh thu phí dịch vụ quý 1/2024 phân theo hạng khách là bao nhiêu — theo đúng hạng của họ tại thời điểm phát sinh phí?"
Nếu dim_customer là Type 1, ta chịu chết: hôm nay chị Lan là Kim cương, mọi giao dịch cũ của chị bị gán nhầm vào Kim cương, thổi phồng doanh thu của hạng cao và làm rỗng hạng thấp. Báo cáo trở nên vô nghĩa cho việc đánh giá hiệu quả từng phân khúc.
Với dim_customer là SCD Type 2:
- ETL/snapshot phát hiện
tierđổi → đóng dòng cũ, mở dòng mới với surrogate key mới. - Khi nạp
fact_fee, mỗi dòng phí được join vàodim_customer_scd2theocustomer_idvà điều kiện thời gian (fee_datenằm trong[valid_from, valid_to)), rồi lưu đúngcustomer_skcủa phiên bản đang hiệu lực. - Báo cáo chỉ cần join fact với dim theo surrogate key và
GROUP BY tier— con số tự động "đúng theo thời điểm".
-- Doanh thu phí theo hạng, đúng hạng tại thời điểm phát sinh
SELECT d.tier, SUM(f.fee_amount) AS total_fee
FROM fact_fee f
JOIN dim_customer_scd2 d ON f.customer_sk = d.customer_sk -- đã chốt đúng phiên bản khi nạp fact
WHERE f.fee_date BETWEEN '2024-01-01' AND '2024-03-31'
GROUP BY d.tier
ORDER BY total_fee DESC;
Kết quả phản ánh trung thực: doanh thu từ khi chị Lan còn là Bạc được tính vào Bạc, từ khi lên Vàng tính vào Vàng. Đây là loại phân tích mà chỉ SCD2 mới cho được.
Unknown member & late-arriving dimension
Hai tình huống thực tế luôn phải chuẩn bị:
- Unknown / default member. Fact đôi khi tới mà business key chưa có trong dimension, hoặc bị null/lỗi. Không được để join rớt dòng fact. Giải pháp: chèn sẵn một dòng "Unknown" với surrogate key cố định (thường
-1hoặc0) và trỏ mọi tham chiếu mồ côi vào đó — báo cáo vẫn cân, và nhóm "Unknown" chính là tín hiệu chất lượng dữ liệu cần điều tra (xem Governance & Data Quality). - Late-arriving dimension. Bản ghi fact về trước khi bản ghi dimension tương ứng kịp về. Cách xử lý phổ biến: tạo trước một dòng dimension "khung" (chỉ có business key, các thuộc tính để trống hoặc "Unknown"), cấp surrogate key, cho fact trỏ vào; khi dữ liệu dimension thật về thì cập nhật lại dòng đó (đối với thuộc tính Type 1) hoặc chèn phiên bản đúng và chỉnh lại tham chiếu (đối với Type 2).
Ghi nhớ
- Surrogate key là nền móng của mọi dimension nghiêm túc: fact luôn trỏ theo surrogate key, không theo business key — đó là điều kiện để làm được SCD Type 2.
- SCD trả lời một câu hỏi: khi thuộc tính đổi, giữ hay bỏ lịch sử và giữ ở đâu. Nhớ trục chính: Type 0 giữ nguyên, Type 1 ghi đè, Type 2 thêm dòng, Type 3 thêm cột, Type 4 tách bảng, Type 6 hybrid (1+2+3).
- Mặc định Type 2 cho thuộc tính cần phân tích theo thời điểm; Type 1 cho sửa lỗi. Một bảng chiều thường trộn nhiều Type trên các cột khác nhau.
- Type 2 cần ba cột kỹ thuật:
valid_from,valid_to,is_current; nhớ lọcis_current = TRUEkhi chỉ cần ảnh chụp hiện tại, và join theo thời gian khi cần "as-of". - Conformed dimension để nhiều fact/bộ phận nói cùng một ngôn ngữ; role-playing để tái dùng một chiều (nhất là
dim_date) qua nhiều vai bằng view/alias. - Junk gom cờ lặt vặt; degenerate giữ mã giao dịch ngay trong fact; hierarchy/outrigger/bridge xử lý phân cấp và quan hệ nhiều-nhiều.
- Luôn dựng sẵn Unknown member và có phương án cho late-arriving dimension để pipeline không rớt dòng và báo cáo luôn cân.
- Trong dbt, vòng đời SCD2 được tự động hoá bằng snapshots — xem dbt Sources & snapshots.
Bài viết liên quan
CSV/JSON/Parquet/Avro, lưu trữ theo hàng vs cột, nén, và OLTP vs OLAP.
Data Engineering là gì, vai trò trong vòng đời dữ liệu, và bức tranh hệ sinh thái công cụ.
Vì sao xử lý phân tán, mô hình Spark (RDD/DataFrame), lazy evaluation, shuffle và tối ưu.
Đảm bảo chất lượng & minh bạch dữ liệu bằng dbt: data tests dựng sẵn (unique, not_null, relationships, accepted_values), singular test, unit test, và tài liệu tự sinh kèm lineage graph.