dbt 6 — Incremental models & hiệu năng

13 thg 7, 2026 4 lượt xem
#data-engineering
#dbt
#performance
#incremental
#merge
#partition

Vấn đề: khi rebuild trở nên đắt đỏ

bài Models & materializations chúng ta đã gặp bốn kiểu vật chất hóa và một lời khuyên: bắt đầu bằng view, lên table khi query chậm, chỉ dùng incremental khi table quá đắt để rebuild. Bài này đi sâu vào nhánh khó nhất và cũng đáng giá nhất đó.

Hãy hình dung một bảng sự kiện thật: bảng giao dịch của một ngân hàng, mỗi ngày thêm vài chục triệu dòng, tích lũy nhiều năm thành hàng tỷ dòng. Với materialization table, mỗi lần dbt run là một câu CREATE TABLE ... AS (SELECT ...) quét lại toàn bộ dữ liệu lịch sử — kể cả những dòng đã tính hôm qua, tháng trước, năm ngoái, không hề thay đổi. Trên warehouse tính tiền theo lượng dữ liệu quét (BigQuery) hay theo thời gian compute chạy (Snowflake), việc quét lại tỷ dòng mỗi giờ là một sự lãng phí trực tiếp thành hóa đơn.

Ý tưởng của incremental model đơn giản đến mức hiển nhiên: dữ liệu cũ đã xử lý rồi thì đừng đụng vào nữa. Mỗi lần chạy, chỉ lấy phần mới hoặc vừa thay đổi kể từ lần chạy trước, biến đổi phần nhỏ đó, rồi ghép vào bảng đích đã có sẵn. Thay vì "xây lại cả tòa nhà mỗi sáng", ta chỉ "xây thêm tầng mới". Kết quả: thời gian chạy giảm từ hàng giờ xuống vài phút, chi phí giảm tương ứng.

Cái giá phải trả là độ phức tạp. View và table là "khai báo thuần túy": bạn tả kết quả, dbt lo. Incremental thì bạn phải tự trả lời hai câu hỏi mà trước đó dbt lo hộ: "dòng nào là mới?""khi một dòng cũ thay đổi thì xử lý ra sao?". Trả lời sai là trùng dữ liệu hoặc thiếu dữ liệu — những lỗi âm thầm, khó phát hiện. Đây là lý do incremental không nên là lựa chọn mặc định.

Cơ chế: một câu SELECT chạy hai kiểu

Một incremental model vẫn là một file .sql chứa một câu SELECT (đúng triết lý dbt). Điểm khác biệt nằm ở chỗ cùng một file được biên dịch thành hai câu SQL khác nhau tùy tình huống:

  • Lần chạy đầu tiên (bảng đích chưa tồn tại): dbt sinh CREATE TABLE ... AS (SELECT ...) — build full, quét toàn bộ nguồn, đúng như một table thường.
  • Các lần sau (bảng đã tồn tại): dbt sinh một chuỗi DML — thường là tạo một bảng tạm chứa dữ liệu mới rồi MERGE/INSERT vào bảng đích. Lúc này phần code trong khối {% if is_incremental() %} mới có tác dụng, thu hẹp câu SELECT chỉ còn dòng mới.

Điều khiển toàn bộ hành vi này là macro is_incremental(). Nó trả về True khi cả ba điều kiện cùng đúng:

  1. Bảng đích đã tồn tại như một table trong database.
  2. Không truyền cờ --full-refresh.
  3. Model được cấu hình materialized='incremental'.

Quy tắc vàng: câu SQL phải hợp lệ dù is_incremental()True hay False. Khối {% if is_incremental() %} chỉ được thêm điều kiện lọc, không được làm câu query vỡ khi chạy full.

Sơ đồ luồng tổng thể:

Chú ý biến đặc biệt {{ this }} — nó trỏ tới chính bảng đích hiện có. Ta hỏi "dữ liệu trong bảng đích đang mới đến mốc nào" bằng cách đọc max(ts) từ {{ this }}, rồi chỉ lấy nguồn có mốc lớn hơn.

unique_key: chống trùng và cho phép cập nhật

Nếu bạn không khai unique_key, incremental model hành xử append-only: mỗi lần chạy nó chèn thêm các dòng thỏa điều kiện lọc. Nếu một dòng lọt qua bộ lọc hai lần (ví dụ dữ liệu nguồn bị nạp lại), nó bị chèn hai lần → trùng.

unique_key là câu trả lời cho câu hỏi "hai dòng này có phải cùng một thực thể không?". Khi có nó, với chiến lược merge/delete+insert, dbt sẽ: nếu key đã tồn tại trong bảng đích → update/thay thế dòng cũ; nếu key mới → insert dòng mới. Nhờ đó chạy lại nhiều lần vẫn cho cùng kết quả (idempotent).

-- (minh hoạ) key đơn
{{ config(materialized='incremental', unique_key='order_id') }}

-- (minh hoạ) key phức hợp (composite)
{{ config(materialized='incremental', unique_key=['user_id', 'session_number']) }}

Lưu ý sống còn: cột trong unique_key không được chứa NULL. Trên nhiều adapter, NULL != NULL khiến việc so khớp key sai lệch, sinh trùng. Nếu key có thể null, hãy coalesce() hoặc tạo surrogate key (băm nhiều cột thành một khóa duy nhất, không null).

Bốn chiến lược incremental — và khi nào dùng cái nào

Cách dbt ghép dữ liệu mới vào bảng đích do incremental_strategy quyết định. Không phải warehouse nào cũng hỗ trợ mọi chiến lược; mỗi adapter có một mặc định riêng.

merge — mặc định của phần lớn warehouse

MERGE là một câu SQL upsert: với mỗi dòng mới, nếu unique_key khớp một dòng đích thì UPDATE, không khớp thì INSERT. Đây là mặc định trên Snowflake, BigQuery, Databricks/Spark, Postgres, Redshift.

  • Dùng khi: cần giữ bảng luôn cập nhật (bản ghi có thể sửa sau khi tạo), có unique_key đáng tin.
  • Điểm yếu: MERGE có thể phải quét toàn bộ bảng đích để tìm key khớp → đắt trên bảng cực lớn. Không có unique_key, merge thoái hóa thành append.
  • Tinh chỉnh: merge_update_columns=[...] (chỉ update các cột này) hoặc merge_exclude_columns=[...] (loại các cột khỏi update, ví dụ giữ nguyên created_at).

insert_overwrite — ghi đè theo partition

Thay vì so khớp từng dòng, chiến lược này ghi đè trọn các partition xuất hiện trong dữ liệu mới. Nếu batch mới chứa dữ liệu của ngày 2026-06-30, dbt xóa sạch partition 2026-06-30 cũ rồi ghi lại toàn bộ.

  • Dùng khi: bảng đã partition_by (thường theo ngày), và mỗi lần chạy bạn tính lại trọn vẹn một vài ngày gần nhất. Rất phù hợp BigQuery và Spark.
  • Ưu điểm: không cần quét cả bảng đích, không cần MERGE — chỉ đụng vào đúng những partition liên quan. Cực rẻ trên BigQuery vì tránh quét lịch sử. Xem BigQuery partitioning & clustering để hiểu cơ chế pruning.
  • Lưu ý: BigQuery không hỗ trợ delete+insert; hai lựa chọn chính là mergeinsert_overwrite.

append — chỉ thêm, không khử trùng

Chèn thẳng dòng mới, không kiểm tra trùng, không update. Nhanh và rẻ nhất, nhưng chỉ an toàn khi bạn chắc chắn dòng mới không bao giờ lọt qua bộ lọc hai lần và không bao giờ cần sửa. Hợp với log/sự kiện chỉ-thêm (append-only), immutable.

delete+insert — thay thế theo key khi không có MERGE

Xóa các dòng đích có unique_key trùng với dữ liệu mới, rồi insert lại. Kết quả tương đương merge về mặt cập nhật, nhưng hiện thực bằng DELETE + INSERT — dùng khi adapter không hỗ trợ MERGE, hoặc khi unique_key không thật sự duy nhất (delete+insert vẫn thay sạch cả nhóm). BigQuery không hỗ trợ chiến lược này.

Bảng chọn nhanh:

Tình huốngChiến lược nên dùng
Bản ghi có thể sửa, có key tin cậy, warehouse thườngmerge
Bảng partition theo ngày, tính lại vài ngày gần nhất (BigQuery/Spark)insert_overwrite
Log/sự kiện immutable, không bao giờ trùngappend
Cần thay theo key nhưng không dùng được MERGEdelete+insert

Ngoài bốn chiến lược trên, dbt còn có microbatch (chia dữ liệu chuỗi thời gian thành nhiều lô theo cửa sổ thời gian, chạy song song, bền hơn khi lỗi) trên các adapter như Snowflake/Spark/Databricks. Đây là hướng nâng cao cho bảng chuỗi thời gian rất lớn; nắm chắc bốn chiến lược cơ bản trước đã.

Partition & cluster: nền tảng của incremental rẻ

Trên BigQuery và Snowflake, incremental thực sự tiết kiệm chỉ khi warehouse không phải quét cả bảng. Đó là vai trò của partition và cluster:

  • partition_by: chia bảng vật lý theo một cột (thường là ngày). Khi query có WHERE date = ..., warehouse chỉ đọc đúng partition đó — gọi là partition pruning. Đây cũng là điều làm insert_overwrite khả thi: ghi đè theo đơn vị partition.
  • cluster_by: sắp xếp/gom dữ liệu trong partition theo cột hay lọc (ví dụ customer_id) để giảm thêm lượng đọc.
# models/marts/fct_transactions.yml (minh hoạ) — cấu hình cho BigQuery
models:
  - name: fct_transactions
    config:
      materialized: incremental
      incremental_strategy: insert_overwrite
      partition_by:
        field: txn_date
        data_type: date
        granularity: day
      cluster_by: ['customer_id']

Kết hợp insert_overwrite + partition_by theo ngày là mẫu kinh điển trên BigQuery: mỗi run chỉ đọc nguồn của vài ngày gần nhất và chỉ ghi đè đúng những partition đó.

Late-arriving data & reprocessing

Bộ lọc ngây thơ WHERE ts > (select max(ts) from {{ this }}) giả định dữ liệu luôn đến đúng thứ tự thời gian. Thực tế không vậy: một giao dịch phát sinh hôm nay nhưng lên hệ thống nguồn ba ngày sau (late-arriving). Nếu max(ts) trong bảng đích đã là hôm nay, dòng trễ đó không bao giờ lọt bộ lọc → mất dữ liệu âm thầm.

Giải pháp là cửa sổ lookback: đừng lọc từ đúng max(ts), mà lùi lại vài ngày để "xét lại" vùng có thể còn dữ liệu trễ. Kết hợp với unique_key, việc xử lý lại vùng chồng lấn này là an toàn — dòng đã có sẽ được update thay vì trùng.

-- (minh hoạ) incremental merge có lookback
{{
  config(
    materialized='incremental',
    unique_key='txn_id',
    incremental_strategy='merge',
    on_schema_change='append_new_columns'
  )
}}

with source as (

    select
        txn_id,
        customer_id,
        txn_amount,
        txn_ts,
        cast(txn_ts as date) as txn_date
    from {{ source('core_banking', 'transactions') }}

    {% if is_incremental() %}
    -- Lùi 3 ngày để bắt dữ liệu đến trễ; unique_key đảm bảo không trùng
    where txn_ts >= (
        select dateadd(day, -3, max(txn_ts)) from {{ this }}
    )
    {% endif %}

)

select
    txn_id,
    customer_id,
    txn_amount,
    txn_ts,
    txn_date
from source

Vài công cụ đi kèm reprocessing:

  • --full-refresh: buộc bỏ qua logic delta, rebuild bảng từ đầu. Dùng khi logic biến đổi thay đổi, hoặc nghi ngờ dữ liệu lịch sử sai: dbt run --full-refresh --select fct_transactions.
  • on_schema_change: xử lý khi cột của model đổi giữa các lần chạy. ignore (mặc định — cột mới không được thêm vào bảng đích), fail (báo lỗi khi schema lệch), append_new_columns (thêm cột mới, không xóa cột cũ), sync_all_columns (thêm cột mới xóa cột không còn). Lưu ý: không tùy chọn nào backfill giá trị cho cột mới ở các dòng cũ.
  • incremental_predicates: giới hạn vùng quét trên bảng đích khi MERGE, dùng alias DBT_INTERNAL_DEST (bảng cũ) và DBT_INTERNAL_SOURCE (dữ liệu mới). Ví dụ chỉ cho MERGE xét 7 ngày gần nhất của bảng đích thay vì quét toàn bộ — cứu tinh cho bảng tỷ dòng.
# (minh hoạ) giới hạn vùng MERGE để không quét cả bảng tỷ dòng
models:
  - name: fct_transactions
    config:
      materialized: incremental
      unique_key: txn_id
      incremental_strategy: merge
      incremental_predicates:
        - "DBT_INTERNAL_DEST.txn_date > dateadd(day, -7, current_date)"

Use case thực tế: bảng giao dịch tỷ dòng

Đặt bài toán cụ thể: fct_transactions — mọi giao dịch của ngân hàng, ~2 tỷ dòng, thêm ~30 triệu dòng/ngày, cần cập nhật mỗi giờ để dashboard vận hành. Dữ liệu có thể đến trễ tối đa 2 ngày; một số giao dịch bị điều chỉnh (adjust) trong vòng 3 ngày.

Kiến trúc incremental hợp lý:

  1. Chiến lược: merge theo unique_key='txn_id' (vì có điều chỉnh → cần update, không chỉ append).
  2. Partition theo txn_date (ngày) để mọi truy vấn dashboard đều pruning được, và để incremental_predicates giới hạn vùng merge.
  3. Lookback 3 ngày ở bộ lọc nguồn để bắt dữ liệu trễ và giao dịch điều chỉnh.
  4. incremental_predicates giới hạn MERGE chỉ đụng 7 ngày cuối của bảng đích — nếu không, mỗi giờ dbt sẽ MERGE vào toàn bộ 2 tỷ dòng, đánh sập lợi ích incremental. Đây là chi tiết dễ bị bỏ quên nhất và cũng đắt nhất.
  5. on_schema_change='append_new_columns' để thêm chỉ tiêu mới không cần full-refresh cả bảng tỷ dòng.

Kết quả điển hình của mẫu này: mỗi run chỉ quét nguồn ~3 ngày (~90 triệu dòng) và merge vào ~7 ngày cuối của đích, thay vì đụng 2 tỷ dòng. Thời gian và chi phí một lần chạy gần như hằng số theo lượng dữ liệu mới, không tăng theo lịch sử tích lũy — đó chính là mục tiêu của incremental.

Trên BigQuery, cùng bài toán này thường chuyển sang insert_overwrite theo partition ngày: mỗi run tính lại trọn 3 ngày gần nhất và ghi đè đúng 3 partition đó. Không có MERGE quét cả bảng, chi phí quét gần như bằng lượng dữ liệu 3 ngày.

Bẫy thường gặp

  • Quên unique_key → trùng. Không có key, incremental là append-only; mọi lần dữ liệu lọt bộ lọc hai lần đều nhân đôi dòng. Nếu bạn cố ý append (log immutable), hãy ghi rõ điều đó.
  • NULL trong unique_key. NULL != NULL phá vỡ khớp key. Luôn coalesce() hoặc dùng surrogate key.
  • Bộ lọc is_incremental() sai. > bỏ sót dòng bằng đúng max(ts); >= không lookback thì bỏ sót dữ liệu trễ. Đi kèm unique_key, hãy nghiêng về lọc rộng hơn cần thiết (lookback) rồi để merge khử trùng — thiếu dữ liệu nguy hiểm hơn xử lý dư.
  • SQL vỡ khi chạy full. Đừng để code trong {% if is_incremental() %} là thứ mà câu query cần cả khi chạy lần đầu. Khối này chỉ thêm điều kiện lọc.
  • MERGE quét cả bảng. Trên bảng cực lớn, merge không giới hạn sẽ quét toàn bộ đích mỗi run. Dùng incremental_predicates (hoặc insert_overwrite theo partition).
  • Đổi schema mà không xử lý. Thêm/bớt cột với on_schema_change='ignore' khiến cột mới không xuất hiện, âm thầm. Chọn tùy chọn phù hợp, hoặc --full-refresh chủ động khi đổi lớn.
  • Incremental hóa quá sớm. View/table đủ dùng thì đừng gánh phức tạp của incremental. So sánh: view rẻ lưu trữ nhưng chậm query và tính lại mỗi lần đọc; table nhanh query nhưng rebuild toàn bộ mỗi run; incremental chỉ xử lý delta nhưng phải tự lo đúng-sai của bộ lọc. Chỉ trèo lên incremental khi rebuild table đã thực sự đau.

Hiệu năng chung ngoài incremental

Incremental là đòn bẩy lớn nhất, nhưng vài thói quen sau giúp mọi model rẻ hơn:

  • Chỉ chọn cột cần. SELECT * trên bảng cột (columnar warehouse) kéo theo mọi cột, đội chi phí quét. Liệt kê đúng cột dùng.
  • Tránh ORDER BY không cần thiết ở bước trung gian — sắp xếp tốn compute mà thường vô ích cho model downstream (đã có cluster_by lo việc gom dữ liệu).
  • Tận dụng partition của warehouse: luôn để điều kiện lọc chạm được cột partition để pruning phát huy.
  • Đẩy bộ lọc lên nguồn: lọc dữ liệu ở CTE đọc nguồn (trong khối is_incremental()) chứ không lọc sau khi đã join/tính toán nặng.
  • Giám sát chi phí: theo dõi bytes quét (BigQuery) hay thời gian/credit (Snowflake) theo model để phát hiện model bất ngờ đắt lên — thường là dấu hiệu incremental đã "gãy" và đang quét full mỗi run.

Nhiều mẫu ở trên (lookback, config động theo môi trường) dựa vào Jinja — nếu cần ôn lại, xem Jinja & macros.

Ghi nhớ

  • Vì sao incremental: bảng sự kiện cực lớn thì rebuild mỗi run quá đắt; incremental chỉ xử lý phần mới/đổi, giữ chi phí gần như hằng số theo lượng dữ liệu mới.
  • Cơ chế: materialized='incremental'; lần đầu build full, các lần sau chỉ chạy phần trong {% if is_incremental() %} (thường WHERE ts > (select max(ts) from {{ this }})). SQL phải hợp lệ ở cả hai chế độ.
  • unique_key biến append-only thành upsert: key khớp → update, key mới → insert. Không được null; cân nhắc surrogate key.
  • Bốn chiến lược: merge (mặc định, upsert theo key), insert_overwrite (ghi đè partition — BigQuery/Spark), append (chỉ thêm), delete+insert (thay theo key khi thiếu MERGE). BigQuery không có delete+insert.
  • Partition/cluster là nền tảng của incremental rẻ: partition_by cho pruning và cho insert_overwrite; cluster_by giảm thêm lượng đọc.
  • Late data: dùng cửa sổ lookback + unique_key; --full-refresh để rebuild; on_schema_change khi đổi cột; incremental_predicates để chặn MERGE quét cả bảng.
  • Bẫy lớn nhất: quên unique_key (trùng), lọc sai (thiếu/thừa), MERGE quét full, và incremental hóa quá sớm.

Bài tiếp theo: Triển khai & CI — đưa dbt lên production, tách môi trường dev/prod, và chạy tự động an toàn trong CI/CD.

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