DataOps nâng cao 26 — Mô hình hoá dữ liệu trong dbt: Kimball, OBT & Data Vault
Mô hình hoá là quyết định kiến trúc, không phải sở thích
Bài này là góc thực thi/DataOps: hiện thực các phương pháp mô hình hoá trong lớp marts/ của một dự án dbt. Phần lý thuyết đầy đủ nằm ở series Data Modeling — Kimball & dimensional, Data Vault, OBT & metrics hiện đại. Ở đây ta không tranh luận triết học; ta bàn: viết model dbt thế nào, materialize ra sao, test cái gì, và khi nào chọn cách nào.
Điểm mấu chốt cần thấm trước: cách bạn chọn mô hình hoá quyết định trực tiếp cấu trúc thư mục marts/, cách đặt tên model, materialization, và bộ test. Ba trường phái phổ biến — Kimball (dimensional), One Big Table (OBT), Data Vault — không phải ba "phong cách viết SQL" mà là ba hợp đồng cấu trúc khác nhau về grain, khoá, và cách chịu thay đổi. Chọn sai ở lớp gold thì mọi thứ phía sau (BI, semantic layer, chi phí truy vấn) đều lệch theo.
Kimball / Dimensional trong dbt
Đây là mặc định của đa số mart báo cáo. Mô hình sao (star schema) chia dữ liệu thành fact (sự kiện đo được — giao dịch, dư nợ) và dimension (bối cảnh mô tả — khách hàng, tài khoản, thời gian). Trong dbt điều này thể hiện qua quy ước đặt tên rất rõ:
| Tiền tố | Ý nghĩa | Ví dụ ngân hàng |
|---|---|---|
dim_ | Bảng chiều, mô tả một thực thể | dim_customer, dim_account, dim_date |
fct_ | Bảng sự kiện, có grain rõ ràng | fct_transactions, fct_daily_balance |
Grain của fact phải được tuyên bố rõ. Grain là "một dòng đại diện cho cái gì". fct_transactions grain = một giao dịch; fct_transactions_daily grain = một (tài khoản × ngày). Trộn grain trong cùng bảng là lỗi thiết kế nặng nhất của dimensional modeling — nó làm mọi phép SUM/AVG sai âm thầm. Trong dbt, hãy ghi grain vào docstring model và test bằng unique trên khoá grain (xem Kiểm thử nâng cao).
Surrogate key. Fact không nên chứa khoá nghiệp vụ (business key) trực tiếp mà nên tham chiếu tới surrogate key của dimension — khoá kỹ thuật ổn định, không mang ý nghĩa. dbt có helper chuẩn:
-- Minh hoạ macro dbt (KHÔNG chạy được trên sandbox: là Jinja + dbt_utils)
select
{{ dbt_utils.generate_surrogate_key(['customer_id', 'account_no']) }} as account_sk,
...
generate_surrogate_key hash các cột đầu vào (MD5) thành một chuỗi ổn định. Ưu điểm: không phụ thuộc sequence của DB nguồn, tái tạo được (idempotent), và cô lập fact khỏi việc business key đổi kiểu.
SCD Type 2 bằng dbt snapshot. Dimension "chậm thay đổi" (Slowly Changing Dimension) — ví dụ khách chuyển thành phố, đổi phân khúc — cần giữ lịch sử. dbt giải quyết bằng snapshot: một cơ chế riêng, không phải model thường.
-- Minh hoạ dbt snapshot (KHÔNG chạy được: cú pháp Jinja/dbt, không phải SELECT thuần)
{% snapshot dim_customer_history %}
{{ config(
target_schema='snapshots',
unique_key='id',
strategy='check',
check_cols=['city', 'full_name']
) }}
select id, full_name, city, created_at from {{ source('core', 'customers') }}
{% endsnapshot %}
Mỗi lần dbt snapshot chạy, nếu city của một khách đổi, dbt tự thêm dòng mới với dbt_valid_from/dbt_valid_to, khép dòng cũ lại. Đây là SCD2 "miễn phí" — không phải viết MERGE tay. Dimension hiện tại thì dbt_valid_to IS NULL.
Conformed dimension. dim_customer, dim_date phải dùng chung cho mọi fact — cùng khoá, cùng thuộc tính. Đây là điều làm star schema kết nối được nhiều mart: fct_transactions và fct_loan_balance cùng join dim_customer thì mới so sánh chéo được. Trong dbt, conformed dimension chỉ đơn giản là một model được nhiều fact ref() tới — DAG tự đảm bảo tính nhất quán.
Block 1 — dim_customer (dimension khách hàng)
Dimension thực tế thường làm giàu thực thể bằng thuộc tính tổng hợp phái sinh (derived attribute): tổng số tài khoản, tổng dư, phân khúc. Đây là logic điển hình trong một dim_customer dbt model.
-- ▶ Chạy được
WITH acct AS (
SELECT
customer_id,
COUNT(*) AS num_accounts,
ROUND(SUM(balance)::numeric, 2) AS total_balance
FROM accounts
GROUP BY customer_id
)
SELECT
c.id AS customer_sk,
c.full_name,
c.city,
c.created_at AS onboarded_at,
COALESCE(a.num_accounts, 0) AS num_accounts,
COALESCE(a.total_balance, 0) AS total_balance,
CASE
WHEN COALESCE(a.total_balance, 0) >= 1000000000 THEN 'Priority'
WHEN COALESCE(a.total_balance, 0) >= 100000000 THEN 'Mass Affluent'
ELSE 'Mass'
END AS segment
FROM customers c
LEFT JOIN acct a ON a.customer_id = c.id
ORDER BY total_balance DESC;
LEFT JOIN giữ cả khách chưa có tài khoản (không rơi mất dòng dimension). Cột segment là ví dụ derived attribute phân khúc theo tổng dư. Khoá join đúng: accounts.customer_id → customers.id. Trong dbt thật, khối acct sẽ là một ref('int_customer_accounts') riêng.
Block 2 — fct_transactions (fact grain ngày × tài khoản)
Fact bảng lớn thường được cuộn (roll-up) về grain phù hợp báo cáo. Ở đây grain = (tài khoản × ngày) — một dòng cho mỗi tài khoản mỗi ngày có phát sinh, chứa các độ đo (measure) cộng dồn được.
-- ▶ Chạy được
SELECT
a.customer_id AS customer_sk,
t.account_id AS account_sk,
DATE(t.created_at) AS txn_date,
COUNT(*) AS txn_count,
ROUND(SUM(t.amount)::numeric, 2) AS total_amount,
ROUND(AVG(t.amount)::numeric, 2) AS avg_amount,
ROUND(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END)::numeric, 2) AS credit_amount,
ROUND(SUM(CASE WHEN t.amount < 0 THEN t.amount ELSE 0 END)::numeric, 2) AS debit_amount
FROM transactions t
JOIN accounts a ON a.id = t.account_id
GROUP BY a.customer_id, t.account_id, DATE(t.created_at)
ORDER BY txn_date DESC, total_amount DESC;
Đây là fact additive điển hình: mọi measure (txn_count, total_amount, credit_amount) đều cộng được dọc theo mọi dimension. customer_sk và account_sk là foreign key trỏ về dim_customer/dim_account. Lưu ý ép ::numeric trước ROUND — vì AVG/SUM trả double precision và PostgreSQL không có ROUND(double, int). Khoá join đúng: transactions.account_id → accounts.id.
One Big Table (OBT): denormalize thành bảng rộng
OBT đi ngược Kimball: thay vì tách fact/dimension rồi để BI join lúc chạy, ta denormalize sẵn mọi thứ vào một bảng rộng — mỗi dòng giao dịch đã kèm luôn tên khách, thành phố, số tài khoản. Không còn join lúc query.
| Ưu điểm | Nhược điểm |
|---|---|
| Truy vấn cực đơn giản — không cần join | Trùng lặp dữ liệu (tên khách lặp triệu lần) |
| Rất nhanh trên column-store (chỉ đọc cột cần) | Khó bảo trì: đổi tên khách phải rebuild cả bảng |
| Analyst/BI tự phục vụ dễ, ít lỗi join | Không giữ lịch sử tốt như SCD2 |
| Hợp với dashboard đọc-nhiều, ghi-hiếm | Tốn dung lượng lưu trữ |
Khi nào OBT hợp lý: khi warehouse là column-store (cột) như BigQuery, ClickHouse, Snowflake, Redshift — nơi engine chỉ đọc đúng cột cần và nén trùng lặp rất tốt, nên nhược điểm "trùng dữ liệu" gần như biến mất về chi phí quét. Ngược lại trên PostgreSQL/MySQL (row-store), OBT rộng thường phản tác dụng. OBT cũng là đầu vào lý tưởng cho công cụ BI không giỏi join, và cho semantic layer khi muốn một nguồn phẳng, dễ định nghĩa metric.
Block 3 — OBT khách-giao dịch (denormalize 3 bảng)
Đây là hình dạng một obt_transactions — mỗi dòng giao dịch đã "phẳng" đủ ngữ cảnh, sẵn sàng cho BI.
-- ▶ Chạy được
SELECT
t.id AS transaction_id,
t.created_at AS txn_ts,
DATE(t.created_at) AS txn_date,
t.kind AS txn_kind,
ROUND(t.amount::numeric, 2) AS amount,
a.account_no,
a.currency,
ROUND(a.balance::numeric, 2) AS account_balance,
c.id AS customer_id,
c.full_name AS customer_name,
c.city AS customer_city
FROM transactions t
JOIN accounts a ON a.id = t.account_id
JOIN customers c ON c.id = a.customer_id
ORDER BY t.created_at DESC
LIMIT 500;
Chú ý: customer_name/customer_city được lấy qua hai lần JOIN (transactions → accounts → customers) vì sandbox không có customer_name sẵn ở transactions/accounts. Bảng này lặp tên khách ở mọi dòng giao dịch — đó chính là bản chất denormalize của OBT. Chuỗi join đúng: transactions.account_id → accounts.id, accounts.customer_id → customers.id.
Data Vault trong dbt
Data Vault là trường phái thứ ba, sinh ra cho môi trường nhiều nguồn thay đổi liên tục và bắt buộc audit — đúng bối cảnh ngân hàng lõi tích hợp corebanking, thẻ, tín dụng, CRM. Nó tách dữ liệu thành ba loại thực thể:
- Hub — danh sách business key duy nhất của một thực thể (
hub_customer= tập các CIF). Không thuộc tính mô tả, chỉ key + metadata tải. - Link — quan hệ giữa các hub (
link_customer_accountnối khách ↔ tài khoản). Chỉ chứa các khoá. - Satellite — mọi thuộc tính mô tả và lịch sử của chúng, treo vào hub hoặc link (
sat_customer_detailschứafull_name,citytheo thời gian).
Điểm mạnh: mỗi nguồn ghi vào satellite riêng, chỉ chèn không cập nhật (insert-only), nên toàn bộ lịch sử và nguồn gốc được giữ nguyên vẹn — lý tưởng cho truy vết và audit. Điểm yếu: phức tạp hơn hẳn. Một thực thể đơn giản có thể nở thành 3-5 bảng; và Data Vault không dùng để query trực tiếp — bạn vẫn phải dựng một lớp "information mart" theo Kimball/OBT phía trên để BI dùng.
Trong dbt, không viết hub/link/satellite bằng tay mà dùng package chuyên dụng: dbtvault (nay đổi tên thành automate-dv). Nó cung cấp macro hub(), link(), sat() sinh SQL insert-only chuẩn từ vài dòng cấu hình:
-- Minh hoạ automate-dv (KHÔNG chạy được: macro Jinja, không phải SELECT thuần)
{{ automate_dv.hub(
src_pk='CUSTOMER_HK',
src_nk='customer_id',
src_ldts='load_datetime',
src_source='record_source',
source_model='stg_customers'
) }}
Khi nào Data Vault đáng công: khi bạn tích hợp nhiều hệ nguồn vào một lớp chung, các nguồn đổi lược đồ thường xuyên, và có yêu cầu pháp lý về audit/truy vết (rất phổ biến ở ngân hàng). Với một mart báo cáo đơn từ một nguồn, Data Vault là over-engineering — Kimball đủ và rẻ hơn nhiều.
So sánh & chọn mô hình
| Tiêu chí | Kimball (Star) | One Big Table | Data Vault |
|---|---|---|---|
| Dễ hiểu / dễ dùng cho analyst | Cao | Rất cao | Thấp |
| Hiệu năng query | Tốt (cần join) | Rất tốt trên column-store | Kém (phải xuất mart) |
| Chi phí bảo trì | Trung bình | Cao khi dimension đổi | Cao (nhiều bảng) |
| Giữ lịch sử / audit | SCD2 (tốt) | Yếu | Xuất sắc |
| Đa nguồn, schema hay đổi | Trung bình | Kém | Xuất sắc |
| Phù hợp nhất | Mart báo cáo, star | BI/dashboard, warehouse cột | Lớp tích hợp lõi, audit |
Quy tắc thực dụng tại lớp gold của kiến trúc medallion:
Trong thực tế đa số đội phối hợp: dựng star schema Kimball ở gold làm chuẩn, rồi xuất thêm vài bảng OBT cho những dashboard nóng cần tốc độ. Data Vault chỉ chèn vào giữa silver–gold khi bài toán tích hợp/audit đủ lớn để trả giá phức tạp.
Hiện thực hoá: materialization & incremental
Chọn mô hình xong phải chọn cách materialize (xem dbt models & materializations và dbt cho ELT):
dim_nhỏ →table(rebuild toàn bộ mỗi lần, đơn giản, rẻ).fct_lớn, chỉ thêm mới →incrementalđể không quét lại lịch sử:
-- Minh hoạ config incremental dbt (KHÔNG chạy được: Jinja + is_incremental)
{{ config(materialized='incremental', unique_key='transaction_id') }}
select ... from {{ ref('stg_transactions') }}
{% if is_incremental() %}
where created_at > (select max(created_at) from {{ this }})
{% endif %}
Với fct_transactions triệu dòng/ngày, incremental là bắt buộc — full-refresh mỗi lần vừa chậm vừa tốn tiền. Ngược lại, OBT rộng thường cũng incremental theo ngày, còn Data Vault satellite bản chất đã insert-only nên hợp incremental append tự nhiên.
Test cho model dimensional
Star schema có bộ test đặc thù cấu trúc, khác test dữ liệu thông thường (xem Kiểm thử nâng cao):
unique+not_nulltrên surrogate key của mỗi dimension (dim_customer.customer_sk).uniquetrên khoá grain của fact — vớifct_transactionsgrain ngày, đó là combo(account_sk, txn_date); đảm bảo không nhân đôi grain.relationships— kiểm mọi FK trong fact đều tồn tại ở dimension (referential integrity):fct_transactions.customer_sk → dim_customer.customer_sk. Đây là test quan trọng nhất của dimensional model — nó bắt "fact trỏ tới dimension không tồn tại" (orphan fact), lỗi làm báo cáo hụt số âm thầm.
# Minh hoạ schema.yml (không phải SQL chạy được)
models:
- name: fct_transactions
columns:
- name: customer_sk
tests:
- not_null
- relationships:
to: ref('dim_customer')
field: customer_sk
Use case thực tế
Bối cảnh: NCB cần mart báo cáo giao dịch phục vụ khối bán lẻ và số liệu gửi NHNN. Nguồn: corebanking (khách, tài khoản, giao dịch).
Quyết định mô hình:
- Lớp mart báo cáo → Kimball. Dựng
dim_customer,dim_account,dim_date(conformed) và factfct_transactionsgrain (tài khoản × ngày). Analyst tự join theodim_dateđể ra báo cáo tháng/quý mà không cần hiểu cấu trúc nguồn. Surrogate key bằnggenerate_surrogate_key(['customer_id','account_no']);dim_customergắn snapshot SCD2 để giữ lịch sử phân khúc — khi một khách lên "Priority" tháng 6, báo cáo tháng 3 vẫn thấy họ là "Mass" (đúng lịch sử). - Dashboard giám đốc chi nhánh (nóng, đọc nhiều) → OBT. Xuất thêm
obt_transactionsphẳng từ star, materialize incremental theo ngày. Vì kho báo cáo chạy trên column-store, bảng rộng này trả dashboard dưới 1 giây mà không phải join runtime. - Lớp tích hợp lõi (khi thêm nguồn thẻ + CRM) → cân nhắc Data Vault. Khi cần gộp 3 nguồn có CIF khác nhau và bị kiểm toán nội bộ yêu cầu truy vết "số liệu này lấy từ đâu, lúc nào", nhóm dựng
hub_customer+ cácsat_*theo nguồn bằng automate-dv, rồi xuất ngược ra star ở gold. Chỉ làm bước này khi giá trị audit vượt chi phí phức tạp — không áp dụng cho mọi mart.
Kết quả: một kiến trúc phối hợp — Kimball làm xương sống, OBT tăng tốc điểm nóng, Data Vault dự phòng cho tích hợp/audit — thay vì ép mọi thứ vào một khuôn.
Ghi nhớ
- Cách mô hình hoá quyết định cấu trúc
marts/: grain, khoá, materialization, và bộ test đều thay đổi theo lựa chọn. - Kimball:
fct_/dim_, grain fact tuyên bố rõ, surrogate key bằngdbt_utils.generate_surrogate_key, SCD2 bằng dbt snapshot, conformed dimension dùng chung nhiều fact. - OBT: denormalize thành bảng rộng — đơn giản và nhanh trên column-store (BigQuery/ClickHouse), nhưng trùng lặp và khó bảo trì; hợp dashboard đọc-nhiều.
- Data Vault: hub/link/satellite, insert-only, xuất sắc cho đa nguồn + audit (ngân hàng), nhưng phức tạp; dùng package automate-dv và vẫn phải xuất mart phía trên.
- Ở lớp gold nên phối hợp: Kimball làm chuẩn, OBT cho điểm nóng, Data Vault khi tích hợp/audit đủ lớn.
- Materialize
dim_nhỏ bằngtable,fct_lớn bằngincremental; test dimensional:uniquesurrogate key,uniquegrain fact,relationshipscho FK (bắt orphan fact).
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.