本章将介绍
- 如何在规则或不规则的时间点运行 DAG
- 如何利用数据区间(data intervals)进行增量数据处理
- 如何借助回填(backfilling)加载并重新处理已处理过的数据
- 如何应用最佳实践来提升任务可靠性
在前两章中,我们了解了 Airflow 的 UI,也学会了如何定义一个基础的 Airflow 有向无环图(DAG),并通过设置调度区间让它每天运行一次。在本章中,我们将更深入地探讨 Airflow 中的调度机制,并看看它如何让我们能够以固定间隔对数据进行增量处理。首先,我们会引入一个小型用例:分析网站上的用户事件,并探索如何构建一个 DAG,使其能够在固定时间点定期分析这些事件。接着,我们会进一步探讨如何通过增量方式分析数据来提高这个过程的效率,以及它与 Airflow 的调度区间概念之间的关系。我们还会看一种基于特定事件时间的调度选项。最后,我们会展示如何使用回填来补齐数据集中的空缺,并讨论一个良好的 Airflow 任务应具备的重要特性。
3.1 处理用户事件
为了理解 Airflow 的调度是如何工作的,我们来看一个小例子。假设我们有一个服务,用来追踪用户在网站上的行为,并允许我们分析用户(通过 IP 地址标识)访问了哪些页面。出于营销目的,我们想知道用户每次访问期间浏览了多少页面,以及他们在网站上停留了多长时间。为了了解这种行为是如何随时间变化的,我们希望每天都计算一次这些统计指标,以便对比不同日期以及更长时间范围内的变化。事件数据大致如下所示:
{"user":"132.242.6.226","timestamp":"2025-08-01T23:51:38.739617945"}
{"user":"220.187.70.44","timestamp":"2025-08-01T23:55:58.075215591"}
{"user":"30.147.208.138","timestamp":"2025-08-01T23:58:41.352037055"}
出于实际原因,这个外部追踪服务不会保存超过 30 天的数据。而我们希望保留更长时间的历史记录,因此必须自行存储并累计这些数据。通常来说,由于原始数据量可能相当大,比较合理的做法是把这些数据存储到云存储服务中,比如 Amazon 的 S3 或 Google 的 Cloud Storage,这类服务兼具高持久性和相对较低的成本。不过为了简化起见,这里我们会把数据存储在本地。
为了模拟这个示例,我们创建了一个简单的(本地)API,使我们能够获取用户事件。我们可以通过下面这个 API 调用,获取过去 7 天内可用的全部事件列表:
curl -o /tmp/events.json http://events-api:8081/events/latest
这个调用会返回一个 JSON 编码的用户事件列表,我们可以基于它来分析并计算用户统计信息。借助这个 API,我们可以把工作流拆成两个任务:抓取用户事件和计算统计指标。数据本身可以像第 2 章中那样,用 BashOperator 下载。至于统计计算,我们可以使用 PythonOperator,这样就能把数据加载到 pandas DataFrame 中,再通过 groupby 和聚合操作计算事件数量。这样就得到了下面这个 DAG(dags/01_unscheduled.py)。
代码清单 3.1 初始的、未调度的事件处理 DAG
from pathlib import Path
import pandas as pd
from airflow.providers.standard.operators.bash import BashOperator
from airflow.providers.standard.operators.python import PythonOperator
from airflow.sdk import DAG
def _calculate_stats(input_path, output_path):
"""Calculates event statistics."""
events = pd.read_json(input_path) #1
stats = events.groupby(["date", "user"]).size() #1
➥ .reset_index() #1
Path(output_path).parent.mkdir(exist_ok=True) #2
stats.to_csv(output_path, index=False) #2
with DAG(
dag_id="01_unscheduled",
schedule=None, #3
):
fetch_events = BashOperator(
task_id="fetch_events",
bash_command=(
"mkdir -p /data && "
"curl -o /data/events.json
➥ http://events-api:8081/events/latest" #4
),
)
calculate_stats = PythonOperator(
task_id="calculate_stats",
python_callable=_calculate_stats,
op_kwargs={
"input_path": "/data/events.json",
"output_path": "/data/stats.csv",
},
)
fetch_events >> calculate_stats #5
#1 加载事件数据并计算所需统计信息
#2 确保输出目录存在,并把统计结果写入 CSV
#3 指定该 DAG 不进行调度
#4 从 API 抓取事件并保存
#5 设置任务的执行顺序
3.2 Airflow 调度的基本组成部分
我们现在已经有了一个基础 DAG,但还需要确保 Airflow 能够定期运行它。我们会把它调度成每天运行一次,这样就能每天得到一次用户统计更新。
在 Airflow 中,调度由 4 个参数定义:start_date(必需)、end_date(可选)、schedule(必需)和 catchup(可选)。这些参数本质上定义了 DAG 的运行边界。
start_date 和 end_date 参数指定了 Airflow 会考虑执行该 DAG 的时间窗口(见图 3.1)。在这个窗口内,schedule 参数则决定 DAG 到底在什么时候执行(比如每天、每小时等等)。这些调度既可以是基于时间的(本章讨论),也可以是基于资产的(第 4 章讨论)。默认情况下,DAG 是没有调度的(schedule=None),因此除非手动触发,否则它不会运行。
图 3.1 Airflow 中规则的基于时间的调度示例:流水线会在给定的开始和结束日期之间,按固定间隔(schedule)定期执行。如果没有设置结束日期,那么流水线会无限期持续执行。
catchup 参数决定 Airflow 是否还应该对过去错过的时间点执行 DAG。默认情况下(catchup=False),Airflow 不会为过去错过的时间点执行 DAG;它只会考虑未来,也就是从 DAG 被启用的时刻开始(见图 3.2A)。如果为 DAG 启用了 catchup,那么 Airflow 也会从 start_date 开始,为过去的时间点补调度 DAG 运行(见图 3.2B)。
图 3.2 catchup 参数对 Airflow 调度的影响。catchup=False(A)时,Airflow 不会对 DAG 启用之前的历史时间点执行流水线;catchup=True(B)时,Airflow 会为过去的时间点安排运行。
3.3 使用基于触发的调度按规则时间运行
为了看看这个例子在实践中是如何工作的,我们将使用 Airflow 3 默认的基于时间的调度类型:trigger-based schedules(基于触发的调度) 。我们先从让 DAG 每天运行一次开始,这样就能每天更新一次用户统计数据。
警告
Airflow 2 和 Airflow 3 之间的调度行为有较大变化。详情请参见侧栏“Airflow 2 与 Airflow 3 调度行为的变化”。
AIRFLOW 2 与 AIRFLOW 3 调度行为的变化
在使用字符串 cron 表达式(例如 schedule="5 4 * * *")或 Airflow 预设(例如 schedule="@daily")定义调度时,Airflow 2 和 Airflow 3 的默认行为发生了显著变化。
在 Airflow 3 中,这些字符串表达式会产生基于触发的调度(见 3.3.1 节);而在 Airflow 2 中,它们产生的是基于区间的调度(见 3.4 节)。为了避免混淆,本书会尽量避免直接使用字符串表达式,而是在可能的情况下显式引用 timetable 类(例如 3.3.1 节中的 CronTriggerTimetable),因为这种写法在 Airflow 2 和 Airflow 3 中应表现一致。
另一个重大变化是:在 Airflow 2 中默认开启的 catchup,在 Airflow 3 中默认关闭了。若要在 Airflow 3 中启用 catchup,你必须显式给 DAG 传入 catchup=True,或者通过 Airflow 配置中的 scheduler.catchup_by_default 设置来改变默认值。
3.3.1 定义每日调度
为了把我们的 DAG 调度为每天执行一次,我们需要为 DAG 指定一个开始日期,并配合一个每日调度,如下一个代码清单所示(dags/02_trigger_cron.py)。我们可以使用 CronTriggerTimetable 类来定义这个每日调度,它需要一个时区和一个 cron 表达式。关于 cron 表达式的更多细节,见 3.3.2 节。在这里,我们先使用协调世界时(UTC)时区——它也是 Airflow 的默认设置——以及 cron 表达式 "0 0 * * *",表示每天午夜运行一次 DAG。
代码清单 3.2 定义一个每日调度
from airflow.timetables.trigger import CronTriggerTimetable
with DAG(
dag_id="02_daily_schedule",
start_date=pendulum.datetime(year=2025, month=1, day=1), #1
end_date=pendulum.datetime(year=2025, month=1, day=5) #2
schedule=CronTriggerTimetable("0 0 * * *", timezone="UTC"), #3
...
):
#1 从 2025 年 1 月 1 日开始运行 DAG
#2 在 2025 年 1 月 5 日停止运行 DAG
#3 安排 DAG 每天午夜运行一次
结合 start_date 和 end_date,这段代码的效果是:从 1 月 1 日 00:00(开始日期)起,到 1 月 5 日(结束日期)止,让我们的 DAG 每天午夜(00:00 UTC)运行一次,如图 3.3 所示。
图 3.3 指定开始日期(2025-01-01)和结束日期(2025-01-05)的每日调度 DAG 的执行情况;这些日期会阻止 DAG 在该范围之外继续执行。
你也可以省略结束日期,这样 DAG 就会无限期持续运行(见图 3.4)。不过目前我们先保留结束日期,以避免生成潜在数量很大的 DAG runs。
图 3.4 省略结束日期后,可以让 DAG 无限期持续运行。
如果你现在启用这个 DAG,Airflow 仍然不会执行任何运行,因为这些执行时间点全都已经在过去,而 Airflow 3 默认又关闭了 catchup。(在 Airflow 2 中,catchup 默认是开启的。)你可以像下面这样启用 catchup 来改变这一行为(dags/02_trigger_cron.py);这样一来,Airflow 就会开始执行过去日期的运行。
代码清单 3.3 为过去的运行启用 catchup
with DAG(
dag_id="02_daily_schedule",
start_date=pendulum.datetime(year=2025, month=1, day=1),
schedule=CronTriggerTimetable("@daily", timezone="UTC"),
catchup=True, #1
):
#1 启用 catchup,从而执行过去的运行
这段代码确保了我们的 DAG 会在 2025 年 1 月 1 日到 1 月 5 日之间每天运行一次,并且都在 00:00 UTC 执行。不过这个 DAG 仍然有一个局限:我们依旧把每次运行的输出都写到同一个文件(/data/events.json)里。这样,每一次 DAG run 都会覆盖上一次运行的结果,导致我们无法积累历史结果。
解决这个问题的一种办法,是修改 DAG,让它把输出写到与 DAG 执行日期对应的文件里(例如 /data/2025-01-01.json)。幸运的是,Airflow 会在任务的执行上下文中提供这些信息,我们会在第 5 章详细介绍。现在你只需要知道:这个上下文中包含一个名为 logical_date 的参数,它表示 DAG 这次执行对应的日期。我们可以借助 Airflow 基于 Jinja 的模板功能(第 5 章会详讲),把这个参数动态插入到 Bash 命令中,如下面的代码清单所示(dags/02_trigger_cron.py)。
代码清单 3.4 使用模板来指定日期
fetch_events = BashOperator(
task_id="fetch_events",
bash_command=(
"mkdir -p /data/events && "
"curl -o /data/events/{{ logical_date | ds }}.json" #1
"'http://events-api:8081/events/latest"
),
)
#1 引用 logical_date,把输出写入以日期命名的文件
当 DAG 运行时,这段代码会在调用 API 之前,把 DAG 的执行日期(logical_date)插入到文件名中,从而保证 curl 的输出会被写入一个以该日期命名的文件中。为了针对每一天的事件集计算统计信息,我们还可以修改 calculate_stats 任务,让它在计算统计时只读取当天对应的事件文件,如下所示(dags/02_trigger_cron.py)。
代码清单 3.5 针对每次执行计算统计信息
calculate_stats = PythonOperator(
task_id="calculate_stats",
python_callable=_calculate_stats,
op_kwargs ={
"input_path": "/data/events/{{logical_date | ds}}.json", #1
"output_path": "/data/stats/{{logical_date | ds}}.csv", #2
},
)
#1 只读取当前这次执行对应 JSON 文件中的事件数据
#2 把结果写入一个按执行日期分区的 CSV 文件中
很好!我们现在已经保证,每天都能得到一份最新事件的抽取结果,并且能针对这份每日抽取的数据计算统计信息。不过,这种做法依然有几个限制:首先,我们无法控制 latest 返回的事件时间范围(如果我们之后重跑 DAG,这可能会成为问题);其次,我们的统计结果其实并不是真正按天划分的,因为这个事件抽取中包含的事件不止是过去一天的数据。在 3.4 节中,我们会借助基于区间的调度来解决这些问题。不过在此之前,我们先继续深入了解不同类型的基于触发的调度。
3.3.2 使用 cron 表达式
正如 3.3.1 节所展示的那样,Airflow 允许我们用与 cron 相同的语法来定义调度。cron 是一种基于时间的作业调度器,广泛用于 macOS、Linux 等类 UNIX 操作系统。cron 语法由 5 个部分组成,定义如下:
# ┌─────── 分钟 (0 - 59)
# │ ┌────── 小时 (0 - 23)
# │ │ ┌───── 每月中的第几天 (1 - 31)
# │ │ │ ┌───── 月份 (1 - 12)
# │ │ │ │ ┌──── 星期几 (0 - 6)(周日到周六;
# │ │ │ │ │ 某些系统中 7 也表示周日)
# * * * * *
在这个定义中,当 cron 表达式中的时间 / 日期字段与当前系统时间 / 日期匹配时,对应的作业就会被执行。我们也可以用星号(*)来表示“不受限制的字段”,即 cron 会匹配该字段的所有可能值。
虽然这种基于 cron 的表示方式看上去有些绕,但它确实给了我们很大的灵活性来定义时间间隔。比如,我们可以用以下 cron 表达式定义按小时、按天和按周的间隔:
0 * * * *—— 每小时运行一次(整点运行)0 0 * * *—— 每天运行一次(午夜运行)0 0 * * 0—— 每周运行一次(周日午夜运行)
我们也可以定义更复杂的表达式,例如:
0 0 1 * *—— 每个月的第一天午夜运行45 23 * * SAT—— 每周六 23:45 运行
此外,cron 表达式还允许我们使用逗号(,)来定义值的列表,或使用连字符(-)来定义值的范围。借助这种语法,我们可以构建出在一周中多个工作日、或者一天中多个小时点运行的表达式:
0 0 * * MON,WED,FRI—— 每周一、周三、周五午夜运行0 0 * * MON-FRI—— 每个工作日午夜运行0 0,12 * * *—— 每天 00:00 和 12:00 各运行一次
提示
cron 表达式虽然非常强大,但也可能比较难写,因此在真正放进 Airflow 之前,最好先测试一下你的表达式。幸运的是,网上有很多工具可以帮助你定义、验证,或者用自然语言解释 cron 表达式。(例如,crontab.guru 这个网站就能把 cron 表达式翻译成易读的人类语言。)另外,对复杂 cron 表达式背后的设计理由做一些注释说明,也是一件很有价值的事。这能帮助其他人(包括未来的你自己)在重新阅读代码时,更容易理解这个表达式。
3.3.3 使用简写表达式
Airflow 提供了一些内置的简写表达式,用来描述常见的 cron 调度。在我们的用例中,可以使用 Airflow 的 @daily 简写,它表示 DAG 每晚午夜运行一次,等价于我们前面使用过的 cron 表达式 0 0 * * *。我们可以像代码清单 3.6 那样,把这个简写传给 CronTriggerTimetable(dags/03_trigger_preset.py),也可以直接把它作为 DAG 的 schedule 参数传入。在后一种情况下,传入 @daily 与使用 CronTriggerTimetable("@daily", timezone="UTC") 是等价的。不过,我们更倾向于使用更显式的写法,特别是考虑到 Airflow 2 和 Airflow 3 之间最近发生的行为变化(详情见前面的侧栏“Airflow 2 与 Airflow 3 调度行为的变化”)。
代码清单 3.6 用预设代替 cron 表达式
with DAG(
dag_id="03_trigger_preset",
start_date=pendulum.datetime(year=2025, month=1, day=1),
schedule=CronTriggerTimetable("@daily", timezone="UTC"), #1
catchup=True,
):
#1 传入一个 Airflow 简写表达式,而不是 cron 表达式
除了 @daily 之外,Airflow 还为像按小时、按周运行这样的常见场景提供了其他简写。表 3.1 给出了这些可用简写的概览。
表 3.1 Airflow 常用调度的简写表达式
| 预设 | 含义 |
|---|---|
@hourly | 每小时运行一次,在该小时结束时运行 |
@daily | 每天午夜运行一次 |
@weekly | 每周日午夜运行一次 |
@monthly | 每月第一天午夜运行一次 |
@quarterly | 每季度第一天午夜运行一次 |
@yearly | 每年 1 月 1 日午夜运行一次 |
3.3.4 使用基于频率的 timetable
cron 表达式的一个限制在于,它无法表达某些基于“频率”的调度。那么,如果你想定义一个 DAG 每两天运行一次,该怎么写 cron 呢?你可以勉强写成“每个月的 1 号、3 号、5 号……运行一次”,但这样一到月末就会出问题:DAG 会在当前月最后一天和下个月第一天连续运行,这显然违背了“每两天一次”的目标调度。
这个限制源于 cron 表达式的本质:它定义的是一个会被持续拿去与当前时间匹配的模式,用来判断任务是否应该执行。这种方式的好处是 cron 表达式本身是无状态的,也就是说你不需要记住上一次运行是在什么时候,才能推算下一次运行时间。但正如你所看到的,这种好处是以表达能力受限为代价的。
如果你真的想让 DAG 每两天运行一次怎么办?为了支持这种调度,Airflow 允许你按相对时间频率来定义 schedule。要使用这种基于频率的调度,可以把 pendulum 模块中的一个 Duration 实例作为 schedule 传入,如下所示(dags/04_trigger_frequency.py)。
代码清单 3.7 定义一个基于频率的调度
import pendulum
...
with DAG(
dag_id="04_trigger_frequency",
start_date=pendulum.datetime(year=2025, month=1, day=1),
schedule=DeltaTriggerTimetable(pendulum.duration(days=2)), #1
...
):
...
#1 定义一个基于频率的调度,让 DAG 每两天运行一次
这种方式会让你的 DAG 从开始日期起,每两天运行一次(即在 2025 年 1 月 3 日、5 日等日期运行)。同样地,你也可以用它来让 DAG 每 10 分钟运行一次(使用 duration(minutes=10)),或者每 2 小时运行一次(使用 duration(hours=2))。
默认情况下,基于频率的调度使用 UTC 时区。如果你想使用其他时区,可以在 start_date 参数中提供对应的时区(dags/04_trigger_frequency.py)。
代码清单 3.8 为基于频率的调度指定时区
import pendulum
...
with DAG(
dag_id="03_timedelta",
schedule=DeltaTriggerTimetable(pendulum.duration(days=2)),
start_date=pendulum.datetime(2024, 1, 1, tz="Europe/Amsterdam"), #1
end_date=pendulum.datetime(2024, 1, 5),
catchup=True,
):
...
#1 为基于频率的调度定义一个时区
3.3.5 总结 trigger timetable
由于调度在 Airflow 中是一个如此重要的概念,我们不妨在这里总结一下 trigger timetables,确保我们完全理解 DAG 到底会在什么时候执行。
正如前文所见,我们可以通过两到三个参数来控制 Airflow 何时运行 DAG:start_date、schedule 和(可选的)end_date。Airflow 利用这些参数来确定 DAG 在什么时间范围内可以被执行(介于开始与结束日期之间),以及它应该在什么时刻被触发(由 schedule 决定)。
在基于触发的调度中,schedule 定义了一系列离散的时间点,DAG 就应该在这些时间点执行。一旦时间越过某个这样的点,Airflow 就会立刻为这个 DAG 安排一次执行,使其尽可能快地在该时间点之后运行起来(参见图 3.3)。触发式调度与 cron 这类工具的工作方式很相似。
在 DAG run 执行过程中,Airflow 会提供一个名为 logical_date 的变量,它标识 DAG 这次运行所对应的日期 / 时间。你可以在命令或代码中使用这个变量,来查询对应日期的数据、定义输出文件名等等。
3.4 使用数据区间进行增量处理
即便我们的 DAG 现在已经每天运行一次,并且把输出分别存储到了不同文件中,我们依然还没有完全实现目标。一个明显的问题是:我们的 DAG 仍然每天都在下载和计算整个用户事件目录的数据,这显然不够高效。另一个问题是,虽然我们把输出保存成了不同文件,但这些文件里依然包含了大量重复的行,因为每次处理时,我们始终都把完整事件目录包含了进去。
3.4.1 增量处理数据
解决这些问题的一种方式,是把 DAG 改造成增量加载数据:也就是说,在每个调度区间中,只加载该天对应的事件,并且只针对这些新增事件计算统计结果。采用这种方式时,我们还会把每日事件及其统计结果写入按日期命名的不同文件(即按日期分区),这样既能避免覆盖旧结果,又能让结果随时间不断积累(见图 3.5)。
图 3.5 通过把时间拆分成按天划分的数据块,并让每次执行只处理其中某一天的数据,实现增量抓取与增量处理
这种增量方式比每天抓取和处理整个数据集高效得多;它显著减少了每个调度区间中需要处理的数据量。而且,由于现在我们把数据按天分别存到不同文件中,也就可以随着时间推移逐步积累出一套历史文件——远远超过 API 所提供的 7 天限制。
3.4.2 使用数据区间定义增量调度
为了在工作流中实现增量处理,我们必须修改 DAG,让它只下载某一天的数据。幸运的是,我们可以把 API 调用改成访问 /events/range 端点,它接受开始日期和结束日期作为参数,并返回处于该时间区间内的事件,如代码清单 3.9 所示(dags/05_interval_cron.py)。在这个示例里,start_date 是包含性的,而 end_date 是排他性的,因此实际上我们抓取的是发生在 2024-01-01 00:00:00 到 2024-01-01 23:59:59 之间 的事件。
代码清单 3.9 抓取某个特定时间区间内的事件
fetch_events = BashOperator(
task_id="fetch_events",
bash_command=(
"mkdir -p /data && "
"curl -o /data/{{ logical_date | ds }}.json "
"'http://events-api:8081/events/range?" #1
"start_date=2024-01-01&" #1
"end_date=2024-01-02'" #1
),
)
#1 抓取某个特定时间范围内的数据
如果想抓取的不是 2024 年 1 月 1 日这一天的数据,我们就需要修改这条命令,让它根据 DAG 这次执行所对应的日期,使用不同的开始和结束日期。为此,我们可以把 DAG 切换成 Airflow 中另一种调度方式:data-interval timetables(基于数据区间的 timetable) 。与前面章节中使用的 trigger timetables 不同,data-interval timetables 定义的是一段时间跨度,而不是单个时间点(见图 3.6)。这就使得我们能够把时间切分成离散的小块(区间),并让流水线在这些区间上执行,从而处理区间内的数据。
图 3.6 增量(数据)处理中的基于区间调度窗口,与由触发式调度推导出的窗口之间的对比。对于增量数据处理,通常会把时间划分为离散的时间区间,并在相应区间结束后立即处理该区间中的数据。基于区间的调度方式会显式地为每个区间安排任务运行,并向每个任务准确提供该区间的开始和结束时间。相比之下,基于触发的调度方式只会在给定时间点执行任务,而具体应处理哪个增量区间,则要由任务本身自行判断。
为了看看这在实践中如何工作,我们将把 DAG 切换为使用 CronDataIntervalTimetable,而不是前面的 trigger-based timetable(代码清单 3.10;dags/05_interval_cron.py)。这个切换会带来几件事:
- DAG 不再会在区间开始时执行,而会在区间结束时执行。这个行为很合理,因为我们不可能去处理未来的数据;必须等到区间结束后,该区间中的全部数据才是可用的。
- Airflow 会在执行上下文中额外提供两个参数:
data_interval_start和data_interval_end,它们分别表示 DAG 这次执行所对应区间的开始和结束时间(见图 3.7)。
图 3.7 Airflow 中调度区间值的示意图。DAG 会在给定区间的结束时被触发,其中 data_interval_start 指向区间起点,data_interval_end 指向区间终点。
代码清单 3.10 使用基于数据区间的 timetable
import pendulum
...
with DAG(
dag_id="05_interval_cron",
schedule=CronDataIntervalTimetable("@daily", timezone="UTC"), #1
start_date=pendulum.datetime(2024, 1, 1),
end_date=pendulum.datetime(2024, 1, 5),
catchup=True,
):
...
#1 将 DAG 切换为使用基于区间的 timetable
我们可以借助 Airflow 的模板功能(第 5 章会详细讲)把这些参数动态插入到 Bash 命令中,如代码清单 3.11 所示(dags/05_interval_cron.py)。这样,当 DAG 运行时,它就会在调用 API 之前,把执行区间的开始和结束日期(通过 ds 宏格式化为 YYYY-MM-DD)插入到 URL 中,从而确保我们抓取到的只是该区间内的事件。再结合前面把输出写到不同 JSON 文件中的改动(例如 /data/events/2025-01-01.json),现在每个数据文件中就只会包含对应那一天的事件。
代码清单 3.11 使用模板来指定日期
fetch_events = BashOperator(
task_id="fetch_events",
bash_command=(
"mkdir -p /data && "
"curl -o /data/events/{{logical_date | ds}}.json"
"'http://events-api:8081/events/range?"
"start_date={{data_interval_start | ds}}&" #1
"end_date={{data_interval_end | ds}}'" #1
),
)
#1 使用 data_interval_start 和 data_interval_end 变量,把查询的开始/结束日期动态插入进去
把一个数据集划分成更小、更易于管理的部分,是数据存储与处理系统中非常常见的一种策略。这种策略通常被称为 partitioning(分区) ,而数据集中的这些小块则被称为 partitions(分区) 。把数据集分区还有一个额外好处:这样一来,我们 DAG 中的第二个任务 calculate_stats 也只会针对刚刚下载到的这批新增事件计算统计信息(dags/05_interval_cron.py)。
代码清单 3.12 按执行区间计算统计信息
calculate_stats = PythonOperator(
task_id="calculate_stats",
python_callable=_calculate_stats,
op_kwargs ={
"input_path": "/data/events/{{logical_date | ds}}.json", #1
"output_path": "/data/stats/{{logical_date | ds}}.csv", #2
},
)
#1 只读取当前这次执行对应 JSON 文件中的事件
#2 把结果写入一个按执行日期分区的 CSV 文件
这种方式给了我们一个 DAG,使我们能够随着时间推移逐步积累事件和统计结果的历史记录;同时又保持高效,因为它是按天批量抓取事件,并且只针对刚下载到的新事件计算统计。如果上面例子里的模板语法({{logical_date | ds}})让你觉得有点快了,也不用担心;我们会在第 5 章更详细地讨论任务上下文。在第 12 章中,我们还会进一步深入探讨分区场景以及处理分区的最佳实践。这里最重要的一点是:这些改动使我们能够按调度区间进行增量计算,每天只处理整体数据中的一个很小的子集。如果我们想使用稍微不同一点的区间(比如 @hourly 预设),这套机制同样成立。虽然可能需要调整统计代码,以适配这个新的区间粒度,但其底层原理仍然是一样的。
3.4.3 使用频率定义区间
和 trigger-based 的变体一样,你也可以不使用 cron 表达式,而是用“频率”来定义数据区间。做法是把 schedule 切换为 DeltaIntervalTimetable,并传入一个 pendulum.Duration 实例(dags/06_interval_delta.py)。
代码清单 3.13 定义一个基于频率的调度区间
import pendulum
...
with DAG(
dag_id="03_timedelta",
schedule=DeltaIntervalTimetable(pendulum.duration(days=2)), #1
...
):
...
#1 定义一个基于频率的调度,让 DAG 每两天运行一次
和它在 trigger-based 模式下的对应写法一样,这种基于区间的调度也会让 DAG 从指定的 start_date 开始,每两天执行一次。但它遵循的是区间调度语义:因此 Airflow 会提供 data_interval_start 和 data_interval_end 这两个变量,而且 DAG 会在一个区间结束时运行,而不是在区间开始时运行。
3.4.4 总结 interval-based schedules
由于数据区间很容易让人混淆,我们不妨花一点时间,彻底弄清楚区间是如何定义的,以及它与 trigger-based 方式到底有什么不同。
正如前面所见,我们可以通过 3 个参数来控制 Airflow 何时运行 DAG:start_date、schedule 和(可选的)end_date。在基于数据区间的 timetable 中,Airflow 会利用这 3 个参数,把时间从给定的开始日期起(并可选地在结束日期结束)划分为一系列调度区间(见图 3.8)。
图 3.8 在 Airflow 调度区间语义下的时间表示方式,假设使用每日区间,且开始日期为 2024-01-01。执行发生在区间末尾(例如第一个区间对应的 DAG run 会在 2024-01-02 00:00 执行)。
在这种基于区间的时间表示中,某个 DAG 会在该区间结束、也就是该区间对应的时间段完整过去之后执行。举例来说,图 3.8 中的第一个区间,会在 2024-01-01 23:59:59 之后尽快被执行,因为那时该区间中的最后一个时间点已经过去。同理,第二个区间会在 2024-01-02 23:59:59 之后不久执行,如此反复,直到到达我们设置的可选 end_date。
这种基于区间的方式非常适合像本章前面所展示的那种增量数据处理,因为我们能够准确知道一个任务正在处理的是哪一段时间区间:即该区间的开始和结束。我们可以通过 data_interval_start 和 data_interval_end 这两个变量来访问每个区间的边界。Airflow 还会提供一个 logical_date 变量,它通常指向该区间的起点;如果有需要,我们可以把它当作这次运行的标识符来使用。
3.5 处理不规则区间
尽管规则的调度区间非常强大,而且也是最常见的调度方式,但它们并不适合那些不会按固定间隔发生的不规则事件来触发工作流(比如体育赛事、节假日、节庆活动、火箭发射和选举)。一种替代方案是:把 DAG 调度为每天运行一次,然后在 DAG 内部再加一个条件来判断日期,但如果这些事件本身很零散,这种做法显然并不高效。
幸运的是,Airflow 通过它的 EventsTimetable 类支持这类不规则事件调度。这个类允许我们不是根据“周期性时间计划”,而是根据一组具体的日期 / 时间列表来定义调度区间。比如,假设我们只想在一年中的某些法定节假日计算事件统计(因为我们预期这些日期的网站流量会更高)。那么,我们可以通过创建一个包含这些日期的 EventsTimetable 对象,并把它作为 DAG 的调度区间传入,从而只在这些法定节假日触发 DAG(dags/07_events_timetable.py)。
代码清单 3.14 使用 EventsTimetable,在法定节假日处理数据
from pendulum import datetime
from airflow import DAG
from airflow.timetables.events import EventsTimetable
holiday_days = EventsTimetable( #1
event_dates=[
datetime(2024, 1, 1),
datetime(2024, 3, 31),
datetime(2024, 5, 2),
]
)
with DAG(
dag_id="07_events_timetable",
schedule=holiday_days, #2
start_date=datetime(2024, 1, 1),
):
...
#1 包含法定节假日的 timetable
#2 把该 timetable 作为调度区间传给 DAG
需要特别注意的是,这个事件列表必须是有限的;你不能写一段自定义逻辑,去生成一个长度可能未知的列表。此外,这个列表的规模也应该保持在合理范围内,因为列表越长,DAG 的加载时间也会越长。
采用这种 schedule 后,这个 DAG 会执行 3 次,分别对应下面这 3 个日期:
2024-01-01 00:00
2024-03-31 00:00
2024-05-02 00:00
从行为上看,EventsTimetable 与前面章节中的 trigger-based schedules 很相似。它会让 DAG 在指定日期 / 时间点精确触发执行(例如 2024-01-01 00:00:00),并提供一个 logical_date;但它不会定义数据区间,因此 data_interval_start 和 data_interval_end 没有实际意义。这意味着,如果我们想用 EventsTimetable 做增量处理,就必须自己计算所需的日期偏移量。
最后,还有一个不那么显眼但值得一提的行为。通常来说,无论你的调度如何设置,你总是可以在 Airflow UI 中手动触发 DAG 运行。对于 EventsTimetable,你可以通过传入一个额外标志 restrict_to_events 来关闭这种自由触发能力。下面的代码(dags/07_events_timetable.py)会让手动运行强制使用最近的一个 EventsTimetable 条目;如果当前尚无任何事件发生过,则使用列表中的第一个事件。
代码清单 3.15 为 EventsTimetable 设置手动运行时的正确行为
...
public_holidays = EventsTimetable(
event_dates=[
pendulum.datetime(year=2024, month=1, day=1),
pendulum.datetime(year=2024, month=3, day=31),
pendulum.datetime(year=2024, month=5, day=2),
],
restrict_to_events=True
)
...
3.6 管理历史数据回填
正如我们前面看到的,Airflow 允许我们从过去任意一个开始日期起定义调度区间。我们可以利用这一特性,对 DAG 执行历史运行,以加载或分析过去的数据集——这个过程通常被称为 backfilling(回填) 。当它与基于区间的调度(3.4 节)结合使用时,这个机制尤其好用。
回填行为(见图 3.9)由 catchup 参数控制,可以对某个 DAG 显式启用或禁用。前面提到过,在 Airflow 3 中,catchup 默认是关闭的,因此历史运行不会被处理,除非你显式启用它。相比之下,Airflow 2 中的 catchup 默认是开启的。我们强烈建议你显式设置 DAG 的 catchup 行为。
图 3.9 Airflow 中的回填。默认情况下,Airflow 3 不会自动为过去所有区间运行任务直到当前时刻。你可以通过把 DAG 的 catchup 参数设置为 True 来改变这一行为;这样 Airflow 就会把历史运行也纳入调度。
虽然回填是一个非常强大的概念,但它也受限于源系统中可用的数据范围。在我们的示例中,我们可以通过向 API 指定一个开始日期,来加载最多 7 天前的历史事件。然而,由于这个 API 最多只提供 7 天历史,因此我们无法利用回填去加载更早之前的数据。
在我们修改代码,或者发现历史 DAG runs 中的数据已经损坏时,也可以利用回填重新处理数据。比如,假设我们修改了 calc_statistics 函数,新增了一个统计指标。那么,我们就可以通过回填清除过去的 calc_statistics 任务运行,用新代码重新分析历史数据。在这种情况下,我们不会受限于数据源只有 7 天历史,因为这些更早的数据分区在我们之前的运行中就已经被加载下来了。
不过,有一点需要考虑:回填会给 Airflow 以及它所交互的外部系统带来什么样的负载。当你启动回填时,Airflow 会尽可能快地“追赶”上来,这可能会导致同时创建数百个(甚至更多)并行 DAG runs。结果就是,其他 DAG 的运行可能被挤占资源,或者外部系统被打爆。
对此你是有一些控制手段的。其中一种方式是使用 pools(详见第 12 章)。此外,你也可以在 DAG 层面调整一些其他配置:
- 你可以使用 DAG 的
max_active_tasks参数,设置这个 DAG 在同一时间最多允许多少个并行 task instance 运行。如果你不显式设置,它会默认继承 Airflow 配置中的core.max_active_tasks_per_dag(默认值为 16)。
注意
在 Airflow 2 中,这个参数叫做 concurrency(全局配置项名为 core.dag_concurrency)。
- 你还可以使用 DAG 的
max_active_runs参数,设置某个 DAG 允许同时处于活动状态的最大运行数。尤其在回填场景中,这个参数会非常有用。当 Airflow 达到这个上限时,就不会再创建新的 DAG runs,直到之前的一些运行完成。如果这个参数没有显式设置,那么它会默认采用core.max_active_runs_per_dag的值(默认也是 16)。
例如,下面这个 DAG 定义:
dag = DAG('my_dag', max_active_tasks=10, max_active_runs=2)
表示这个 DAG 在任意时刻最多只能有 10 个任务并行运行,并且这些任务最多只能分布在 2 个 DAG runs 中。
3.7 设计行为良好的任务
虽然 Airflow 已经为回填和重跑任务做了很多繁重工作,但要想得到正确结果,我们仍然必须确保自己的任务满足某些关键属性。本节中,我们将深入讨论一个“良好 Airflow 任务”最重要的两个属性:原子性(atomicity) 和 幂等性(idempotency) 。
3.7.1 原子性
“原子性”这个术语在数据库系统中经常出现:一个原子事务被视为一系列不可分割、不可再简化的数据库操作——要么全部发生,要么什么都不发生。类似地,在 Airflow 中,任务也应该被定义成这样:它要么成功并产生某个正确结果,要么失败,并且失败时不会影响系统状态(见图 3.10)。
图 3.10 原子性意味着一系列操作要么完整完成,要么完全不发生,从而避免产生不完整或错误的工作结果。
(a) 这个非原子的 Airflow DAG 把两项工作放进了一个任务里。如果其中一项失败,任务会处于 failed 状态,但这掩盖了一个事实:数据可能其实已经写出去了。
(b) 在这个原子的 Airflow DAG 中,每项工作都是一个独立任务。这确保了每项工作要么完整完成,要么根本不执行,并且能够正确反映每个任务的执行结果。
来看一个对用户事件 DAG 的简单扩展示例。我们想在每次运行结束时,给访问量最高的前 10 位用户发一封邮件。实现这个功能的一种简单方式,是直接在原先函数的末尾,增加一次对某个发送邮件函数的调用,把统计结果通过邮件发出去(dags/08_non_atomic_send.py)。
代码清单 3.16 在一个任务里做两件事,破坏了原子性
def _calculate_stats(**context):
"""Calculates event statistics."""
input_path = context["templates_dict"]["input_path"]
output_path = context["templates_dict"]["output_path"]
events = pd.read_json(input_path)
stats = events.groupby(["date", "user"]).size().reset_index()
stats.to_csv(output_path, index=False)
email_stats(stats, email="user@example.com") #1
#1 在写完 CSV 之后再发送邮件,这让一个函数里包含了两项工作,从而破坏了任务的原子性
不幸的是,这种做法的一个问题在于:这个任务已经不再是原子的了。你能看出为什么吗?如果一时还没想到,不妨考虑一下:假如 email_stats 函数失败了(而如果邮件服务器不够稳定,这种情况迟早会发生),会怎么样?在这种情况下,我们实际上已经把统计结果写入到了 output_path 所指定的输出文件中,这就会让人误以为任务已经成功完成了,但实际上它却是失败状态。
要想以原子的方式实现这个功能,我们可以把邮件发送逻辑拆成一个单独的任务(dags/09_atomic_send.py)。
代码清单 3.17 把功能拆分成多个任务,以提升原子性
def _send_stats(email, **context):
stats = pd.read_csv(context["templates_dict"]["stats_path"])
email_stats(stats, email=email) #1
send_stats = PythonOperator(
task_id="send_stats",
python_callable=_send_stats,
op_kwargs={"email": "user@example.com"},
templates_dict={
"stats_path": "/data/09_atomic_send/stats/{{data_interval_start | ds}}.csv"
},
)
calculate_stats >> send_stats
#1 把 email_stats 这一句拆到单独的任务中,以保证原子性
这样一来,发送邮件失败就不会影响 calculate_stats 任务的结果;此时只会有 send_stats 失败,因此两个任务都保持了各自的原子性。
看完这个例子,你可能会觉得:只要把所有操作都拆成独立任务,就足以保证任务具备原子性。其实并不一定。为了理解这一点,不妨想想这样一个场景:如果你的事件 API 在查询事件前需要先登录呢?通常你就需要额外发起一次 API 调用来获取认证 token,然后才能开始抓取事件。
按照你前面的推理——“一项操作 = 一个任务”——那么你就必须把这两个操作拆成两个独立任务。但这么做其实会带来一个很强的耦合关系,因为第二个任务(抓取事件)如果不是在第一个任务刚刚执行完之后立刻运行,就会失败。这种任务之间的强依赖关系意味着:更合理的做法,反而往往是把这两个操作保留在同一个任务中,让它们共同构成一个单一、完整的工作单元。
大多数 Airflow operator 本身都是按原子方式设计的,这也是为什么很多 operator 会把诸如认证这类紧密耦合的操作内置在内部。而像 PythonOperator 和 BashOperator 这种更灵活的 operator,则需要你在设计操作时更仔细地思考,确保自己的任务仍然保持原子性。
3.7.2 幂等性
在编写 Airflow 任务时,另一个非常重要的属性是幂等性(idempotency) 。如果对同一个任务,在输入完全相同的情况下调用多次,不会产生额外影响,那么这个任务就被称为幂等的。换句话说,在输入不变的前提下,重跑一个任务不应该改变整体输出结果。
最简单的重跑方式,就是在 UI 中点击 Clear Task 按钮。这样会触发 Airflow 再次运行这个任务,但使用的仍然是相同调度区间下的变量值(而不是像手动新触发一次 DAG run 那样重新生成一个新的运行)。来看下面这个 fetch_events 任务实现(dags/06_interval_delta.py),它会抓取某一天的数据,并写入我们已经分区的数据集中。
代码清单 3.18 现有的抓取事件实现
fetch_events = BashOperator(
task_id="fetch_events",
bash_command=(
"mkdir -p /data/events && "
"curl -o /data/events/{{ logical_date | ds }}.json "
"'http://events-api:8081/events/range?"
"start_date={{data_interval_start | ds}}&" #1
"end_date={{data_interval_end | ds}}'" #1
),
)
#1 通过模板化文件名进行分区
如果我们对某个日期重跑这个任务,那么它会再次抓取与之前相同的那一天事件数据(当然前提是这个日期仍在我们可用的 30 天窗口内),然后覆盖 /data/events 目录下已有的那个 JSON 文件,从而产生与之前完全相同的结果。因此,这个 fetch_events 任务实现显然是幂等的。
为了看一个非幂等任务的例子,不妨想象一下这样的实现:我们不按日期分文件,而是只使用一个 JSON 文件(/data/events.json),然后简单地把事件一条条追加进去。这样一来,如果你重跑某个任务,该天的事件就会再次追加到已有数据集中,导致同一天事件被重复写入(见图 3.11)。这种实现显然不是幂等的,因为额外执行一次任务就会改变最终结果。
图 3.11 一个幂等任务无论运行多少次,都会产生相同结果。幂等性保证了一致性,也提升了系统应对失败的能力。
一般来说,要让写数据的任务变成幂等的,可以通过以下方式实现:检查是否已经存在结果,或者确保任务会覆盖旧结果而不是重复追加。在按时间分区的数据集中,这一点通常很容易做到:直接覆盖对应分区即可。类似地,在数据库系统中,我们可以使用 upsert 操作来写入数据,这样就能覆盖之前任务执行时写入的旧行。不过,在更一般的应用场景中,我们必须仔细分析任务的所有副作用,并确保这些副作用都以幂等的方式执行。
小结
- 在 Airflow 中,调度是一项基础能力,它使工作流能够被自动触发执行。
- Airflow 中用于触发 DAG 的调度主要有两类。基于时间的调度(本章讨论)会在规则或不规则的时间间隔、特定时间点触发 DAG;而基于资产的调度(第 4 章讨论)则采用不同思路。
- 基于时间的调度由 4 个主要参数定义:
start_date(调度开始时间)、schedule(定义运行频率)、可选的end_date(调度结束时间),以及catchup(决定是否为过去的时间点补调度执行)。 - 基于触发的时间调度允许你在规则重复出现的时间点执行 DAG。DAG 的执行会发生在调度定义的那个具体时刻(例如午夜)。执行时,调度会提供
logical_date参数,表示这次 DAG 执行所对应的准确时间点。 - 基于数据区间的调度非常适合做增量处理,因为它会把时间切分成离散区间。某个日期对应的 DAG run,会在该区间结束时执行。区间的起始与结束信息,分别由
data_interval_start和data_interval_end参数提供。 - 你可以使用 cron 表达式、频率(例如
pendulum.duration),以及 Airflow 简写表达式(例如@daily)来定义基于触发和基于数据区间的调度。 - 你也可以借助
EventsTimetable来实现不规则的时间调度,它允许你在任意给定的一组时间点触发 DAG。 - 回填(由
catchup参数控制)能够让工作流为过去的区间执行,从而通过追溯性地运行 DAG,从指定开始日期起处理历史数据。 - 原子性(atomicity) 保证一系列操作被当作一个整体:要么全部完成,要么完全不发生;而幂等性(idempotency) 则保证在输入不变的情况下,重跑一个任务不会改变结果。
- Airflow 3 对默认调度行为做了较大调整:默认从基于数据区间的调度切换成了基于触发的调度,并且默认
catchup也从True改成了False。我们建议你在调度定义上尽量写得显式一些,特别是在从 Airflow 2 升级到 Airflow 3 时,这样可以避免很多意料之外的问题。