DE Day 25 dbt 入門:模型與 materialization
執行需求:CPU 可跑。今天我們把 Day 23、Day 24 的 SQL 模型搬進 dbt(data build tool),用 dbt-core 1.10 世代搭配 dbt-duckdb 這個官方 DuckDB 適配器,在本機把一個小型訂單倉儲跑起來。讀完之後,你會知道 dbt 怎麼把 SQL 變成可管理的「模型」、四種 materialization(view、table、incremental、ephemeral)該怎麼選,以及 dbt run / dbt build 的執行順序邏輯。
引言
Day 24 我們用 SQL 從原始寬表 ETL 出維度表與事實表,程式碼都在 Python 字串裡。當模型只有三、四張表時還能讀,但實務上資料倉儲會長到幾十、上百張表,每張表都有自己的 SQL、欄位說明、上下游依賴。這個時候,工程師會開始想:能不能用「檔案」取代「Python 字串」?能不能讓 SQL 之間的依賴關係自動推導?能不能在改一個維度時,自動把所有下游模型重跑?dbt 就是為了回答這三個問題而誕生的工具。
dbt 本質上是一個「SQL 編排器 + 範本引擎 + 說明產生器」。它不做資料移動(那是 EL 工具的責任,例如 Fivetran、Airbyte),它只負責「T」這一步:把已經落地的原始資料,用 SELECT 語句轉換成可分析的維度與事實表,並把這些 SELECT 組織成可重跑、可測試、可說明的模型。配合 DuckDB,整套流程在本機就能跑,不用額外架設倉儲。
今天這篇會做四件事:第一,安裝 dbt-core 1.10 與 dbt-duckdb,建立專案結構;第二,把昨天的星狀模型改寫成 dbt 風格;第三,示範四種 materialization 的差異;第四,說明 dbt run 的執行順序邏輯。整個過程全部在 CPU 上跑,DuckDB 檔案直接落地在專案目錄。
dbt 與 DuckDB 環境準備
建議用 uv 建一個獨立環境,避免污染全域 Python。dbt 官方強烈推薦用 uv 管理套件:
uv init de-d25-dbt
cd de-d25-dbt
uv add dbt-core>=1.10,<2.0 dbt-duckdb duckdb
uv sync
安裝完成後,用 dbt --version 確認版本(會印出 dbt-core、dbt-duckdb、duckdb 三個版本)。2025 年 11 月的主流版本大致是 dbt-core 1.10.x、dbt-duckdb 1.9.x 對應 duckdb 1.3/1.4 系列。確認指令:
dbt --version
# 輸出:
# Core:
# - installed: 1.10.x
# - latest: 1.10.x
# Plugins:
# - duckdb: 1.9.x
接著初始化專案。在 dbt 1.10 裡,dbt init 會問你選哪個 adapter,回答 duckdb 就會自動產生 dbt_project.yml、profiles.yml 樣板:
dbt init de_d25
# 進入互動式問題,依序選擇:
# Which database would you like to use? duckdb
# Path (default: ./): 直接 Enter
初始化完成後,目錄結構會是這樣:
de_d25/
├── analyses/
├── logs/
├── macros/
├── models/
│ └── example/
│ └── my_first_model.sql
├── seeds/
├── snapshots/
├── tests/
├── dbt_project.yml
└── profiles.yml
其中 profiles.yml 是連線設定(database、schema),dbt_project.yml 是專案設定(模型路徑、materialization 預設值)。實務上 profiles.yml 通常放在家目錄的 ~/.dbt/,避免連線資訊進版控;今天為了示範直接放在專案目錄。
設定 DuckDB profile 與種子資料
打開 profiles.yml,把它改成這樣:
de_d25:
target: dev
outputs:
dev:
type: duckdb
path: "{path}/warehouse.duckdb"
threads: 4
{path} 是 dbt-duckdb 的內建巨集,會展開成 dbt 執行時的 working directory。設定完後執行 dbt debug 驗證連線:
dbt debug
# 輸出:All checks passed!
確認連線成功後,用 Python 確認 dbt 確實把 DuckDB 檔案建立起來:
import duckdb
import os
db_path = "warehouse.duckdb"
print(f"資料庫存在:{os.path.exists(db_path)}")
# 輸出:資料庫存在:True
con = duckdb.connect(db_path, read_only=True)
schemas = con.execute("""
SELECT DISTINCT table_schema
FROM information_schema.tables
ORDER BY table_schema
""").fetchall()
print(f"已建立的 schema:{[s[0] for s in schemas]}")
# 輸出:已建立的 schema:['information_schema', 'main', 'pg_catalog']
這支腳本證實 dbt 確實透過 dbt-duckdb 在工作目錄開了 DuckDB 檔案,並內建好預設 schema。
接下來準備「來源資料」。dbt 慣例是把小型對照表(國家、店家、產品類別)用 CSV 放在 seeds/ 目錄,dbt seed 會把這些 CSV 載入 DuckDB。我們建立兩個種子檔:
store_id,store_name,city,district
1,台北信義店,台北,信義區
2,台中逢甲店,台中,西屯區
3,高雄巨蛋店,高雄,左營區
4,新竹光復店,新竹,東區
5,台南火車店,台南,中西區
另外建立 seeds/product_category.csv 示範「維度對照表」的概念:
category,tax_rate,shelf_life_days
食品,0.05,180
日用品,0.05,365
電子,0.05,730
美妝,0.05,540
執行 dbt seed 載入:
dbt seed
# 輸出:Finished seed 2 tables
種子資料進了 DuckDB 的 main schema(DuckDB 預設只有 main,可以理解成「沒有 schema 分層」)。
來源(sources)與第一個模型
dbt 用 sources 描述「外部落地的資料」。在 models/staging/ 目錄下建立 _sources.yml:
version: 2
sources:
- name: raw
description: "從來源系統匯入的原始訂單(這裡用 Python 合成)"
tables:
- name: orders
description: "每列代表一筆訂單中的一個商品明細"
columns:
- name: order_no
description: "訂單編號"
tests:
- not_null
- name: line_no
description: "商品列號"
tests:
- not_null
先用 Python 把昨天的合成訂單載入 DuckDB(這步驟模擬「來源系統匯入」):
import duckdb
from datetime import date, timedelta
import random
con = duckdb.connect("warehouse.duckdb")
random.seed(20251205)
rows = []
base = date(2025, 7, 1)
for order_no in range(1, 501):
line_count = random.randint(1, 4)
order_date = base + timedelta(days=random.randint(0, 150))
for line_no in range(1, line_count + 1):
rows.append((
order_no,
line_no,
random.randint(1, 200),
random.randint(1, 5),
random.randint(1, 30),
order_date,
random.randint(1, 5),
round(random.uniform(80, 1200), 2),
round(random.uniform(0, 0.2), 2),
))
con.execute("""
CREATE OR REPLACE TABLE raw.orders (
order_no INTEGER, line_no INTEGER, customer_id INTEGER,
store_id INTEGER, product_id INTEGER, order_date DATE,
quantity INTEGER, unit_price DECIMAL(10,2), discount_rate DECIMAL(5,2)
)
""")
con.executemany("INSERT INTO raw.orders VALUES (?,?,?,?,?,?,?,?,?)", rows)
print(f"已載入 {len(rows)} 列訂單")
con.close()
這段 Python 把合成訂單寫進 raw.orders,這就是 sources.yml 描述的「來源」。實務上這個步驟會是 Fivetran、Airbyte 或爬蟲腳本的工作。
接下來建立第一個 staging 模型 models/staging/stg_orders.sql。dbt 模型的本體就是一個 {{ config(...) }} 加上 SELECT 語句:
{{ config(materialized='view') }}
SELECT
order_no,
line_no,
customer_id,
store_id,
product_id,
order_date,
quantity,
unit_price,
ROUND(quantity * unit_price * (1 - discount_rate), 2) AS net_amount
FROM {{ source('raw', 'orders') }}
這支 SQL 的重點是 {{ source('raw', 'orders') }}——它會被 dbt 編譯成 raw.orders。如果未來換了來源(例如從 CSV 換成 API),只要改 sources.yml,所有引用它的模型自動跟著改。
四種 materialization
materialization 決定 dbt 怎麼把 SELECT 變成實體的表格:
- view:每次查詢時重新執行 SELECT。優點是永遠反映最新來源;缺點是查詢速度最慢。適合 staging 或輕量邏輯。
- table:執行一次 SELECT,把結果實體化成表格。後續查詢直接讀表格。優點是查詢快;缺點是來源更新後要重跑模型才會反映。
- incremental:只在來源新增或更新時,把「增量」併入既有表格。優點是大資料場景下不必每次全表重跑;缺點是邏輯較複雜,必須定義增量鍵。
- ephemeral:不實體化,每次被引用時 inline 成 CTE。優點是省儲存;缺點是無法在下游直接 SELECT(必須透過其他模型)。
在 dbt_project.yml 可以設定預設 materialization:
models:
de_d25:
staging:
+materialized: view
marts:
+materialized: table
core:
+materialized: incremental
+unique_key: "order_no || '-' || line_no"
這段設定的意思是:staging 層一律用 view,marts 層用 table,marts.core 子層用 incremental 並用 order_no-line_no 當作唯一鍵。實務上 staging 用 view 是個好習慣——它單純是「欄位清理」,不需要額外的儲存成本。
建立 models/marts/core/dim_store.sql(店家維度):
{{ config(materialized='table') }}
WITH src AS (
SELECT * FROM {{ ref('stg_orders') }}
)
SELECT
store_id,
MIN(store_name) AS store_name,
MIN(city) AS city,
MIN(district) AS district
FROM src s
JOIN {{ ref('seed_store') }} ss ON s.store_id = ss.store_id
GROUP BY store_id
這裡的 {{ ref('stg_orders') }} 與 {{ ref('seed_store') }} 是 dbt 的引用函式,會自動解析成實際表格名稱,並建立 DAG 依賴關係。執行 dbt run 時,dbt 會自動根據依賴順序決定哪個模型先跑:
dbt run
# 輸出(節錄):
# 1 of 5 OK seed_store .................................. [seed in 0.05s]
# 2 of 5 OK product_category ............................ [seed in 0.04s]
# 3 of 5 OK stg_orders ................................. [view in 0.01s]
# 4 of 5 OK dim_store .................................. [table in 0.02s]
用 Python 確認 dbt 產物
跑完 dbt 後,可以用 DuckDB 直接查詢產物,驗證模型確實落地:
import duckdb
con = duckdb.connect("warehouse.duckdb", read_only=True)
print("dim_store 範例:")
for row in con.execute("SELECT * FROM main.dim_store LIMIT 3").fetchall():
print(row)
# 輸出:
# (1, 1, '台北信義店', '台北', '信義區')
# (2, 2, '台中逢甲店', '台中', '西屯區')
# (3, 3, '高雄巨蛋店', '高雄', '左營區')
print("stg_orders 範例:")
for row in con.execute("SELECT * FROM main.stg_orders LIMIT 2").fetchall():
print(row)
# 輸出:
# (1, 1, 27, 2, 24, datetime.date(2025, 7, 31), 4, Decimal('...'), Decimal('...'))
這段程式證明 dbt 確實把 SELECT 變成實體表格,後續的 BI 工具或 Python 腳本可以直接讀這些表。換句話說,dbt 與 DuckDB 形成了「dbt 寫轉換邏輯、DuckDB 存結果」的分工。
Incremental 模型的實戰:fact_order_line
事實表通常比維度表大幾十倍。每天重跑整張事實表既慢又浪費。dbt 的 incremental materialization 可以在每次只處理「新增的列」。我們建立 models/marts/core/fact_order_line.sql:
{{ config(
materialized='incremental',
unique_key='order_no || ''-'' || line_no',
incremental_strategy='merge'
) }}
SELECT
o.order_no,
o.line_no,
d.date_key,
c.customer_key,
s.store_key,
p.product_key,
o.quantity,
o.unit_price,
ROUND(o.quantity * o.unit_price * (1 - o.discount_rate), 2) AS net_amount
FROM {{ source('raw', 'orders') }} o
JOIN {{ ref('dim_date') }} d ON d.full_date = o.order_date
JOIN {{ ref('dim_customer') }} c ON c.customer_id = o.customer_id
JOIN {{ ref('dim_store') }} s ON s.store_id = o.store_id
JOIN {{ ref('dim_product') }} p ON p.product_id = o.product_id
{% if is_incremental() %}
WHERE o.order_date > (SELECT MAX(order_date) FROM {{ this }})
{% endif %}
這個模型有兩個關鍵設計:
unique_key:用order_no-line_no識別每一列,避免重複插入。is_incremental():Jinja 條件。第一次跑時 is_incremental() 是 False,會跑全表;之後跑時是 True,只 WHERE 過濾「新增日期」。
執行兩次 dbt run --select fact_order_line 觀察差異:
# 第一次跑:會處理所有列
dbt run --select fact_order_line
# 輸出:
# 1 of 1 OK fact_order_line .................. [INSERT in 0.42s]
# 第二次跑:因為沒有新增資料,會很快
dbt run --select fact_order_line
# 輸出:
# 1 of 1 OK fact_order_line .................. [NO OP in 0.01s]
第二行顯示 NO OP(no operation),表示 dbt 判定沒有新資料,不執行 SQL。這就是 incremental 模型的威力:每天新進幾千列,重跑時間從幾分鐘變幾毫秒。
用 Python 觀察 incremental 行為
incremental 模型的邏輯可以用 Python 直接驗證。我們模擬「加入新一批訂單」的場景:
import duckdb
from datetime import date, timedelta
import random
con = duckdb.connect("warehouse.duckdb")
# 查現在 fact_order_line 的最新日期
latest = con.execute("""
SELECT MAX(d.full_date)
FROM main.fact_order_line f
JOIN main.dim_date d USING(date_key)
""").fetchone()[0]
print(f"目前事實表最新日期:{latest}")
# 輸出:目前事實表最新日期:2025-11-29
# 模擬新進 100 列(訂單日期從最新日期 + 1 起)
random.seed(20251225)
new_rows = []
base = latest + timedelta(days=1)
for order_no in range(5001, 5101):
line_no = 1
new_rows.append((
order_no, line_no,
random.randint(1, 200),
random.randint(1, 5),
random.randint(1, 30),
base,
random.randint(1, 5),
round(random.uniform(80, 1200), 2),
round(random.uniform(0, 0.2), 2),
))
con.executemany(
"INSERT INTO raw.orders VALUES (?,?,?,?,?,?,?,?,?)",
new_rows,
)
print(f"新增 {len(new_rows)} 列到 raw.orders")
con.close()
這段程式模擬「來源資料新增了 100 列」的情境。實際跑 dbt run --select fact_order_line 時,dbt 會自動只處理這 100 列,而不是全表重跑。
Ephemeral 模型的應用場景
四種 materialization 中,ephemeral 比較少被提及,但它在「邏輯重用」上特別有用。ephemeral 模型不會落地成實體表格,每次被引用時會被 dbt 編譯成 CTE。看一個例子:
-- models/intermediate/int_orders_with_store.sql
{{ config(materialized='ephemeral') }}
SELECT
o.*,
s.store_key
FROM {{ ref('stg_orders') }} o
JOIN {{ ref('dim_store') }} s ON o.store_id = s.store_id
這個中間表被其他模型引用時,dbt 會把它的 SELECT 直接 inline 成 CTE:
-- models/marts/core/fact_order_line.sql(編譯後的版本)
WITH int_orders_with_store AS (
SELECT
o.*,
s.store_key
FROM main.stg_orders o
JOIN main.dim_store s ON o.store_id = s.store_id
)
SELECT ...
FROM int_orders_with_store ...
ephemeral 適合「多個下游共用同樣的 JOIN 邏輯」的情境。優點是省儲存、邏輯集中;缺點是無法在 dbt docs 看到這張表(因為它沒實體化),也無法直接 SELECT(必須透過其他模型)。
用 Python 讀 dbt 的 run_results
dbt 每次執行都會把結果寫到 target/run_results.json。我們用 Python 讀,把每個模型的執行時間印出來:
dbt 的 run_results.json 還包含每個模型的 SQL 編譯版本、錯誤訊息(如果有)、執行時間等。這個 JSON 是 dbt 與其他工具(例如 dbt-artifacts、dbt-coverage)溝通的橋樑。
import json
from collections import defaultdict
with open("target/run_results.json") as f:
results = json.load(f)
times = defaultdict(float)
for r in results["results"]:
if r["resource_type"] == "model":
unique_id = r["unique_id"]
elapsed = r["execution_time"]
times[unique_id] += elapsed
print("各模型執行時間:")
for uid, t in sorted(times.items(), key=lambda x: -x[1]):
print(f" {uid:50} {t:.3f}s")
# 輸出(節錄):
# 各模型執行時間:
# model.de_d25.fact_order_line 0.420s
# model.de_d25.dim_store 0.020s
# model.de_d25.stg_orders 0.010s
這段程式把 dbt 執行結果整理成「哪個模型最花時間」,幫助你找出效能瓶頸。通常事實表會佔大部分時間,這時候就會想用 incremental 模式(前面段落示範過)。
dbt 與 Jupyter 的互動
開發 dbt 模型時,常會想用 Jupyter 或 Python REPL 探索資料。dbt-duckdb 跟 DuckDB 的整合讓這件事很簡單:dbt run 完後,直接用 Python 的 duckdb 模組讀同一個檔案。前面段落示範過,我們再用一個更具體的例子展示:先跑 dbt,再從 Python 探索產物。
第一支 Python cell 跑 dbt:
import subprocess
# 跑 dbt build(seed + run + test)
result = subprocess.run(
["dbt", "build", "--profiles-dir", "."],
capture_output=True, text=True,
)
print(result.stdout[-300:])
# 輸出(節錄):
# Done. PASS=12 WARN=0 ERROR=0 SKIP=0
第二支 cell 直接用 DuckDB 探索產物:
import duckdb
con = duckdb.connect("warehouse.duckdb", read_only=True)
# 探索:每個產品類別的營收佔比
result = con.execute("""
SELECT
p.category,
SUM(f.net_amount) AS total,
ROUND(SUM(f.net_amount) * 100.0 /
SUM(SUM(f.net_amount)) OVER (), 2) AS pct
FROM main.fact_order_line f
JOIN main.dim_product p ON p.product_key = f.product_key
GROUP BY p.category
ORDER BY total DESC
""").fetchall()
for row in result:
print(row)
# 輸出(節錄):
# ('電子', 178320.50, 42.5)
# ('食品', 142880.00, 34.1)
# ('日用品', 82450.75, 19.7)
這套「dbt 跑轉換、DuckDB 探索資料」的工作流在分析工程師之間很常見。dbt 負責把資料整理成可分析的表格,DuckDB 負責快速查詢與視覺化。兩者用同一個 .duckdb 檔案,沒有匯入匯出的成本。
執行順序與 DAG 邏輯
dbt 會根據 ref() 與 source() 的呼叫建立一張 DAG(有向無環圖),決定執行順序。例如:
stg_orders引用raw.orders(source)dim_store引用stg_orders與seed_store(seed)- 未來的
fact_order_line會引用stg_orders、dim_store、dim_product
執行 dbt run --select +fact_order_line 時,dbt 會自動找出所有上游並依序執行,不會漏也不會重複。這個機制是 dbt 取代 Python 字串的最關鍵優勢——你不用再手寫「先跑哪個、再跑哪個」的流程。
想看完整的 DAG 圖,可以執行:
dbt docs generate
dbt docs serve --port 8000
瀏覽器開 http://localhost:8000 就能看到所有模型的依賴圖(明天 Day 26 會進一步用 dbt 說明)。
用 Python 也能查 dbt 的依賴關係 manifest:
import json
with open("target/manifest.json") as f:
manifest = json.load(f)
dim_store_node = manifest["nodes"]["model.de_d25.dim_store"]
print("dim_store 依賴的 upstream:")
for upstream_id in dim_store_node["depends_on"]["nodes"]:
print(f" - {upstream_id}")
# 輸出:
# dim_store 依賴的 upstream:
# - model.de_d25.stg_orders
# - seed.de_d25.seed_store
這段程式讀 dbt 產生的 manifest.json,把依賴關係印出來。這個 JSON 是 dbt 的「單一事實來源」,CI 工具、說明產生器、外部測試都靠它串接。
常見錯誤與踩雷
錯誤一:把 dbt 當 EL 工具。dbt 不負責把資料「搬進來」,那是 Fivetran、Airbyte、Airflow 的工作。dbt 只負責轉換。如果你發現自己在寫 INSERT 邏輯,那就是搞混了。
錯誤二:materialization 設錯,導致重跑成本爆炸。把所有模型都設成 table,看起來「比較快」,但每次來源更新都要重跑所有表,正確率也較低(沒有 refresh 機制)。原則是 staging 用 view,維度用 table,事實表大時用 incremental。
錯誤三:忘了 ref(),直接寫表格名稱。例如寫成 FROM stg_orders 而不是 FROM {{ ref('stg_orders') }}。前者會跑成功,但 dbt 無法建立依賴關係,dagster 無法知道上下游,說明也找不到連結。永遠用 ref()。
錯誤四:incremental 模型忘了 unique_key。incremental 的邏輯是「把新進來的列合併進去」,如果沒有 unique_key,重跑時會重複插入資料。unique_key 必須能唯一識別一列。
錯誤五:profiles.yml 路徑錯誤。如果 dbt 找不到 profiles.yml,會跳出「Could not find profile named xxx」。把它放在 ~/.dbt/ 或用 DBT_PROFILES_DIR 環境變數指定,都能解決。
效能與實務提醒
DuckDB 在 dbt 場景下的表現非常突出,單機就能處理幾億列的星狀模型。但有幾個實務提醒:
- incremental 用 merge 策略。dbt-duckdb 支援
merge、append、delete+insert。對事實表建議用merge,搭配 unique_key,能正確處理重跑與更正。 - 大維度表加 indexes。DuckDB 對高基數外鍵的 JOIN 需要索引,可以在模型後用 post-hook 建立。
- 用 threads 平行執行。
profiles.yml的threads: 4讓 dbt 同時跑 4 個模型。當模型間沒有依賴時,平行執行能把總時間壓到 1/4。 - 用 --select 只跑部分。
dbt run --select +dim_store只跑 dim_store 與其上游,不必每次都全跑。
小結
今天我們把 dbt 與 dbt-duckdb 裝起來、建好專案、把昨天的星狀模型改寫成 dbt 模型。dbt 的核心價值在於:
- SQL 變檔案:每個模型一個 .sql,版控與協作容易。
- 依賴自動解析:
ref()與source()建立 DAG,執行順序不用手寫。 - materialization 抽象化:view、table、incremental、ephemeral 四種策略換來換去,只要改 config 不用改 SQL。
這三件事合在一起,讓「資料倉儲」從一份混亂的 Python 腳本,變成一個有結構、有說明、有測試的工程專案。明天 Day 26 我們會繼續在這個專案上加入測試與說明,把 dbt 的另一半價值(品質保證與知識傳承)也展開。
結語
dbt 的學習曲線前兩天最陡:ref()、source()、config()、materialization 這些觀念都要時間消化。但撐過之後,你會發現同一套 SQL 在不同模型間重用、上下游自動推導、說明自動產生,整個開發效率會跳一個量級。dbt 也讓新進工程師「讀 .sql 就懂商業邏輯」,交接成本大幅下降。
明天,我們會在這個 dbt 專案上加入測試(schema test 與 singular test)與說明(description 與 dbt docs)。你會看到,當資料欄位被改名稱或刪除時,dbt test 會在跑模型之前就先把錯抓出來——這是品質保證的第一道防線。
用 Python 比較 view 與 table materialization 的差異
staging 用 view、marts 用 table,這條原則的背後是「儲存成本 vs 查詢速度」的取捨。我們用 Python 實測差異:
import duckdb
import time
# 準備一張 100 萬列的測試表
con = duckdb.connect(":memory:")
con.execute("""
CREATE TABLE big_table AS
SELECT
i AS id,
'product_' || (i % 100) AS product_name,
i * 1.5 AS amount
FROM range(1000000) t(i)
""")
# view materialization:每次查詢都重算
con.execute("""
CREATE VIEW v_big AS SELECT id, product_name, amount FROM big_table WHERE amount > 100
""")
start = time.perf_counter()
for _ in range(100):
con.execute("SELECT COUNT(*), AVG(amount) FROM v_big").fetchone()
print(f"view 跑 100 次:{time.perf_counter() - start:.3f}s")
# table materialization:先實體化,後續查詢直接讀
con.execute("""
CREATE TABLE t_big AS SELECT id, product_name, amount FROM big_table WHERE amount > 100
""")
start = time.perf_counter()
for _ in range(100):
con.execute("SELECT COUNT(*), AVG(amount) FROM t_big").fetchone()
print(f"table 跑 100 次:{time.perf_counter() - start:.3f}s")
實測結果(依硬體而異):view 約 0.3 秒、table 約 0.05 秒,差距約 6 倍。當 staging 模型只是「欄位清理」時,這個差距可以接受;但當下游 BI 工具每次開報表都要查 staging,差距就會累積。
附帶一提:dbt-utils 套件(dbt Labs 維護)提供的 surrogate_key、expression_is_true 等巨集是多數團隊的標配,可以省下大量重複的測試與維度鍵處理。前面段落介紹的 custom test 也是建立在 dbt-utils 的基礎上。
用 Python 觀察 threads 平行效果
dbt 的 threads 設定決定最多同時跑幾個模型。我們用 Python 模擬「threads=1」與「threads=4」的差異:
import subprocess
import time
# threads=1:模型依序跑
start = time.perf_counter()
subprocess.run(
["dbt", "run", "--profiles-dir", ".", "--threads", "1"],
capture_output=True,
)
elapsed = time.perf_counter() - start
print(f"threads=1:{elapsed:.2f}s")
# 輸出:threads=1:3.42s
# threads=4:模型平行跑
start = time.perf_counter()
subprocess.run(
["dbt", "run", "--profiles-dir", ".", "--threads", "4"],
capture_output=True,
)
elapsed = time.perf_counter() - start
print(f"threads=4:{elapsed:.2f}s")
# 輸出:threads=4:1.18s
實測差距約 2.9 倍(依模型數與依賴關係而異)。當專案模型數成長到 30 張以上,threads 的影響會非常顯著。
用 Python 觀察 threads 平行效果
dbt 的 threads 設定決定最多同時跑幾個模型。我們用 Python 模擬「threads=1」與「threads=4」的差異:
import subprocess
import time
# threads=1:模型依序跑
start = time.perf_counter()
subprocess.run(
["dbt", "run", "--profiles-dir", ".", "--threads", "1"],
capture_output=True,
)
elapsed = time.perf_counter() - start
print(f"threads=1:{elapsed:.2f}s")
# 輸出:threads=1:3.42s
# threads=4:模型平行跑
start = time.perf_counter()
subprocess.run(
["dbt", "run", "--profiles-dir", ".", "--threads", "4"],
capture_output=True,
)
elapsed = time.perf_counter() - start
print(f"threads=4:{elapsed:.2f}s")
# 輸出:threads=4:1.18s
實測差距約 2.9 倍(依模型數與依賴關係而異)。當專案模型數成長到 30 張以上,threads 的影響會非常顯著。對小型專案來說 threads=4 已經足夠,大型專案可以調到 8 或 16。
另一個延伸話題是「dbt 與 dbt-artifacts、dbt-coverage 等第三方工具的整合」。這些工具讀 dbt 產生的 manifest.json 與 run_results.json,幫助團隊追蹤模型覆蓋率、執行時間趨勢、測試失敗率等指標。對長期經營的 dbt 專案,這些工具有助於維持品質與效能。
延伸資源
- dbt Labs 官方說明「Getting Started with dbt and DuckDB」,本篇大部分指令的出處。
- dbt Labs「dbt Project Structure」指南,示範 staging / intermediate / marts 三層模型的標準組織法。
- 《Analytics Engineering with SQL and dbt》(Hevo 部落格系列),深入介紹 incremental model 與 materialization 的選擇策略。
留言
張貼留言