批次資料管線
範圍 —— 資料工程面試關於批次管線會問的一切:參考架構、ETL vs ELT、lake vs warehouse vs lakehouse、資料擷取模式、冪等性與回補、遲到資料、
MERGE、用 Airflow 做編排、dbt、資料品質、檔案與表格格式、成本、故障處理、PII,以及一題完整的設計題。表格與分區設計在 schema 設計文件;串流在串流管線文件。 另見:../faq_DE_interview.md—— 總覽與考前檢查清單;./schema_design.md、./data_modeling.md、./spark.md、./stream_pipeline.md、./sql_for_data.md;./aws_data.md—— 同一套架構在 AWS 上的做法(Glue、EMR、Athena、S3 上的 Iceberg);以及較舊的../faq_Airflow.md筆記。
每個答案都先給你會說出口的那句話,再講機制,然後是面試官下一步會追問的取捨。
1) 參考架構 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
1-1) 批次管線的五個階段
批次管線依排程把有界的資料塊送過五個階段:從來源擷取、落地原始副本、清理並一致化、 為使用者建模、對外提供 —— 編排、品質檢查與中介資料橫跨全部五個階段。
Sources Ingest Storage layers Serve
┌──────────┐ ┌──────────┐ ┌────────┐ ┌─────────┐ ┌────────┐ ┌───────────┐
│ OLTP DBs │──┐ │ full / │ │ Bronze │ │ Silver │ │ Gold │ │ BI / SQL │
│ SaaS APIs│──┼─►│ incr. / │────►│ raw, │──►│ cleaned │──►│ marts, │──►│ ML feats │
│ files │──┤ │ CDC / │ │ as-is │ │ deduped │ │ stars, │ │ reverse │
│ events │──┘ │ file drop│ │ append │ │ typed │ │ aggs │ │ ETL, APIs │
└──────────┘ └──────────┘ └────────┘ └─────────┘ └────────┘ └───────────┘
└────── orchestration (Airflow / Dagster) · data quality · catalog & lineage ──────┘
| 階段 | 負責 | 常見工具 |
|---|---|---|
| 擷取(Ingest) | 把資料從來源拿出來,且不傷到來源 | Fivetran / Airbyte、Debezium、Spark JDBC、自寫 Python |
| 落地(Raw) | 收到時原封不動的不可變副本 | 物件儲存(S3、GCS、ADLS)、資料倉儲裡的 raw schema |
| 轉換(Transform) | 清理、去重、定型別、一致化、業務邏輯 | SQL + dbt、Spark |
| 建模(Model) | 給使用者的事實、維度、彙總 | dbt、資料倉儲 SQL |
| 提供(Serve) | dashboard、匯出、特徵表、reverse ETL | BI 工具、feature store、Census / Hightouch |
面試重點:保留原始副本。之後每一層都能從它重建, 這讓轉換的 bug 變成一次重跑,而不是一次資料遺失。
1-2) ETL vs ELT
ETL 在載入前用獨立的引擎轉換;ELT 先載入原始資料,再在資料倉儲裡轉換 —— 這是現代的預設,因為倉儲運算可隨需擴展,而原始資料讓你能重新處理。
| ETL | ELT | |
|---|---|---|
| 轉換在哪跑 | 專用引擎(Spark、Informatica、Python) | 資料倉儲 / lakehouse(SQL、dbt) |
| 目標端有原始資料 | 通常沒有 | 有 |
| 重新處理 | 從來源重新擷取 | 在 raw 層上重跑 SQL |
| 強項 | 大量非 SQL 邏輯;落地前清除 PII | 改得快、分析師自己寫 SQL、lineage |
| 弱項 | 邏輯在倉儲之外,改起來慢 | 原始敏感資料落進倉儲;運算成本 |
大多數真實的技術棧是混合的:大部分分析用 ELT,資料必須轉換後才准落地的地方 (PII、超大的非結構化輸入)才用 ETL。
1-3) Data lake vs warehouse vs lakehouse
Warehouse 是受管理、schema-on-write 的 SQL 儲存;lake 是物件儲存裡便宜的檔案, 以 schema-on-read 讀取;lakehouse 是在 lake 上面加一層表格格式,帶來 ACID 交易、 schema 強制與快速 SQL。
| 資料倉儲 | 資料湖 | Lakehouse | |
|---|---|---|---|
| 儲存 | 專有、受管理 | 物件儲存裡的開放檔案 | 開放檔案 + 表格格式中介資料 |
| Schema | 寫入時 | 讀取時 | 寫入時,可演進 |
| 交易 | 有 | 沒有(只是一個裝檔案的目錄) | 有(Iceberg、Delta、Hudi) |
| 資料型態 | 結構化、半結構化 | 任何 | 任何 |
| 引擎 | 倉儲自己的 | 很多(Spark、Trino、Presto) | 很多,讀同一批表 |
| 例子 | Snowflake、BigQuery、Redshift | S3 + Hive 表 | Databricks Delta、S3 上的 Iceberg 搭配 Trino / Spark |
單純資料湖的失敗模式是 data swamp(資料沼澤):沒有交易,所以寫到一半的 job 會被讀取者看到;沒有 schema,所以沒人信任那些檔案。
1-4) Medallion 分層,以及各層負責什麼
Bronze 是原始副本,silver 是清理並一致化後的事實,gold 為某一種用途而塑形 —— 每一層有不同的負責人,對「什麼可以改」也有不同的規則。
| 層 | 內容 | 規則 |
|---|---|---|
| Bronze / raw | 收到時的來源紀錄,加上擷取中介資料(_ingested_at、_source、_batch_id) |
只能附加;永不修改;保留時間要長到足以重建 silver |
| Silver / cleaned | 去重、定型別、驗證過、一致化的實體與事件 | 每個實體版本或事件一列;有品質關卡;PII 已處理 |
| Gold / marts | 事實、維度、彙總、特徵表 | 依使用者建模;業務定義住在這裡 |
同一個概念、比較舊的名字:staging → integration → presentation。名字不重要, 重要的是約定:每一層都能從下一層重建。
2) 資料擷取模式 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
2-1) 全量、增量,還是 CDC
小表用全量重載;來源有可靠的 updated_at 時用 watermark 欄位做增量;需要抓刪除、
降低來源負擔或接近即時時,用基於 log 的 CDC。
| 模式 | 怎麼做 | 抓得到刪除 | 來源負擔 | 要注意 |
|---|---|---|---|---|
| 全量快照 | 每次 SELECT *,取代目標 |
可以(從缺席判斷) | 高 | 只適合小表或變動慢的表 |
| 增量(watermark) | WHERE updated_at > :last_watermark |
不行 | 低–中 | 需要有索引且可靠的 updated_at |
| 只附加 | WHERE id > :last_id |
不適用 | 低 | 只適合不可變的列(log、事件) |
| 基於 log 的 CDC | 讀 binlog / WAL(Debezium、DMS、Datastream) | 可以 | 最低 | 基礎設施較多;schema 變更會一路流過來 |
| 快照比對 | 比較今天與昨天的全量擷取 | 可以 | 高 | 昂貴;其他都不行時的備案 |
2-2) 把增量載入做對
用 watermark 加上重疊窗口拉資料,再依主鍵去重 —— 因為資料列會「在過去」才提交, 剛好卡在 watermark 邊界的會被漏掉。
- 把 watermark(實際載入的最大
updated_at)存在狀態表裡,而不是存在重跑就歸零的變數裡。 - 重疊:查詢
updated_at > watermark - overlap。在上次執行前開始、之後才提交的交易, 它的updated_at會比 watermark 還舊。 - 在 silver 去重:依
updated_at取每個鍵的最新版本(再加一個 tiebreaker, 例如序號或 CDC 的 log 位置)。 - 應用程式伺服器之間的時鐘偏差讓
updated_at只是近似值;重疊窗口會吸收它。 - 硬刪除永遠不會出現在
updated_at查詢裡。要嘛請來源團隊提供軟刪除旗標,要嘛用 CDC。
-- PostgreSQL source extract: incremental with a 10-minute overlap
SELECT *
FROM orders
WHERE updated_at > :last_watermark - INTERVAL '10 minutes'
AND updated_at <= :run_upper_bound -- fixed upper bound: reruns read the same rows
ORDER BY updated_at;
固定的上界很重要:沒有它,較晚的重跑會比原本那次讀到更多列, job 就不再是確定性的。
2-3) 用 Debezium 做 change data capture
Debezium 讀資料庫的複寫 log,每一次資料列變更 —— insert、update、delete —— 都發出 一個事件到 Kafka,讓資料倉儲不用查來源的表就能重播來源的歷史。
MySQL binlog / Postgres WAL ──► Debezium (Kafka Connect source) ──► Kafka topic per table
│
sink: Spark / Flink / Kafka Connect ─► bronze (append)
│
MERGE into silver (latest per key)
- 每個事件都帶有
before、after、op(c、u、d,快照讀取為r),以及來源 中介資料,例如 log 位置與交易 id。 - 初始快照先載入既有的列,接著從快照記錄的 log 位置繼續串流,所以中間不會漏掉任何東西。
- 用主鍵當 topic 的 key,讓同一列的所有變更依序待在同一個 partition。
- 刪除以
d事件抵達,接著是給 compacted topic 用的 tombstone(null值)。 - 取捨:需要 log 存取權限(
ROW格式的 binlog、Postgres replication slot),而被遺棄的 replication slot 會讓 Postgres 一直保留 WAL,直到磁碟塞滿。
2-4) 檔案與 API
檔案投遞需要落地約定 —— 命名、完整性訊號、schema —— 而 API 拉取需要分頁、 限流處理與 checkpoint,讓失敗時能接續而不是從頭來。
檔案(SFTP、合作夥伴的 S3 bucket):
- 和對方約定含日期與序號的命名規則,以及完整性訊號 ——
_SUCCESS/ manifest 檔, 或結尾列的筆數 —— 這樣你永遠不會載入上傳到一半的檔案。 - 立刻複製到你自己的 raw 區,再從那裡處理;合作夥伴的 bucket 不是你的封存庫。
- 記錄每個載入過的檔案(名稱、checksum、筆數),才能偵測到重送的檔案。
API(SaaS 來源):
- 分頁:優先用 cursor-based(
next_cursor)而不是 offset-based,因為拉取期間資料 變動時,offset 會跳過或重複紀錄。 - 限流:遵守
429與Retry-After,以指數退避加上 jitter 重試。 - 增量:API 有提供時,用
updated_since參數過濾。 - Checkpoint 最後的 cursor,讓失敗時從那裡接續。
# python — cursor-paginated pull with retry on 429 / 5xx (requests 2.x)
import random
import time
import requests
def fetch_all(url, params, max_retries=5):
cursor = None
while True:
query = dict(params, cursor=cursor) if cursor else dict(params)
for attempt in range(max_retries):
resp = requests.get(url, params=query, timeout=30)
if resp.status_code == 429 or resp.status_code >= 500:
retry_after = resp.headers.get("Retry-After")
wait = float(retry_after) if retry_after else 2 ** attempt + random.random()
time.sleep(wait)
continue
resp.raise_for_status()
break
else:
raise RuntimeError(f"giving up after {max_retries} attempts: {url}")
body = resp.json()
yield from body["data"]
cursor = body.get("next_cursor")
if not cursor:
return
3) 冪等性、確定性與回補 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
3-1) 對管線來說,冪等是什麼意思
一個 task 對同一個邏輯日期跑一次或跑十次,目標端最後的狀態都一樣,它就是冪等的 —— 這個性質讓重試、重跑與回補變得安全。
三個要素:
- 確定性的輸入 —— task 讀的是由它的邏輯日期 / data interval 定義的固定切片,
永遠不是「
now()以來的所有東西」。 - 取代,不要附加 —— task 擁有它的輸出切片並覆寫它
(分區覆寫、
MERGE、在同一個交易裡先刪後插)。 - 沒有隱藏的副作用 —— 寄信、呼叫 API、計數器都是獨立的 task,有各自的去重鍵。
| 寫入模式 | 冪等嗎? | 為什麼 |
|---|---|---|
INSERT INTO ... SELECT |
不是 | 重跑會讓資料列重複 |
INSERT OVERWRITE ... PARTITION (dt = ...) |
是 | 該分區被取代 |
DELETE WHERE dt = ... + INSERT,同一個交易 |
是 | 效果相同,給沒有 overwrite 的引擎用 |
以自然鍵 MERGE |
是 | 匹配的列更新,其他的插入 |
CREATE OR REPLACE TABLE ... AS SELECT |
是 | 整張表重建 |
-- Spark SQL / Hive: idempotent daily partition load
INSERT OVERWRITE TABLE silver.orders PARTITION (dt = '2026-11-01')
SELECT order_id, customer_id, status, amount, updated_at
FROM bronze.orders_raw
WHERE dt = '2026-11-01';
在 Spark 裡,用 DataFrame 搭配 partitionBy 做 mode("overwrite") 寫入前,要先設定
spark.sql.sources.partitionOverwriteMode=dynamic,否則它會取代表的每一個分區,
而不只是 job 寫到的那些。
3-2) 確定性的陷阱
任何讀取牆上時鐘、亂數,或輸入的「最新」版本的東西,都會讓重跑得到不同的答案。
- 轉換裡用
CURRENT_DATE/now()→ 改用這次執行的邏輯日期參數。 - 回補去年資料時讀「現在」的維度 → 依事件發生時有效的版本 join(SCD2), 或每次執行時對維度做快照。
- 沒有全序的
ROW_NUMBER()(updated_at同值)→ 加上 tiebreaker 欄位。 - 沒有排序的
LIMIT、沒有 seed 的抽樣、在轉換裡生成 UUID 當鍵 → 從資料推導鍵(自然鍵的雜湊值)。 - 來源資料事後會變 → 重新處理時讀不可變的 bronze 副本,而不是線上的來源。
3-3) 回補與重新處理
回補是對過去的邏輯日期重跑管線;只有在每個 task 都冪等且以日期為參數時才安全, 而且要限流,免得餓死每日的例行執行。
- 在 Airflow 2.x:
airflow dags backfill -s 2026-01-01 -e 2026-03-31 my_dag(Airflow 3 改成airflow backfill create),或清除過去的 task instance 讓排程器重跑;DAG 第一次部署時,catchup=True會自動回補漏掉的 interval。 - 限制併發(
max_active_runs、pool),讓 90 天的回補不會把資料倉儲塞滿。 - 後面的分區依賴前面的分區時(累計總和、SCD2),從最舊的開始跑。
- Schema 變更 + 回補:部署新程式碼,回補到新表或新分區,拿筆數與彙總值和舊輸出比對, 然後切換。
- 全表重建 vs 增量回補:當邏輯變更影響每一列(新的業務定義)時,從 bronze 重建整張表 常常比修補分區更簡單也更安全。
3-4) 遲到的資料與遲到的維度
遲到的事實會落進已經跑過的分區,所以要嘛每天重新處理最近一段窗口,要嘛依抵達日期 分區;遲到的維度是指事實比它的維度列先到,所以先指向一個佔位列,等維度到了再修正。
遲到的事實:
| 策略 | 怎麼做 | 取捨 |
|---|---|---|
| 滾動重處理窗口 | 每次執行都重建最近 N 天的事件日期分區 | 簡單,N 天內正確;運算成本是 N 倍 |
| 依擷取日期分區 | 依抵達時間載入;使用者依事件日期過濾 | 永遠不重寫;依事件日期查詢要掃更多 |
增量 MERGE |
把遲到的列 upsert 進正確的事件日期分區 | 有效率;需要鍵,以及支援 MERGE 的表格格式 |
遲到的維度(「早到的事實」):
- 事實參照了
customer_id = 42,但dim_customer還沒有 42 這一列。 - 插入一個 inferred member(推定成員) —— 只有自然鍵、屬性為
unknown的佔位維度列 —— 並讓事實指向它的代理鍵。 - 真正的維度列到了之後,就地更新那個推定列(對佔位列做 Type 1), 已經指向它的事實就會拿到正確的屬性。
- 另一種做法 —— 單一的
-1 Unknown列 —— 會失去連結,之後還得把事實重新對應鍵。
3-5) MERGE / upsert 模式
MERGE 以某個鍵把進來的列對應到目標,並逐列決定要更新、插入還是刪除 ——
這是冪等地套用一批增量資料或一批 CDC 資料的標準做法。
-- Delta Lake / Databricks SQL:
-- apply one batch of CDC changes, latest change per key wins
MERGE INTO silver.customers AS t
USING (
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY customer_id
ORDER BY change_lsn DESC) AS rn
FROM bronze.customers_cdc
WHERE batch_id = '2026-11-01'
) AS ranked
WHERE rn = 1
) AS s
ON t.customer_id = s.customer_id
WHEN MATCHED AND s.op = 'd' THEN DELETE
WHEN MATCHED THEN UPDATE SET
t.email = s.email,
t.status = s.status,
t.updated_at = s.updated_at
WHEN NOT MATCHED AND s.op <> 'd' THEN INSERT (customer_id, email, status, updated_at)
VALUES (s.customer_id, s.email, s.status, s.updated_at);
- 先對來源去重:當多筆來源列匹配到同一筆目標列時,
MERGE會失敗(或結果不確定)。 - 可以的話,在
ON子句裡加上分區條件來限制目標掃描範圍 —— 沒有限制的MERGE會重寫整張表的檔案。 - Snowflake 與 BigQuery 的
MERGE形狀相同,但細節不同(BigQuery 的SET只接受不帶前綴的欄位名);要確認方言。 - 對 SCD2 來說,單靠
MERGE無法用一筆來源列同時關閉舊列又插入新版本;用 staged-union 技巧,或在同一個交易裡寫兩條語句(見./data_modeling.md)。
4) 編排 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
4-1) 編排器負責什麼
編排器負責 task 何時跑、以什麼順序、帶什麼重試、針對哪個邏輯日期 —— 它不該擁有轉換邏輯,也不該自己搬資料。
| 負責 | 不負責 |
|---|---|
| task 之間與管線之間的依賴 | SQL / Spark 程式碼(呼叫它,不要內嵌它) |
| 排程與 data interval | task 之間傳遞大量資料 |
| 重試、逾時、告警 | 業務定義 |
| 回補與重跑 | 儲存 |
| 共用資源的併發限制 |
4-2) 面試官會問的 Airflow 概念
Airflow DAG 是宣告 task 及其依賴的 Python 程式碼;排程器為每個 data interval 建立一個 DAG run,並在上游 task 成功時執行每個 task。
| 概念 | 是什麼 |
|---|---|
| DAG | task 的圖,帶有排程、開始日期與預設參數 |
| Operator | task 範本 —— BashOperator、PythonOperator、SQLExecuteQueryOperator、provider 提供的 operator |
| TaskFlow | 以 @task 裝飾的 Python 函式;回傳值自動透過 XCom 傳遞 |
| Sensor | 等待某個條件(檔案、分區、另一個 DAG)的 task;用 mode="reschedule" 或 deferrable sensor,才不會佔住 worker slot |
| XCom | task 之間傳遞的小型中介資料 —— id、路徑、筆數;不是資料集 |
| 邏輯日期 / data interval | 這次執行針對的區間;2026-11-01 的每日執行在該區間結束後才開始 |
| Catchup | 為 True 時,排程器會為 start_date 以來每個漏掉的 interval 建立執行 |
| 重試 | 每個 task 的 retries、retry_delay、retry_exponential_backoff |
| Pool | 跨 DAG 共用、有名字的 slot 上限,用來限制對某個資料庫的負載 |
| SLA | Airflow 2.x 中每個 task 的 sla= 會觸發 miss callback;Airflow 3.0 移除了它,deadline alert 是後繼者 |
| Dataset / Asset | 資料感知排程:上游 task 更新某個 dataset 時,DAG 就執行(Airflow 2.4+;3.0 改名為 Asset) |
| Dynamic task mapping | .expand() 在執行期為 list 的每個元素建立一個 task instance(Airflow 2.3+) |
經典陷阱:每日 DAG 在邏輯日期 2026-11-01 的那次執行,涵蓋區間
[2026-11-01, 2026-11-02),並在區間結束之後,也就是 2026-11-02 才開始。查詢應該用
data_interval_start / data_interval_end,而不是牆上時鐘。
4-3) 一個小型的 Airflow 2.x DAG
# python — Airflow 2.4+ (TaskFlow API, dynamic task mapping)
from datetime import datetime, timedelta
from airflow.decorators import dag, task
TABLES = ["orders", "customers", "payments"]
@dag(
schedule="@daily",
start_date=datetime(2026, 1, 1),
catchup=False,
max_active_runs=1,
default_args={
"retries": 3,
"retry_delay": timedelta(minutes=5),
"retry_exponential_backoff": True,
},
tags=["ingest"],
)
def daily_ingest():
@task
def list_tables():
return TABLES
@task(pool="oltp_replica") # cap concurrent queries against the source
def extract(table, data_interval_start=None, data_interval_end=None):
# Airflow injects the context values named in the signature
start, end = data_interval_start.isoformat(), data_interval_end.isoformat()
path = f"s3://lake/bronze/{table}/dt={data_interval_start.date()}/"
# ... SELECT ... WHERE updated_at >= start AND updated_at < end, write to path ...
return path
@task
def load_silver(paths):
# ... MERGE each bronze path into silver ...
return len(paths)
@task
def check_counts(n_tables):
if n_tables != len(TABLES):
raise ValueError(f"expected {len(TABLES)} tables, loaded {n_tables}")
paths = extract.expand(table=list_tables()) # one mapped task per table
check_counts(load_silver(paths))
daily_ingest()
它像正式環境的地方:每個 mapped extract 讀固定的 data interval(確定性),寫到以日期
分區的路徑(重跑時冪等),透過 XCom 只傳遞路徑,而來源由 pool 保護。
4-4) Airflow vs Dagster vs Prefect
| Airflow | Dagster | Prefect | |
|---|---|---|---|
| 單位 | DAG 裡的 task | software-defined asset(它產出的那張表) | flow 與 task(一般 Python) |
| 排程 | 依時間,加上 dataset / asset | 以 asset 驅動,分區是一等公民 | 依時間 / 事件,執行期動態決定 |
| 強項 | 生態系、provider,多數公司的預設 | 內建 lineage 與分區、可在本機測試 | 儀式感低、動態工作流 |
| 弱項 | DAG 解析開銷;資料感知是後來才加的 | 生態系較小 | 對重型資料平台來說生態系較小 |
| 託管 | MWAA、Cloud Composer、Astronomer | Dagster+ | Prefect Cloud |
好的面試答案會說出取捨:團隊已經在跑 Airflow、工作是 task 形狀的,就用 Airflow; 想讓編排器理解表與分區時,用 Dagster。
5) dbt 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
5-1) dbt 做什麼
dbt 把 SQL SELECT 語句依依賴順序變成資料倉儲裡的表與 view,並附上測試與文件 ——
它是 ELT 的「T」,不負責把資料搬進搬出。
- 一個 model 是一個包含
SELECT的.sql檔;{{ ref('stg_orders') }}參照另一個 model,並建立依賴圖。 {{ source('app', 'orders') }}宣告原始表,才能對它們檢查新鮮度。dbt build依 DAG 順序執行 model、test、seed 與 snapshot。- Lineage 與文件來自那張圖:
dbt docs generate。
5-2) Materialization
| Materialization | 產出 | 用於 |
|---|---|---|
view |
一個 view | 輕量的 staging model,永遠是最新的 |
table |
每次執行都重建的表 | 中小型 mart |
incremental |
只 insert / merge 新的列 | 大型事實表 |
ephemeral |
內嵌進下游 model 的 CTE | 可重用的邏輯,不產生表 |
materialized_view |
資料倉儲的 materialized view(依 adapter 而定) | 低延遲的彙總 |
-- models/marts/fct_orders.sql — dbt incremental model (dbt-core 1.x)
{{ config(
materialized = 'incremental',
unique_key = 'order_id',
incremental_strategy = 'merge'
) }}
SELECT order_id,
customer_id,
status,
amount,
updated_at
FROM {{ ref('stg_orders') }}
{% if is_incremental() %}
-- only rows changed since the last load, with a 1-day overlap for late updates
WHERE updated_at > (SELECT MAX(updated_at) - INTERVAL '1 day' FROM {{ this }})
{% endif %}
INTERVAL '1 day' 是 PostgreSQL / Snowflake 語法;在 BigQuery 要寫
TIMESTAMP_SUB(MAX(updated_at), INTERVAL 1 DAY)。incremental model 的 schema 變更
需要 on_schema_change 或 --full-refresh。
5-3) 測試、snapshot 與 contract
- YAML 裡的 generic test:
unique、not_null、accepted_values、relationships。 - Singular test:回傳失敗列的 SQL 檔;零列代表通過。
dbt_utils、dbt_expectations這類套件提供筆數、新近度與範圍測試。- Snapshot 在來源表上實作 SCD Type 2(
timestamp或check策略), 加上dbt_valid_from/dbt_valid_to。 - Model contract(
contract: {enforced: true})讓 model 的欄位或型別偏離宣告的 schema 時,build 直接失敗。 dbt source freshness會在來源的loaded_at_field超過門檻時告警。
# models/marts/schema.yml
version: 2
models:
- name: fct_orders
columns:
- name: order_id
tests: [unique, not_null]
- name: status
tests:
- accepted_values:
values: ['placed', 'paid', 'shipped', 'cancelled']
- name: customer_id
tests:
- relationships:
to: ref('dim_customer')
field: customer_id
6) 資料品質與可觀測性 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
6-1) 檢查項目,以及在哪裡跑
在每一層的邊界檢查資料,在壞資料到達使用者之前讓管線停下來 —— 讓一次載入停下來的失敗檢查,比董事會簡報上的錯誤數字便宜得多。
| 面向 | 檢查 | 例子 |
|---|---|---|
| Schema | 欄位與型別符合預期 | 來源把 amount 改名成 total |
| 完整性 | 鍵不可為 null;筆數和歷史比較 | 今天的訂單比過去 4 週同一星期幾的平均少了 40% |
| 唯一性 | 主鍵沒有重複 | 一次重試的擷取讓某個分區翻倍 |
| 有效性 | 範圍、列舉值、格式 | amount < 0、未知的 status |
| 一致性 | 參照完整性、跨表總和 | 事實指向不存在的維度鍵 |
| 新鮮度 | 最新分區 / max(updated_at) 在 SLA 內 |
上游的匯出沒有落地 |
| 準確性 | 和事實來源對帳 | 每日營收 vs 金流系統 |
在哪裡跑:
- 擷取:schema、筆數、檔案完整性 —— 盡早失敗。
- Silver:唯一性、null、有效性 —— 隔離壞的列。
- Gold:業務規則、對帳、異常檢查 —— 阻擋發布。
6-2) 失敗、警告,還是隔離
| 嚴重度 | 動作 | 用於 |
|---|---|---|
| 錯誤 | 讓 task 失敗,阻擋下游 | 壞掉的鍵、schema 漂移、空的載入 |
| 警告 | 告警,繼續 | 小幅的量下滑、少數無效列 |
| 隔離 | 把壞的列寫到旁邊的表,其餘繼續 | 列層級的有效性失敗 |
Write-audit-publish(WAP):把新資料寫到 staging 分支或表,跑檢查,通過後才發布
(交換表、fast-forward 分支 —— Iceberg branch、Nessie,或用 CREATE OR REPLACE 交換)。
使用者永遠看不到沒檢查過的資料。
6-3) 資料契約與可觀測性
- 資料契約(data contract) 是和產出資料的團隊之間的協議:schema、語意、SLA、 負責人,以及變更如何公告。在產出端的 CI 強制執行(schema registry、dbt model contract), 就能把「來源變了」從一次事故變成一個失敗的 pull request。
- 可觀測性 = 監控資料,而不只是監控 job:每張表的新鮮度、量、schema 與分佈指標, 並在它們的歷史上做異常偵測(Monte Carlo、Elementary、Soda,或自建的指標表)。
- Lineage(OpenLineage、dbt docs、DataHub 或 Unity Catalog 這類 catalog)回答事故中的 兩個問題:是什麼弄壞了這張表,以及誰會受影響。
7) 檔案格式與表格格式 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
7-1) 列式 vs 欄式格式
擷取與訊息用 Avro(列式、內嵌 schema),分析用 Parquet(欄式、壓縮、帶統計資訊), 過了 raw 層就永遠不要用 CSV 或 JSON。
| 格式 | 結構 | Schema | 可切分 | 最適合 |
|---|---|---|---|---|
| CSV | 列式、文字 | 沒有 | 未壓縮時可以;gzip 後不行 | 和人類與舊系統交換資料 |
| JSON lines | 列式、文字 | 沒有 | 未壓縮時可以 | API、半結構化的原始資料 |
| Avro | 列式、二進位 | 內嵌,有演進規則 | 可以 | 擷取、Kafka 訊息、寫入密集 |
| Parquet | 欄式 | 內嵌 | 可以 | 分析掃描 |
| ORC | 欄式 | 內嵌 | 可以 | Hive 生態系 |
欄式格式在分析上勝出,是因為查詢只讀它碰到的欄位,同一欄的值放在一起壓縮效果好, 而每個區塊的統計資訊讓讀取端能跳過資料。
7-2) Parquet 內部結構
一個 Parquet 檔切成多個 row group,每個 row group 為每一欄存一個 column chunk, 每個 chunk 再切成 page,而 footer 存放 schema 以及讓讀取端能跳過 row group 的 min/max 統計。
Parquet file
├── Row group 0 (default target size in parquet-mr: 128 MB)
│ ├── Column chunk: user_id → pages (dictionary page + data pages)
│ ├── Column chunk: country → pages
│ └── Column chunk: amount → pages
├── Row group 1
│ └── ...
└── Footer: schema, row-group metadata, per-chunk min / max / null count
- 編碼:低基數欄位用 dictionary encoding(
country變成小整數 id),上面再疊 run-length 與 bit-packing,最後套一個 codec(Snappy、Zstd、Gzip)。 - Predicate pushdown:
WHERE amount > 1000會跳過amount最大值 ≤ 1000 的所有 row group。只有資料依該欄排序或 cluster 過才有用 —— 在隨機資料上,每個 row group 的 min/max 都涵蓋全部範圍。 - Column pruning:只讀被選取欄位的 chunk。
- 巢狀資料(struct、list)以 repetition level 與 definition level 儲存(Dremel 編碼),所以巢狀欄位也是欄式的。
7-3) 表格格式:Iceberg、Delta Lake、Hudi
表格格式在 Parquet 檔案之上維護一層中介資料,精確記錄表的每個版本由哪些檔案組成 ——
這就是讓 lake 擁有原子提交、MERGE、schema 演進與 time travel 的東西。
每種表格格式相對於「一個裝 Parquet 檔的目錄」多出來的東西:
- 原子提交 —— 讀取者看到的是舊版本或新版本,永遠不會是寫到一半的 job。
- 列層級的
UPDATE/DELETE/MERGE。 - Schema 演進,透過欄位 id 或 mapping,讓改名不會打壞檔案。
- Time travel —— 查詢或回滾到先前的快照。
- 中介資料裡的檔案層級統計 —— 規劃查詢時不用列目錄就能跳過檔案。
- Compaction 與快照過期,作為維護操作。
| Apache Iceberg | Delta Lake | Apache Hudi | |
|---|---|---|---|
| 中介資料 | metadata 檔 → manifest list → manifest | _delta_log JSON 提交 + Parquet checkpoint |
.hoodie 裡的提交時間軸 |
| 分區 | 以轉換函式做隱藏分區(days(ts)、bucket(16, id));分區可演進 |
Hive 風格的欄位;liquid clustering 是另一個選項 | Hive 風格 |
| 列層級變更 | copy-on-write 或 merge-on-read(delete file) | copy-on-write;deletion vector | copy-on-write 或 merge-on-read,為 upsert 而生 |
| 引擎廣度 | 非常廣(Spark、Flink、Trino、Snowflake、BigQuery、Athena) | 在 Spark / Databricks 上最完整;其他靠 connector | Spark 與 Flink 優先 |
| 常見選擇 | 多引擎的 lakehouse | Databricks 使用者 | CDC 密集的 upsert 工作負載 |
Copy-on-write vs merge-on-read:copy-on-write 每次更新都重寫受影響的檔案 (讀快、寫慢);merge-on-read 寫入小的 delete / log 檔,在讀取時合併 (寫快,在 compaction 之前讀會變慢)。
7-4) 小檔案問題與 compaction
大量小檔案會拖慢每一個讀取者 —— 每個檔案都要一次開檔、一次中介資料查詢,常常還要 一個 task —— 所以批次 job 應該寫出幾百 MB 的檔案,而 compaction 應該合併串流或頻繁 小批次留下的檔案。
成因:
- 輸出分區太多 × task 太多(每個 task 對它碰到的每個分區各寫一個檔案)。
- 頻繁的 micro-batch 或串流 sink。
- 依高基數欄位過度分區。
解法:
- 寫入前減少寫入者:
coalesce(n),或依分區欄位repartition, 讓每個分區只由少數 task 寫入。 - Spark AQE 會自動合併 shuffle partition(
spark.sql.adaptive.enabled)。 - 表格維護:Delta
OPTIMIZE(搭配ZORDER BY或 liquid clustering)、Icebergrewrite_data_filesprocedure、Hudi clustering / compaction。 - 讓舊快照過期(
VACUUM、expire_snapshots),儲存與中介資料才不會無限成長。
8) 分區、成本與效能 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
8-1) 為讀取者分區
依幾乎每個查詢都會過濾的欄位分區 —— 通常是事件日期或載入日期 —— 並讓每個分區夠大;
完整規則在 ./schema_design.md。
- 依日期分區同時給讀取者 pruning,也給寫入者一個天然的冪等覆寫單位。
- 在分區內依次常被過濾的欄位做 cluster / 排序,讓 min/max 統計發揮作用。
- 過度分區(依
user_id、在小表上依小時)會產生小檔案並讓查詢規劃變慢。
8-2) 批次的成本花在哪裡
| 成本來源 | 槓桿 |
|---|---|
| 掃描的 bytes(BigQuery on-demand、Athena) | column pruning、分區過濾、clustering、預先彙總 |
| 運算時間(Snowflake warehouse、Databricks cluster) | 調整大小並自動暫停;用增量取代全表重建 |
| 重建沒有變的資料 | incremental model,只 MERGE 有變動的分區 |
| Spark 裡的 shuffle 與傾斜 | broadcast join、修正傾斜(見 ./spark.md) |
| 舊快照與重複資料的儲存 | 快照過期、bronze 的保留政策 |
| 跨區流量(egress) | 讓運算和儲存在同一個區域 |
面試答案裡常見的高影響改動:把大型事實表每晚的全表重建,改成只對有變動的分區做增量 MERGE。
8-3) 效能檢查清單
- 把過濾與投影下推到來源擷取;不要從正式資料庫
SELECT *。 - 從 read replica 或 CDC 串流擷取,而不是 primary。
- 模型允許時,用整數代理鍵 join,而不是長字串。
- 在 gold 為 dashboard 預先彙總;不要讓 BI 工具掃 silver。
- 調校前先量測:查詢 profile /
EXPLAIN、Spark UI 的 stage 時間,以及 DAG 關鍵路徑上 最慢的 task。
9) 故障處理與重跑 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
9-1) 為失敗而設計
假設每個 task 都會在某個時候跑到一半失敗;讓每個 task 都原子化且冪等, 修復方式就永遠是「重跑那個 task」。
- 原子化的輸出:先寫到暫存位置或 staging 表,再交換或提交
(表格格式的提交、
ALTER TABLE ... EXCHANGE PARTITION、CREATE OR REPLACE)。 寫到一半的結果就永遠不會被看到。 - 對暫時性錯誤(網路、限流)以指數退避重試;對確定性錯誤不重試 (schema 不符每次都會以同樣方式失敗)。
- 每個 task 都設逾時,讓卡住的查詢不會永遠擋住整次執行。
- 長時間的擷取要有 checkpoint(最後的 cursor、最後的檔案),讓重試能接續。
- 依賴資料,而不是時間:用 sensor 或 dataset 觸發,讓遲到的上游延後這次執行, 而不是產出一個空分區。
9-2) 事故處理手冊
- 偵測 —— task 失敗、品質檢查失敗,或新鮮度告警。
- 控制 —— 停止下游發布;對使用者標記受影響的表為過期。
- 診斷 —— lineage 告訴你哪個上游變了;把失敗的分區和最後一個正常的分區比較 (筆數、schema、相異鍵數)。
- 修復 —— 改程式碼、請來源重送,或改設定。
- 重跑 / 回補 受影響的 interval,從最舊的開始;冪等性讓這件事是安全的。
- 驗證 用同一套檢查,然後發布。
- 預防 —— 加上本來能更早抓到它的那個檢查。
9-3) 常見的故障原因
| 原因 | 症狀 | 預防 |
|---|---|---|
| 上游 schema 變更 | 載入或型別轉換錯誤 | 擷取時的 schema 檢查、資料契約 |
| 上游資料遲到 | 空的或不完整的分區 | sensor、完整性檢查、筆數門檻 |
| 重複擷取 | 指標虛胖 | 冪等寫入、唯一性測試 |
| 資料傾斜或量成長 | job 記憶體不足或錯過 SLA | 量的監控、傾斜處理、自動擴展 |
| 憑證 / 權限變更 | 驗證失敗 | secrets manager、對驗證錯誤告警 |
| 非確定性邏輯 | 重跑時數字改變 | 邏輯日期參數、全序排序 |
10) 安全、PII 與刪除 優先度 3/5 — 值得會 — 多半是必備模式的變形
10-1) 處理 PII
在擷取時就分類 PII,讓它待在盡可能少的表裡,而且大多數使用者只能拿到遮罩或 token 化後的版本。
- 在 catalog 裡用標籤分類欄位(email、電話、地址、身分證號)。
- 最小化:用例不需要的 PII,在進 silver 之前就丟掉。
- 假名化:用帶金鑰的雜湊(帶 secret 的 HMAC)或 vault 發的 token 取代識別碼, join 仍然可行,但原始值不會暴露。沒有加鹽的 email 雜湊可以用字典攻擊還原。
- 在查詢時用欄位層級政策遮罩(Snowflake masking policy、BigQuery policy tag、 Unity Catalog column mask),加上列層級安全。
- 靜態與傳輸中都加密;raw 層的存取只限管線的 service account。
10-2) 被遺忘權的刪除
GDPR / CCPA 的刪除請求必須觸及每一份副本 —— raw、silver、gold、快照與備份 —— 這就是為什麼不可變的 raw 層需要一開始就設計好刪除策略,而不是事後再加。
- 維護一張刪除請求表;排程 job 依當事人 id,在每張表刪除他的列
(在 Iceberg / Delta / Hudi 或資料倉儲上用
DELETE/MERGE)。 - 表格格式會為了 time travel 保留舊檔案:之後要跑快照過期與
VACUUM, 否則被刪除的列仍然存在儲存裡。 - Crypto-shredding:用每個當事人專屬的金鑰加密他的 PII,然後刪掉金鑰 —— 在重寫不可變資料或備份不切實際時很有用。
- 盡量不要讓 PII 進入難以重寫的只附加 raw 檔;從一開始就落進表格格式。
11) 完整的設計題 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
11-1) 「設計一條從 OLTP 資料庫到報表資料倉儲的每日管線」
先釐清需求,再從左到右走過各個階段,並把最多時間花在正確性上:增量擷取、 冪等載入、歷史,以及品質關卡。
要問的釐清問題:
- 哪些表、多大、變動多快?資料列會被硬刪除嗎?
- 新鮮度需求 —— 每天 06:00 前,還是每小時?
- 報表需要歷史嗎(客戶去年三月的等級是什麼)?
- 我們可以讀 primary、replica,還是 binlog / WAL?
- 誰使用輸出 —— BI dashboard、財務對帳、ML?
針對「orders、customers、products;約 5,000 萬筆訂單;每天 06:00 前;有硬刪除; customers 需要歷史」的一個設計:
Postgres primary ─► read replica ─► Debezium ─► Kafka ─► S3 bronze (Parquet, dt=load_date)
│
Airflow daily DAG (data interval = yesterday) ─────────────────┤
1. sensor: bronze partition complete (Connect offsets / row-count manifest)
2. dbt staging: dedupe latest change per key, cast types → silver (Iceberg)
3. dbt snapshot: dim_customer SCD2 → silver
4. dbt incremental: fct_orders MERGE on order_id, 3-day late window → gold
5. dbt tests + reconciliation vs source counts (block on error)
6. publish: swap gold views / notify; freshness metric recorded
│
BI (Looker / Tableau) on gold
每個選擇的理由:
| 決策 | 理由 |
|---|---|
從 replica 做 CDC,而不是在 primary 上 SELECT |
抓得到硬刪除;不對正式資料庫造成負擔 |
| Bronze 只附加,依載入日期分區 | 不可變的重播來源;每個載入日期都冪等 |
| Silver 用 Iceberg / Delta | 在 lake 上做 MERGE 與刪除;用 time travel 除錯 |
| customers 用 SCD2 | 歷史需求 |
| 事實表 3 天的重處理窗口 | 遲到的更新(退款、狀態變更)落進正確的事件日期分區 |
| 測試阻擋發布 | 使用者永遠看不到壞掉的一天 |
| 每個 task 都用固定的 data interval | 回補與重跑得到相同結果 |
要準備的追問:如果不允許 CDC(依 updated_at 加重疊窗口做增量,再加每週一次全量
快照比對來抓刪除);如果量成長 100 倍(分割擷取、重型轉換交給 Spark、clustering、
全部增量化);如果業務需要每小時(同樣的設計改成每小時 interval,或改成串流 ——
見 ./stream_pipeline.md)。
12) 快問快答 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
12-1) 面試現場會出現的問題
- 問:怎麼讓管線冪等? —— 每個 task 都以邏輯日期為參數,讀固定的切片,
並覆寫或
MERGE它的輸出,而不是附加。 - 問:怎麼處理遲到的資料? —— 重新處理最近一段事件日期分區的窗口,或依抵達日期分區,
或把遲到的列
MERGE到正確位置;依資料會遲到多久、重寫要花多少成本來選。 - 問:ETL 還是 ELT? —— 分析預設用 ELT;資料必須先轉換才能落地(PII)或轉換不是 SQL 形狀時,用 ETL。
- 問:增量載入怎麼偵測刪除? —— 用
updated_at做不到;用 CDC、軟刪除旗標, 或定期的全鍵快照比對。 - 問:為什麼用 Parquet? —— 欄式:只讀需要的欄位、壓縮效果好,而 min/max 統計讓讀取端能跳過 row group。
- 問:表格格式多了什麼? —— 在 Parquet 檔案之上加入原子提交、列層級變更、 schema 演進、time travel 與檔案層級統計。
- 問:XCom 裡傳什麼? —— 路徑、筆數這類小型中介資料;資料透過儲存傳遞。
- 問:某個 job 很慢,你先看哪裡? —— DAG 的關鍵路徑,接著是最慢的 stage 或查詢 profile:傾斜、本該 pruning 卻全表掃描,或本可增量卻全表重建。
- 問:怎麼安全地回補一整年? —— 冪等的 task、限制併發、從最舊的開始、寫到新表或 新分區、和舊輸出比對驗證,然後切換。
- 問:怎麼阻止壞資料進到 dashboard? —— 每一層都有品質檢查、錯誤時阻擋, 並用 write-audit-publish,只發布檢查過的資料。