dbt 2 — Models & materializations

13 thg 7, 2026 5 lượt xem
#data-engineering
#dbt
#incremental
#ref
#materialization
#model

Model — đơn vị nhỏ nhất, và cũng là tất cả

bài tổng quan chúng ta đã thấy triết lý của dbt: bạn chỉ viết SELECT, dbt lo phần còn lại. Bài này đi vào chi tiết cái "đơn vị công việc" hiện thân cho triết lý đó — model — và câu hỏi kỹ thuật quan trọng nhất đi kèm nó: một model được vật chất hóa (materialize) vào warehouse như thế nào?

Định nghĩa gọn nhất: một model là một file .sql chứa đúng MỘT câu SELECT. Không hơn. Bạn không viết CREATE TABLE, không INSERT, không DROP, không MERGE. Bạn mô tả "dữ liệu kết quả trông như thế nào" bằng một câu truy vấn, và dbt tự sinh ra DDL/DML phù hợp để biến câu SELECT đó thành một quan hệ (relation) thật trong warehouse.

Hai quy ước tưởng nhỏ nhưng chi phối toàn bộ trải nghiệm:

  • Tên file = tên quan hệ tạo ra. File stg_orders.sql sẽ tạo ra một object tên stg_orders trong warehouse. Không có bước "đặt tên bảng" tách rời — đổi tên file là đổi tên bảng.
  • dbt tự lo DDL. Cùng một câu SELECT, tùy cấu hình, dbt có thể sinh ra CREATE VIEW ... AS (SELECT ...), hoặc CREATE TABLE ... AS (SELECT ...), hoặc một chuỗi MERGE/INSERT phức tạp cho incremental. Bạn không cần biết cú pháp DDL đặc thù của từng warehouse (Snowflake, BigQuery, Postgres... viết khác nhau); dbt trừu tượng hóa qua adapter.

Điều này nghe có vẻ chỉ là "tiện lợi", nhưng hệ quả sâu xa hơn nhiều: vì bạn chỉ mô tả kết quả chứ không mô tả cách tạo ra, dbt có toàn quyền quyết định chiến lược vật chất hóa. Đổi một model từ view sang table chỉ là đổi một dòng cấu hình — logic SELECT không phải sửa. Đây chính là điều tách biệt "logic nghiệp vụ" khỏi "chiến lược lưu trữ", và là lý do dbt mở rộng tốt.

ref() và source() — cách dbt biết thứ tự chạy

Nếu model chỉ là câu SELECT độc lập, dbt không hơn gì một thư mục chứa file .sql. Sức mạnh thật sự đến từ cách các model tham chiếu lẫn nhau. Và đây là quy tắc bất di bất dịch:

Không bao giờ hardcode tên bảng. Luôn dùng ref() để trỏ tới model khác, và source() để trỏ tới bảng nguồn thô.

ref() — trỏ tới model khác

Thay vì viết FROM analytics.stg_orders, bạn viết:

-- (minh hoạ)
select *
from {{ ref('stg_orders') }}

{{ ref('stg_orders') }} là một hàm Jinja (xem Jinja & macros). Khi biên dịch, dbt thay nó bằng tên bảng đầy đủ, đúng schema, đúng database — kể cả khi bạn đang chạy ở môi trường dev (schema riêng) hay prod (schema chung). Nhưng ref() làm hai việc quan trọng hơn cả việc điền tên:

  1. Dựng DAG (Directed Acyclic Graph). Mỗi lần model A dùng ref('B'), dbt ghi nhận "A phụ thuộc vào B". Toàn bộ các quan hệ phụ thuộc này tạo thành một đồ thị có hướng, không chu trình. dbt dùng DAG để tự sắp xếp thứ tự chạy (topological sort): B luôn được build trước A, không cần bạn khai báo thủ công. Với hàng trăm model, đây là điều không thể làm bằng tay.
  2. Lineage. Chính DAG đó cho bạn biết "con số này từ đâu ra" — truy ngược từ mart về tận nguồn. dbt docs sinh ra sơ đồ lineage tương tác từ dữ liệu này.

source() — trỏ tới bảng nguồn thô

Dữ liệu thô (do EL tool như Fivetran, Airbyte đổ vào) không phải là model dbt. Bạn khai báo chúng trong file .yml rồi tham chiếu bằng source():

# models/staging/jaffle_shop/_sources.yml (minh hoạ)
sources:
  - name: jaffle_shop        # tên nhóm nguồn
    database: raw
    schema: jaffle_shop
    tables:
      - name: orders
      - name: customers
-- stg_orders.sql (minh hoạ)
select
    id            as order_id,
    user_id       as customer_id,
    order_date,
    status
from {{ source('jaffle_shop', 'orders') }}

source() cũng đưa bảng nguồn vào DAG như một node "gốc". Ngoài ra, khai báo source còn mở khóa source freshness (kiểm tra dữ liệu nguồn có bị cũ không) — chủ đề của bài Sources & snapshots.

Quy tắc thực hành: staging model là nơi DUY NHẤT được phép dùng source(). Mọi tầng phía sau chỉ dùng ref(). Nhờ đó, khi bảng nguồn đổi tên/đổi schema, bạn chỉ sửa một chỗ (file source .yml).

Bốn kiểu materialization

Đây là phần cốt lõi. Materialization trả lời câu hỏi: dbt biến câu SELECT của bạn thành object gì trong warehouse, và bằng chiến lược nào? Cấu hình qua khóa materialized. dbt 1.x cung cấp bốn kiểu built-in.

view (mặc định)

-- (minh hoạ)
{{ config(materialized='view') }}
select ...

dbt sinh CREATE VIEW ... AS (câu SELECT của bạn). View không lưu dữ liệu — nó chỉ là một câu truy vấn được đặt tên và lưu lại. Mỗi lần có ai query view, warehouse tính toán lại logic bên dưới ngay lúc đó.

  • Ưu: không tốn dung lượng lưu trữ; luôn tươi (phản ánh dữ liệu nguồn mới nhất tức thì); dbt run cực nhanh vì chỉ tạo/thay định nghĩa view.
  • Nhược: query chậm nếu logic nặng, hoặc nếu view chồng lên view chồng lên view (mỗi lần query phải tính lại toàn bộ ngăn xếp bên dưới).
  • Khi nào dùng: mặc định. Đặc biệt hợp với staging (biến đổi nhẹ: đổi tên, ép kiểu) và các model không bị query trực tiếp nhiều lần.

table

-- (minh hoạ)
{{ config(materialized='table') }}
select ...

dbt sinh CREATE TABLE ... AS (câu SELECT). Kết quả được vật chất hóa — tính toán một lần lúc dbt run, ghi ra thành bảng thật.

  • Ưu: query nhanh (đọc dữ liệu đã tính sẵn, không phải tính lại).
  • Nhược: tốn dung lượng (một bản sao dữ liệu); rebuild lại toàn bộ mỗi lần dbt run — với biến đổi nặng thì tốn thời gian và tiền tính toán; dữ liệu chỉ "tươi" tới lần run gần nhất.
  • Khi nào dùng: model bị BI tool query nhiều lần (người dùng cần phản hồi nhanh); model có logic nặng được nhiều model hạ nguồn dùng chung (tính một lần, dùng nhiều lần); nói chung là các mart cuối cùng.

incremental

-- (minh hoạ, chi tiết ở bài incremental)
{{ config(materialized='incremental') }}
select ...
{% if is_incremental() %}
  where event_at > (select max(event_at) from {{ this }})
{% endif %}

Thay vì rebuild toàn bảng mỗi lần, incremental chỉ xử lý và chèn/cập nhật dữ liệu MỚI kể từ lần run trước. Lần chạy đầu tiên (hoặc khi --full-refresh) nó build đầy đủ như table; các lần sau chỉ đụng tới phần delta.

  • Ưu: giảm mạnh thời gian và chi phí build cho bảng rất lớn (log, event, giao dịch hàng trăm triệu dòng).
  • Nhược: phức tạp hơn hẳn — cần cấu hình khóa duy nhất (unique_key), điều kiện lọc delta, xử lý dữ liệu đến muộn (late-arriving), chiến lược incremental_strategy (append/merge/delete+insert...).
  • Khi nào dùng: chỉ khi cần — khi table đã trở nên quá chậm/quá đắt để rebuild. Đừng bắt đầu bằng incremental. Toàn bộ chi tiết (strategy, is_incremental(), this, xử lý schema drift) được mổ xẻ ở Incremental & hiệu năng.

ephemeral

-- (minh hoạ)
{{ config(materialized='ephemeral') }}
select ...

Ephemeral không tạo ra object nào trong warehouse. Thay vào đó, khi một model khác ref() tới nó, dbt nhúng (inline) logic của nó như một CTE vào câu truy vấn của model dùng nó (dbt đặt tiền tố nội bộ __dbt_cte__).

  • Ưu: giữ warehouse sạch (không sinh view/table trung gian thừa); tái sử dụng logic mà không tốn dung lượng.
  • Nhược: không thể query trực tiếp (nó không tồn tại độc lập); khó debug hơn (logic bị "tan" vào model khác); không hỗ trợ model contracts; nếu bị nhiều model dùng, logic bị lặp lại ở mỗi nơi (không tái dùng ở tầng SQL).
  • Khi nào dùng: biến đổi nhẹ, nằm sớm trong DAG, chỉ được 1–2 model hạ nguồn dùng, và không cần ai query trực tiếp.

Bảng so sánh

KiểuObject tạo raTốc độ queryLưu trữĐộ tươiChi phí buildDùng khi
viewViewChậm (tính lúc query)KhôngLuôn tươiRất thấpMặc định, staging, biến đổi nhẹ
tableTableNhanhBản sao đầy đủTới lần run gần nhấtCao (rebuild mỗi run)Mart, BI, logic nặng dùng chung
incrementalTableNhanhCập nhật một phầnTùy lịch chạyThấp sau lần đầuBảng rất lớn (event/log), khi table quá chậm
ephemeralKhông có(nhúng như CTE)KhôngLuôn tươiKhông riêngLogic trung gian nhẹ, 1–2 model dùng

Quy tắc ngón tay cái từ chính dbt: bắt đầu bằng view. Chuyển sang table khi query chậm. Chỉ dùng incremental khi table quá tốn kém để rebuild. Đừng tối ưu sớm.

Cấu hình model — ba tầng, ưu tiên tăng dần

Cùng một thuộc tính (materialized, tags, schema, alias, database...) có thể được đặt ở ba nơi. Nơi càng gần model, độ ưu tiên càng cao:

1. Trong dbt_project.yml — áp theo thư mục, ảnh hưởng hàng loạt. Đây là nơi khai báo quy ước chung cho từng tầng:

# dbt_project.yml (minh hoạ)
models:
  my_project:
    staging:
      +materialized: view       # toàn bộ staging là view
    intermediate:
      +materialized: ephemeral
    marts:
      +materialized: table      # toàn bộ mart là table
      +schema: analytics        # ghi vào schema riêng

Dấu + là cú pháp phân biệt "đây là config" với "đây là tên thư mục con".

2. Trong file property .yml — cấu hình theo từng model cụ thể, tách khỏi code SQL:

# models/marts/_marts.yml (minh hoạ)
models:
  - name: fct_revenue
    config:
      materialized: table
      tags: ["finance", "daily"]

3. Trong chính file .sql qua {{ config(...) }} — ưu tiên cao nhất, ghi đè mọi thứ:

-- fct_revenue.sql (minh hoạ)
{{ config(
    materialized='table',
    tags=['finance'],
    alias='revenue_daily'
) }}
select ...

Một vài thuộc tính hay dùng: tags (gắn nhãn để chạy chọn lọc theo nhóm), schema/alias (điều khiển schema và tên object đầu ra), database (ghi sang database khác). Chiến lược thực hành tốt: đặt mặc định theo tầng ở dbt_project.yml, chỉ dùng config() trong file khi một model cá biệt cần khác đi.

Phân tầng — kiến trúc chuẩn của một dự án dbt

dbt khuyến nghị (qua guide "how we structure") tổ chức model thành ba tầng, phản ánh hành trình dữ liệu đi từ source-conformed (định hình bởi hệ nguồn) sang business-conformed (định hình bởi nghiệp vụ):

  • Staging (stg_) — quan hệ 1–1 với bảng nguồn. Chỉ làm việc "vệ sinh": đổi tên cột theo quy ước, ép kiểu, chuẩn hóa nhẹ (ví dụ NULL rỗng, chuẩn hóa boolean). Đây là các "viên gạch nguyên tử". Đây cũng là nơi duy nhất dùng source(). Thường materialize là view.
  • Intermediate (int_) — các bước biến đổi có mục đích rõ ràng, chuẩn bị dữ liệu để ghép nối (join, pivot, tổng hợp trung gian). Nguyên tắc: mỗi biến đổi chỉ xảy ra ở đúng một nơi. Thường là ephemeral hoặc view. Không bị query trực tiếp bởi người dùng cuối.
  • Marts (fct_ / dim_) — các thực thể nghiệp vụ hoàn chỉnh, "rộng và giàu", phục vụ BI. Theo mô hình chiều (xem Warehouse modeling nếu có): fct_ cho bảng fact (sự kiện đo lường được — doanh thu, đơn hàng), dim_ cho bảng dimension (thực thể mô tả — khách hàng, sản phẩm). Thường là table (hoặc incremental nếu quá lớn).

Quy ước tiền tố (stg_, int_, fct_, dim_) không chỉ là thẩm mỹ — nó là tín hiệu thị giác cho biết dữ liệu đang ở giai đoạn nào của luồng biến đổi, giúp cả đội đọc DAG nhanh.

Use case thực tế — dựng lớp mart doanh thu

Giả sử ta cần một bảng fct_revenue cho dashboard tài chính, từ hai nguồn thô orderspayments.

Bước 1 — staging (view): làm sạch từng nguồn, 1–1.

-- models/staging/jaffle_shop/stg_orders.sql (minh hoạ)
{{ config(materialized='view') }}
select
    id          as order_id,
    user_id     as customer_id,
    order_date,
    status
from {{ source('jaffle_shop', 'orders') }}
-- models/staging/stripe/stg_payments.sql (minh hoạ)
{{ config(materialized='view') }}
select
    orderid                    as order_id,
    amount / 100.0             as amount_usd,   -- cent -> dollar
    payment_method
from {{ source('stripe', 'payments') }}
where status = 'success'

Bước 2 — mart (table), ghép qua ref():

-- models/marts/finance/fct_revenue.sql (minh hoạ)
{{ config(materialized='table', tags=['finance']) }}

with orders as (
    select * from {{ ref('stg_orders') }}
),
payments as (
    select * from {{ ref('stg_payments') }}
)

select
    o.order_date,
    o.customer_id,
    count(distinct o.order_id)  as num_orders,
    sum(p.amount_usd)           as total_revenue
from orders o
join payments p on o.order_id = p.order_id
where o.status = 'completed'
group by 1, 2

Chú ý: fct_revenue không hề biết tên vật lý thật của bảng nguồn — nó chỉ nói "tôi cần stg_ordersstg_payments". dbt đọc hai ref() này, suy ra staging phải chạy trước, rồi tự chạy đúng thứ tự. Đổi materialization của staging từ view sang table? fct_revenue không cần sửa một chữ.

Chạy chọn lọc — không phải lúc nào cũng chạy tất cả

dbt run chạy toàn bộ project. Trong thực tế bạn thường chỉ muốn chạy một nhánh. Dùng --select (viết tắt -s) cùng graph operators:

# (minh hoạ)
dbt run --select fct_revenue          # chỉ 1 model
dbt run --select +fct_revenue         # fct_revenue + TẤT CẢ ancestor (thượng nguồn)
dbt run --select stg_orders+          # stg_orders + TẤT CẢ descendant (hạ nguồn)
dbt run --select +fct_revenue+        # cả thượng lẫn hạ nguồn
dbt run --select 1+fct_revenue        # chỉ ancestor bậc 1
dbt run --select tag:finance          # mọi model gắn tag 'finance'
dbt run --select staging.jaffle_shop  # mọi model trong thư mục đó

Ý nghĩa các toán tử:

  • +model = model và mọi thứ nó phụ thuộc vào (chạy để có đủ nguyên liệu build nó).
  • model+ = model và mọi thứ phụ thuộc vào nó (chạy để cập nhật mọi thứ hạ nguồn khi model này đổi).
  • n+model / model+n = giới hạn số bậc lan tỏa.
  • @model = như model+ nhưng thêm cả ancestor của các descendant — hữu ích trong CI khi bảng hạ nguồn chưa tồn tại.
  • tag:, tên thư mục, path:... là các method chọn model theo tiêu chí khác.

--full-refresh — cờ đặc biệt buộc incremental model rebuild từ đầu (bỏ qua logic delta), đồng thời drop-and-recreate table nếu schema đổi:

dbt run --full-refresh --select fct_events

Ghi nhớ

  • Model = một file .sql = một câu SELECT. Tên file = tên object. dbt lo toàn bộ DDL.
  • Không bao giờ hardcode tên bảng. ref() cho model, source() cho nguồn thô. Chúng vừa điền tên đúng theo môi trường, vừa dựng DAG (thứ tự chạy + lineage). Staging là nơi duy nhất dùng source().
  • Bốn materialization: view (mặc định, nhẹ, luôn tươi, chậm khi query) → table (nhanh, rebuild mỗi run, tốn lưu trữ) → incremental (chỉ xử lý delta, cho bảng rất lớn, phức tạp) → ephemeral (nhúng như CTE, không tạo object).
  • Chiến lược chọn: bắt đầu bằng view; lên table khi query chậm; chỉ incremental khi table quá đắt để rebuild. Đừng tối ưu sớm.
  • Cấu hình ba tầng (ưu tiên tăng dần): dbt_project.yml theo thư mục → property .yml{{ config() }} trong file. Đặt mặc định theo tầng, override cá biệt trong file.
  • Phân tầng chuẩn: staging (stg_, view, 1–1 nguồn) → intermediate (int_, ephemeral/view) → marts (fct_/dim_, table). Tiền tố là tín hiệu giai đoạn biến đổi.
  • Chạy chọn lọc: --select với +/n+/@/tag:; --full-refresh để rebuild incremental từ đầu.

Bài tiếp theo: Sources & snapshots — khai báo nguồn, kiểm tra freshness, và bắt giữ thay đổi lịch sử (SCD) bằng snapshot.

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 7

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