DataOps nâng cao 26 — Mô hình hoá dữ liệu trong dbt: Kimball, OBT & Data Vault

13 thg 7, 2026 2 lượt xem
#data-engineering
#kimball
#data-vault
#data-modeling
#dataops

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ĩaVí 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àngfct_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_transactionsfct_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_skaccount_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ểmNhược điểm
Truy vấn cực đơn giản — không cần joinTrù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 joinKhông giữ lịch sử tốt như SCD2
Hợp với dashboard đọc-nhiều, ghi-hiếmTố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_account nố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_details chứa full_name, city theo 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 TableData Vault
Dễ hiểu / dễ dùng cho analystCaoRất caoThấp
Hiệu năng queryTốt (cần join)Rất tốt trên column-storeKém (phải xuất mart)
Chi phí bảo trìTrung bìnhCao khi dimension đổiCao (nhiều bảng)
Giữ lịch sử / auditSCD2 (tốt)YếuXuất sắc
Đa nguồn, schema hay đổiTrung bìnhKémXuất sắc
Phù hợp nhấtMart báo cáo, starBI/dashboard, warehouse cộtLớ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 & materializationsdbt cho ELT):

  • dim_ nhỏtable (rebuild toàn bộ mỗi lần, đơn giản, rẻ).
  • fct_ lớn, chỉ thêm mớiincremental để 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_null trên surrogate key của mỗi dimension (dim_customer.customer_sk).
  • unique trên khoá grain của fact — với fct_transactions grain 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:

  1. Lớp mart báo cáo → Kimball. Dựng dim_customer, dim_account, dim_date (conformed) và fact fct_transactions grain (tài khoản × ngày). Analyst tự join theo dim_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ằng generate_surrogate_key(['customer_id','account_no']); dim_customer gắ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ử).
  2. Dashboard giám đốc chi nhánh (nóng, đọc nhiều) → OBT. Xuất thêm obt_transactions phẳ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.
  3. 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ác sat_* 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ằng dbt_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ằng table, fct_ lớn bằng incremental; test dimensional: unique surrogate key, unique grain fact, relationships cho 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.

13 thg 7, 2026 8

Data Engineering là gì, vai trò trong vòng đời dữ liệu, và bức tranh hệ sinh thái công cụ.

13 thg 7, 2026 5

Vì sao xử lý phân tán, mô hình Spark (RDD/DataFrame), lazy evaluation, shuffle và tối ưu.

13 thg 7, 2026 5

Đả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.

13 thg 7, 2026 5