Airflow 排程的真相:data interval、catchup 與 backfill
· tech
#airflow#data-engineering#scheduling
上一篇我們把 DAG 跑起來了,還特別把 catchup 設成 False「保平安」。這篇就來拆解 Airflow 最反直覺、也最多人卡住的部分:排程到底怎麼運作。搞懂這一段,你才真的會用 Airflow。
最關鍵的心智轉變:一個 run 代表「一段時間區間」
新手最大的誤解,是以為「@daily 的 DAG 在每天 00:00 觸發、處理當下的資料」。錯。Airflow 的世界裡:
每一次 DAG run 都對應一個資料區間(data interval)
[start, end),而這個 run 會在區間結束之後才被觸發。
也就是說,負責「6/18 這天」的 run,不是在 6/18 跑,而是在 6/19 00:00(6/18 區間結束時)才跑 —— 因為要等 6/18 這天的資料都齊了,才處理得了它。
這也解釋了那個惡名昭彰的欄位 logical_date(舊名 execution_date):它不是「執行時間」,而是「這個 run 代表的區間起點」。難怪以前叫 execution_date 時大家全被搞混。
四個排程相關的關鍵字
| 參數 / 概念 | 意義 |
|---|---|
schedule | 多久跑一次:cron("0 3 * * *")、preset(@daily)、timedelta(hours=6)、None(只手動)。它定義了區間怎麼切。 |
start_date | 第一個資料區間的起點(不是「開始運作的時間」)。必須是靜態時間,別用 datetime.now()。 |
data_interval_start / end | 這個 run 負責的區間 [start, end);run 在 end 之後觸發。 |
catchup | 啟用 DAG 時,要不要把 start_date 到現在錯過的區間全部補跑。 |
from datetime import datetime, timedelta
schedule="@daily" # preset:@hourly / @daily / @weekly / @monthly
schedule="0 3 * * *" # cron:每天 03:00
schedule=timedelta(hours=6) # 每 6 小時
schedule=None # 不排程,只手動觸發
catchup:那個「一開就噴出一堆 run」的兇手
假設你 start_date=6/15、schedule="@daily",然後在 6/18 才打開這個 DAG:
catchup 的預設值是 True(由設定 catchup_by_default 控制)。所以如果你把 start_date 設在三個月前、又沒關 catchup,一啟用 DAG 就會瞬間排出近百個 run —— 這就是 上一篇叫你先 catchup=False 的原因。
backfill:刻意回補,跟 catchup 不一樣
- catchup:啟用 DAG 時自動補跑錯過的區間。
- backfill:你刻意去重跑某段歷史區間(改了邏輯要重算、或某天資料當初壞了)。用 CLI:
airflow dags backfill daily_report \
--start-date 2026-06-01 --end-date 2026-06-07
兩者能成立的前提都一樣:task 必須冪等(可重跑不出錯)—— 這正是 第一篇強調的心智。
把區間用對:run 該吃自己那段資料
最後一塊拼圖:task 裡要用 區間時間決定處理哪段資料,絕不要用 datetime.now()。這樣同一個 run 不管是即時跑、補跑、還是重跑,結果都一致。
from airflow.decorators import dag, task
from datetime import datetime
@dag(
schedule="@daily",
start_date=datetime(2026, 6, 1), # 靜態起點
catchup=False,
tags=["scheduling"],
)
def daily_report():
@task
def process(**context):
start = context["data_interval_start"] # = logical_date
end = context["data_interval_end"]
# 用「區間」決定要處理哪天的資料,而不是 now()
partition = start.strftime("%Y-%m-%d")
print(f"處理區間 {start} ~ {end},覆寫分區 {partition}")
process()
daily_report()
(傳統 Operator 裡則用模板變數:{{ ds }} = 區間起點的 YYYY-MM-DD、{{ data_interval_start }}、{{ data_interval_end }}。)
Airflow 3.x 小提醒:
execution_date已正式移除,一律用logical_date;而且 3.x 裡手動觸發的 run 可能沒有資料區間(logical_date為None),所以別在程式裡假設它一定存在 —— 真要用區間,以排程觸發為準。
反思
「run 代表區間、在區間結束才跑」是整個 Airflow 的鑰匙
我認為這是學 Airflow 投報率最高的一個觀念。一旦你不再把 logical_date 讀成「執行時間」,而是「這批 run 負責的那段資料的起點」,後面所有看似奇怪的行為(為什麼半夜才跑、為什麼 6/18 的報表 logical_date 是 6/18 卻在 6/19 出現)就全部通了。我看過太多人卡在這、甚至在 task 裡硬用 now() 去算「今天」,結果一 backfill 整個資料就錯亂。
catchup 預設 True 是個甜蜜陷阱
它預設 True 是有道理的(資料管線本來就該補齊歷史),但對「邊學邊試」或「臨時補個 DAG」的人來說,配上一個放在過去的 start_date,就是一場 run 海嘯。我的習慣是:新 DAG 一律先寫死 catchup=False,等到我真的想要它補歷史、而且確認 task 冪等(第一篇那條紀律)時,才有意識地打開。預設值幫你做的決定,往往不是你當下要的決定。
把 now() 趕出 task,是可重跑的根本
這點跟 第一篇的冪等、以及之後會寫的可靠性是同一件事的不同面向:run 處理哪段資料,應該由它的 data interval 決定,而不是由「現在幾點」決定。做到這件事,backfill、重試、災難重跑才會給出一致結果;做不到,你的 pipeline 就永遠只能「當下跑剛好對」,一回頭補資料就爆。