跳到主要內容

DE Day 25 dbt 入門:模型與 materialization

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 場景下的表現非常突出,單機就能處理幾億列的星狀模型。但有幾個實務提醒:

  1. incremental 用 merge 策略。dbt-duckdb 支援 merge、append、delete+insert。對事實表建議用 merge,搭配 unique_key,能正確處理重跑與更正。
  2. 大維度表加 indexes。DuckDB 對高基數外鍵的 JOIN 需要索引,可以在模型後用 post-hook 建立。
  3. 用 threads 平行執行。profiles.yml 的 threads: 4 讓 dbt 同時跑 4 個模型。當模型間沒有依賴時,平行執行能把總時間壓到 1/4。
  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 的選擇策略。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...