ETL 管線
使用 Python (uv, ruff, pytest) 進行批次資料擷取至 PostgreSQL(預設儲存)。本機轉換:polars 2.x 或 DuckDB 2.0。
流程
- 定義資料粒度:一列代表什麼,由哪個自然鍵來識別。沒有鍵就沒有冪等載入;請先定義一個(或計算內容雜湊值)。
- 選擇載入模式(如下)。選擇增量策略。最後再選擇調度器。
- 撰寫執行邏輯,使重新執行任何時間區段都能產生相同的終止狀態。證明它:在測試中執行兩次,並斷言列數與校驗和保持不變。
- 在首次生產環境執行前,加入可觀測性檢查(如下)。
- 對照章節末的審查清單進行審查。
冪等載入模式
- 資料列具有穩定的自然鍵且可能會變更:使用
INSERT ... ON CONFLICT (key) DO UPDATE(PostgreSQL) 或MERGE。加上WHERE target.hash IS DISTINCT FROM excluded.hash,使未變更的列不會觸發更新。 - 資料以完整分割區(天、批次 ID)形式到達,並整體重寫:在單一交易中對該分割區執行刪除-插入。若刪除與插入在不同交易中提交,則當當機時會留下空洞。
- 重建整個資料表:載入到暫存資料表,驗證,然後交換(在單一交易中重新命名)。讀取程式永遠不會看到半載入的資料表。
- 僅追加的事實表(無鍵):加入確定性的
batch_id/ 來源檔案雜湊值,並讓載入程序拒絕或取代已載入的批次 ID。 - 切勿盲目地執行
INSERT然後事後去重。重複資料會在被去重之前到達儀表板。 - 透過
COPY載入到暫存/臨時資料表,然後執行一次基於集合的更新插入。從 Python 逐列更新插入的速度會慢 10 到 100 倍。
增量載入
- 根據來源保證為單調遞增且已建立索引的資料欄(
updated_at、序列 ID)設定水位線。將其儲存在狀態表中,並在與載入相同的交易中寫入,絕不存放在旁側的檔案中。 - 讀取
watermark - lookback(例如 1 到 24 小時),而非使用> watermark。來源的提交並非按時間戳記排序;回顧視窗加上更新插入可吸收延遲到達的資料列。根據測量到的延遲程度來設定回顧視窗大小,而非憑猜測。 - 水位線資料欄不可靠(更新時未遞增、時區混亂、軟刪除遺漏):執行定期完整調和(按分割區進行鍵值集合差異比較或計數加校驗和)而非信任它。
- 來源端的硬刪除對水位線是不可見的。明確做出決定:透過鍵值差異來調和,或是接受並記錄此限制。
- CDC(PostgreSQL 邏輯複寫、Debezium 風格的日誌讀取器):僅在需要處理刪除、次分鐘級延遲,或無法按時間戳記查詢來源時使用。它會引入複寫插槽、保留風險(空閒的插槽會填滿來源磁碟)以及排序/快照處理問題。否則請繼續使用水位線。
回填
- 每個任務都接受
--start和--end(左閉右開區間[start, end),UTC),並從中推導出所有參數。轉換邏輯中不得使用now()。 - 回填就是在舊時間區段上執行一般的任務。如果需要不同的程式碼,代表一般任務不是冪等的;請修正它。
- 按分割區進行區塊化,獨立執行區塊,記錄每個區塊的狀態,以便在第 40 個區塊失敗時能從第 40 個區塊繼續執行。
- 透過相同的更新插入/分割區取代操作,回填到同一個目標。檢查無重複計數:在時間區段前後執行
count(*)和count(distinct key)。 - 對來源 API/資料庫進行限流;回填是自找的負載尖峰。
重試、逾時、「精確一次」
- 在網路環境中不存在精確一次的交付。建立至少一次交付加上冪等寫入;其結果為有效一次。
- 僅重試冪等步驟,搭配指數退避和抖動、嘗試次數上限,以及每個網路呼叫的強制逾時設定(分別設定連線和讀取逾時)。在遇到 429/5xx/逾時時重試,而非 4xx。
- 遵循
Retry-After標頭。沒有全域速率限制的重試會放大服務中斷的影響。 - 會發送電子郵件、執行收費或傳送到 webhook 的步驟並非冪等:在呼叫前使用儲存的冪等金鑰進行保護。
- 最終失敗時,以非零狀態碼退出並發出警示;絕不要為了維持排程綠燈而吞掉異常。
結構飄移
- 落地層是寬鬆的(將原始酬載儲存為 JSON/文字,加上載入中繼資料)。型別化層是嚴格的,並在違反型別或必要欄位規則時大聲失敗。
- 新增資料欄:可以接受並觸發警示。刪除、重新命名或變更型別的資料欄:使執行失敗並顯示差異;不要將其強制轉換為 NULL。
- 在邊界處使用明確的結構描述(polars schema、pydantic 或 DuckDB
DESCRIBE)進行驗證,僅一次,而非散落在轉換邏輯中。
爬蟲至倉儲
- 分兩個階段。階段 1 擷取並儲存原始回應,保持不變且不可變(物件路徑以來源、擷取時間、內容雜湊為鍵)。階段 2 將原始資料解析為資料表。
- 之後修正解析器錯誤時,只需重新解析已儲存的原始資料,無需重新擷取、無封鎖風險,也不會遺失歷史記錄。
- 在每個已解析的資料列上記錄 URL、擷取時間戳記、HTTP 狀態碼、解析器版本。解析是原始位元組加上解析器版本的純函數。
- 透過內容檢查來偵測擷取失敗和空白/被封鎖的頁面(驗證碼、回傳 200 但顯示登入牆),而非僅依賴狀態碼。
可觀測性(最低要求)
- 記錄每個步驟:讀取資料列數、寫入資料列數、拒絕資料列數、持續時間、時間區段起迄、執行 ID。使用結構化(JSON)格式。
- 斷言不變量:輸出與輸入的資料列數在容許範圍內;對於通常非空白的時間區段若輸出為零列,使執行失敗。靜默的成功零列輸出是最常見的未偵測錯誤。
- 心跳:在成功時和開始時 ping 一個外部健康檢查端點;在錯過 ping 時發出警示(可偵測 cron 未觸發、主機下線)。失敗時也 ping 失敗端點。
- 對資料新鮮度(最大已載入時間戳記超過閾值)發出警示,而不僅是任務錯誤。
- 維護執行記錄資料表(執行 ID、時間區段、狀態、計數);它同時也是您的回填帳本。
選擇調度器
- Cron(或 systemd 計時器)+ 單一腳本:一到幾個獨立任務,無跨任務依賴關係,團隊能閱讀日誌。預設選項。需要上述的心跳機制和一個鎖(
flock)以防止重疊執行。 - n8n:低資料量、SaaS API 之間的膠水、由 webhook 觸發、非開發人員編輯流程。不適合大量批次、繁重轉換或程式碼審查;將重邏輯保留在其呼叫的 Python 腳本中。
- Airflow 3.x:大量 DAG、跨任務依賴、一流的回填、重試和 SLA、審計 UI。實際的營運成本(排程器、中繼資料資料庫、工作者);不適合只有少量任務的情況。從 Airflow 2 遷移到 3 是一次包含破壞性變更的遷移;在複製程式碼片段前請先確認版本。若要在 Airflow 上使用 dbt,請使用 Astronomer Cosmos。
- dlt:適合那些需要結構推斷、增量狀態和合併/取代處理的 API 或資料庫擷取。用它來替代手寫的分頁擷取 + 水位線 + 更新插入程式碼;但仍需對其設定套用上述規則。
- 容器:將敏感資料設定為 Compose secrets,從
/run/secrets/<name>讀取,絕不使用環境變數或 env_file;將已發佈的連接埠繫結到127.0.0.1或將其保留在內部網路上。
供應商技能
當任務需要 dbt 或 Airflow 的特定細節(模型、測試、DAG 撰寫、運算子、Cosmos 設定)時,請勿即興發揮:應使用透過此倉庫的 de-vendors 技能引入的官方供應商技能,並且僅在需要確認當前版本時,才回退到 references/landscape-2026-11.md 中的版本。
審查清單
- 已定義鍵值和資料粒度;重新執行相同時間區段會產生相同的狀態(已測試)。
- 刪除和插入在同一交易中;水位線與載入一起提交。
- 存在回顧視窗;對延遲資料和刪除有明確的處理方式。
- 已設定逾時、有限次數的重試(含退避)、對非冪等步驟有保護措施。
- 原始落地層不可變;解析可分離且可重新執行。
- 記錄每個步驟的資料列數;已設定零資料列和新鮮度警示;已設定心跳。
- 結構飄移在型別化層會大聲失敗。
- 調度器的選擇是根據任務數量和依賴關係,而非習慣。
具體的 SQL 和 Python 骨架:references/patterns.md。來自真實使用的經驗教訓:references/gotchas.md。