本章涵盖的内容:
- 在固定时间间隔内运行DAG
- 构建动态DAG以逐步处理数据
- 使用回填加载和重新处理过去的数据集
- 应用可靠任务的最佳实践
在前一章中,我们探索了Airflow的用户界面,并向您展示了如何定义一个基本的Airflow DAG并通过定义调度间隔每天运行它。在本章中,我们将更深入地探讨Airflow的调度概念,以及如何在固定时间间隔内逐步处理数据。首先,我们将介绍一个针对分析网站用户事件的小型用例,并探讨如何构建一个DAG来定期分析这些事件。接下来,我们将探讨如何通过逐步分析数据来提高此过程的效率,并了解这与Airflow的执行日期概念的联系。最后,我们将展示如何使用回填来填补数据集中过去的间隙,并讨论一些重要的Airflow任务的属性。
一个例子:处理用户事件
为了理解Airflow的调度工作原理,我们首先考虑一个小例子。假设我们有一个服务,用于跟踪用户在我们的网站上的行为,并允许我们分析用户(通过IP地址标识)访问了哪些页面。出于营销目的,我们想知道用户访问了多少不同的页面,以及每次访问的时间有多长。为了了解这种行为如何随着时间的推移而变化,我们希望每天计算这些统计数据,因为这样可以让我们比较不同日期和较长时间段内的变化。
由于外部跟踪服务由于实际原因不会存储超过30天的数据,因此我们需要自己存储和累积这些数据,因为我们希望保留我们的历史数据更长的时间。通常情况下,由于原始数据可能相当庞大,将这些数据存储在云存储服务(例如Amazon的S3或Google的Cloud Storage)中是有意义的,因为它们结合了高持久性和相对较低的成本。然而,出于简单起见,我们不会考虑这些事情,而是将我们的数据保留在本地。
为了模拟这个例子,我们创建了一个简单的(本地)API,允许我们检索用户事件。例如,我们可以使用以下API调用检索过去30天的所有事件的完整列表:
curl -o /tmp/events.json http://localhost:5000/events
该调用将返回一个(JSON编码的)用户事件列表,我们可以分析以计算用户统计信息。
使用这个API,我们可以将我们的工作流程分为两个单独的任务:一个用于获取用户事件,另一个用于计算统计信息。数据本身可以使用BashOperator下载,就像我们在上一章中看到的那样。对于计算统计信息,我们可以使用PythonOperator,它允许我们将数据加载到Pandas DataFrame中,并使用groupby和聚合来计算事件的数量。总体而言,这给我们了在列表3.1中显示的DAG。
import datetime as dt
from pathlib import Path
import pandas as pd
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator
dag = DAG(
dag_id="01_unscheduled",
start_date=dt.datetime(2019, 1, 1), ❶
schedule_interval=None, ❷
)
fetch_events = BashOperator(
task_id="fetch_events",
bash_command=(
"mkdir -p /data && "
"curl -o /data/events.json "
"http://localhost:5000/events" ❸
),
dag=dag,
)
def _calculate_stats(input_path, output_path):
"""Calculates event statistics."""
events = pd.read_json(input_path) ❹
stats = events.groupby(["date", "user"]).size().reset_index() ❹
Path(output_path).parent.mkdir(exist_ok=True) ❺
stats.to_csv(output_path, index=False) ❺
calculate_stats = PythonOperator(
task_id="calculate_stats",
python_callable=_calculate_stats,
op_kwargs={
"input_path": "/data/events.json",
"output_path": "/data/stats.csv",
},
dag=dag,
)
fetch_events >> calculate_stats ❻
❶ 定义DAG的开始日期。
❷ 指定这是一个未调度的DAG。
❸ 从API中获取和存储事件。
❹ 加载事件并计算所需的统计信息。
❺ 确保输出目录存在并将结果写入CSV。
❻ 设置执行顺序。
现在我们有了基本的DAG,但是我们仍然需要确保Airflow定期运行它。让我们将其调度,以便每天都能进行更新!
定期执行
正如我们在第二章中所见,可以通过在初始化DAG时使用schedule_interval参数为其定义一个定期间隔,以便Airflow DAG可以按照指定的时间间隔运行。默认情况下,该参数的值为None,这意味着DAG不会被调度,只能在通过UI或API手动触发时运行。
定义调度周期
在我们处理用户事件的示例中,我们希望每天计算一次统计数据,因此将DAG安排在每天运行一次是有意义的。由于这是一个常见的用例,Airflow提供了方便的宏@daily,用于定义每天的定期间隔,该定期间隔使我们的DAG在每天午夜运行一次。
dag = DAG(
dag_id="02_daily_schedule",
schedule_interval="@daily", ❶
start_date=dt.datetime(2019, 1, 1), ❷
...
)
❶ 将DAG调度在每天午夜运行。
❷ 开始调度DAG运行的日期/时间。
Airflow还需要知道我们希望何时开始执行DAG,这由其开始日期指定。根据此开始日期,Airflow将在开始日期后的第一个调度间隔(start + interval)执行我们的DAG的第一次执行。随后的运行将继续在此第一个间隔后的调度间隔中执行。
注意,请注意Airflow在一个间隔的末尾开始任务。如果在2019年1月1日13:00开发一个DAG,开始日期为2019年1月1日,调度间隔为@daily,则意味着它在午夜开始运行。在2019年1月1日的13:00运行DAG直到午夜才会发生任何事情。
例如,假设我们在一月一日定义了一个开始日期,如之前在列表3.2中所示。结合每日的调度间隔,这将导致Airflow在一月一日后的每一天午夜运行我们的DAG(图3.1)。请注意,我们的第一次执行发生在1月2日(开始日期之后的第一个间隔),而不是1月1日。我们将在本章后面(第3.4节)进一步了解这种行为背后的原因。
在没有结束日期的情况下,Airflow将(原则上)保持在每日计划中执行我们的DAG,直至永远。然而,如果我们已经知道我们的项目有一个固定的持续时间,我们可以使用end_date参数告诉Airflow在某个日期之后停止运行我们的DAG。
dag = DAG(
dag_id="03_with_end_date",
schedule_interval="@daily",
start_date=dt.datetime(year=2019, month=1, day=1),
end_date=dt.datetime(year=2019, month=1, day=5),
)
这将导致如图3.2所示的完整的计划间隔集。
基于Cron表达式的定期
到目前为止,我们所有的例子都展示了DAG以每天的间隔运行。但是如果我们想要在每小时或每周运行作业呢?以及对于更复杂的间隔,例如我们可能希望在每个星期六的23:45运行DAG呢?
为了支持更复杂的调度间隔,Airflow允许我们使用与cron相同的语法来定义调度间隔。cron是一种时间基准的作业调度程序,常用于类似macOS和Linux等Unix-like计算机操作系统上。该语法由五个组件组成,定义如下:
# ┌─────── minute (0 - 59)
# │ ┌────── hour (0 - 23)
# │ │ ┌───── day of the month (1 - 31)
# │ │ │ ┌───── month (1 - 12)
# │ │ │ │ ┌──── day of the week (0 - 6) (Sunday to Saturday;
# │ │ │ │ │ 7 is also Sunday on some systems)
# * * * * *
在这个定义中,cron job会在时间/日期字段与当前系统时间/日期匹配时执行。星号(*)可以用来代替数字来定义无限制的字段,意味着我们不关心该字段的值。
尽管这种基于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运行
Airflow还支持几个宏,代表了常用的调度间隔的简写方式。我们已经看到其中一个宏(@daily),用于定义每天的间隔。Airflow支持的其他宏的概览如表格3.1所示。
虽然Cron表达式非常强大,但它们可能很难使用。因此,在将其应用于Airflow之前,最好先测试您的表达式。幸运的是,有许多在线工具1可以帮助您用普通英语定义、验证或解释您的Cron表达式。在代码中记录复杂Cron表达式背后的原因也是很有帮助的。这可以帮助他人(包括未来的您!)在重新查看代码时理解表达式的含义。
以频率为基础的定期
Cron表达式的一个重要限制是它们无法表示某些基于频率的计划。例如,如何定义一个每三天运行一次的Cron表达式?事实证明,你可以编写一个在每个月的第一天、第四天、第七天等运行的表达式,但这种方法在月底会出现问题,因为DAG将连续运行在31日和下个月的第一天,违反了预期的计划。
这个Cron的限制源自于Cron表达式的本质,它们定义了一个持续匹配当前时间的模式,以确定是否应该执行作业。这样做的好处是使表达式无状态,这意味着你不必记住上次作业运行的时间来计算下一个间隔。然而,正如你所看到的,这是以一些表达能力为代价的。
那么,如果我们真的想每三天运行一次我们的DAG呢?为了支持这种基于频率的计划,Airflow还允许你用相对时间间隔来定义调度间隔。为了使用这种基于频率的计划,你可以将一个timedelta实例(来自标准库的datetime模块)作为调度间隔传递进去。
dag = DAG(
dag_id="04_time_delta",
schedule_interval=dt.timedelta(days=3), ❶
start_date=dt.datetime(year=2019, month=1, day=1),
end_date=dt.datetime(year=2019, month=1, day=5),
)
❶ timedelta提供了使用基于频率的调度的能力
这将导致我们的DAG在开始日期后每隔三天运行一次(即在2019年1月的4日、7日、10日等日期运行)。当然,您也可以使用此方法每隔10分钟运行您的DAG(使用timedelta(minutes=10)),或者每隔两小时运行(使用timedelta(hours=2))。
增量处理数据
尽管我们的DAG现在每天都在运行(假设我们坚持使用@daily计划),但我们还没有完全实现我们的目标。首先,我们的DAG每天都在下载和计算整个用户事件目录的统计数据,这几乎是低效的。此外,该过程仅下载过去30天的事件,这意味着我们没有为更早的日期建立任何历史记录。
逐步获取事件数据
解决这些问题的一种方法是将我们的DAG改为逐步加载数据,即每个调度间隔只加载相应日期的事件,并仅计算新事件的统计信息(见图3.3)。
这种逐步增量的方法比获取和处理整个数据集要高效得多,因为它显著减少了每个调度间隔需要处理的数据量。另外,由于我们现在将数据存储在每天一个文件的方式,我们还有机会在很长一段时间内建立文件的历史记录,远远超过我们API的30天限制。
要在我们的工作流中实现增量处理,我们需要修改我们的DAG以下载特定日期的数据。幸运的是,我们可以通过包含开始日期和结束日期参数来调整我们的API调用,从而获取当前日期的事件:
curl -O http://localhost:5000/events?start_date=2019-01-01&end_date=2019-01-02
这两个日期参数共同指示我们希望获取事件的时间范围。请注意,在此示例中,开始日期是包含在内的,而结束日期是排除在外的,这意味着我们实际上获取的是发生在2019年01月01日00:00:00至2019年01月01日23:59:59之间的事件。
我们可以通过更改我们的Bash命令,包含这两个日期来在我们的DAG中实现这种增量数据获取。
fetch_events = BashOperator(
task_id="fetch_events",
bash_command=(
"mkdir -p /data && "
"curl -o /data/events.json "
"http://localhost:5000/events?"
"start_date=2019-01-01&"
"end_date=2019-01-02"
),
dag=dag,
)
然而,要获取除2019年01月01日之外的任何其他日期的数据,我们需要更改命令,使用反映DAG正在执行的日期的开始和结束日期。幸运的是,Airflow为我们提供了几个额外的参数来实现这一点,我们将在下一节中进行探讨。
使用执行日期进行动态时间引用
对于许多涉及基于时间的流程的工作流,了解给定任务正在执行的时间间隔非常重要。出于这个原因,Airflow为任务提供了额外的参数,可以用于确定任务正在执行的调度间隔(我们将在下一章中详细介绍这些参数)。
其中最重要的参数称为execution_date,它表示我们的DAG正在执行的日期和时间。与参数名称所暗示的不同,execution_date不是日期,而是一个时间戳,它反映了DAG正在执行的调度间隔的开始时间。调度间隔的结束时间由另一个参数next_execution_date表示。这两个日期共同定义了任务调度间隔的整个长度(图3.4)。
Airflow还提供了一个previous_execution_date参数,它描述了上一个调度间隔的开始时间。虽然我们在这里不会使用这个参数,但它对于执行分析,将当前时间间隔的数据与上一个时间间隔的结果进行对比是很有用的。
在Airflow中,我们可以通过在操作符中引用这些执行日期来使用它们。例如,在BashOperator中,我们可以使用Airflow的模板功能在我们的Bash命令中动态地包含执行日期。模板化功能将在第4章中详细介绍。
fetch_events = BashOperator(
task_id="fetch_events",
bash_command=(
"mkdir -p /data && "
"curl -o /data/events.json "
"http://localhost:5000/events?"
"start_date={{execution_date.strftime('%Y-%m-%d')}}" ❶
"&end_date={{next_execution_date.strftime('%Y-%m-%d')}}" ❷
),
dag=dag,
)
❶ 通过Jinja模板化插入格式化的execution_date
❷ next_execution_date保存下一个调度间隔的执行日期。
在这个例子中,{{variable_name}}的语法是使用Airflow的Jinja模板化语法(jinja.pocoo.org)来引用Airflow的特定参数之一。在这里,我们使用这个语法来引用执行日期,并使用datetime strftime方法将它们格式化为预期的字符串格式(因为两个执行日期都是datetime对象)。
由于execution_date参数经常以这种方式用于引用格式化的日期字符串,Airflow还提供了几个常见日期格式的快捷参数。例如,ds和ds_nodash参数是执行日期的不同表示形式,格式分别为YYYY-MM-DD和YYYYMMDD。类似地,next_ds、next_ds_nodash、prev_ds和prev_ds_nodash分别提供了下一个和上一个执行日期的缩写表示形式。
使用这些缩写表示形式,我们也可以将增量获取命令写成以下方式。
fetch_events = BashOperator(
task_id="fetch_events",
bash_command=(
"mkdir -p /data && "
"curl -o /data/events.json "
"http://localhost:5000/events?"
"start_date={{ds}}&" ❶
"end_date={{next_ds}}" ❷
),
dag=dag,
)
❶ ds 提供了格式化为YYYY-MM-DD的执行日期。
❷ next_ds 提供了格式化为YYYY-MM-DD的下一个执行日期。
这种简短的写法更易于阅读。然而,对于更复杂的日期(或日期时间)格式,你可能仍然需要使用更灵活的 strftime 方法。
分区化数据
尽管我们的新 fetch_events 任务现在在每个新的调度间隔中增量获取事件,细心的读者可能已经注意到,每个新任务只是简单地覆盖了前一天的结果,这意味着我们实际上没有构建任何历史记录。
解决这个问题的一种方法是简单地将新事件追加到 events.json 文件中,这将允许我们在单个 JSON 文件中构建我们的历史记录。然而,这种方法的缺点是,它要求任何下游处理作业加载整个数据集,即使我们只对计算某一天的统计信息感兴趣。此外,它还使得该文件成为单一故障点,如果这个文件丢失或损坏,我们可能会面临丢失整个数据集的风险。
另一种方法是通过将任务的输出写入一个以相应执行日期命名的文件,将数据集分成每日批次。
fetch_events = BashOperator(
task_id="fetch_events",
bash_command=(
"mkdir -p /data/events && "
"curl -o /data/events/{{ds}}.json " ❶
"http://localhost:5000/events?"
"start_date={{ds}}&"
"end_date={{next_ds}}",
dag=dag,
)
❶ 响应已写入模板化的文件名
这将导致任何执行日期为2019年01月01日的数据被写入文件/data/events/2019-01-01.json。
将数据集分割成更小、更易管理的片段是数据存储和处理系统中常见的策略,通常称为分区,其中数据集的较小片段称为分区。通过执行日期对数据集进行分区的优势在于我们考虑DAG中的第二个任务(calculate_stats),在该任务中,我们计算每天的用户事件统计信息。在以前的实现中,我们每天都加载整个数据集并计算整个事件历史的统计信息。
def _calculate_stats(input_path, output_path):
"""Calculates event statistics."""
Path(output_path).parent.mkdir(exist_ok=True)
events = pd.read_json(input_path)
stats = events.groupby([ "date" , "user" ]).size().reset_index()
stats.to_csv(output_path, index=False)
calculate_stats = PythonOperator(
task_id= "calculate_stats" ,
python_callable=_calculate_stats,
op_kwargs={
"input_path" : "/data/events.json" ,
"output_path" : "/data/stats.csv" ,
},
dag=dag,
)
然而,使用我们分区的数据集,我们可以通过将此任务的输入和输出路径更改为指向分区的事件数据和分区的输出文件,更高效地计算每个独立分区的这些统计信息。
def _calculate_stats(**context): ❶
"""Calculates event statistics."""
input_path = context["templates_dict"]["input_path"] ❷
output_path = context["templates_dict"]["output_path"]
Path(output_path).parent.mkdir(exist_ok=True)
events = pd.read_json(input_path)
stats = events.groupby(["date", "user"]).size().reset_index()
stats.to_csv(output_path, index=False)
calculate_stats = PythonOperator(
task_id="calculate_stats",
python_callable=_calculate_stats,
templates_dict={
"input_path": "/data/events/{{ds}}.json" , ❸
"output_path": "/data/stats/{{ds}}.csv" ,
},
dag=dag,
)
❶ 在此字典中接收所有上下文变量。
❷ 从 templates_dict 对象中检索模板化的值。
❸ 传递我们想要被模板化的值。
尽管这些更改可能看起来有些复杂,但它们主要涉及用于确保我们的输入和输出路径被模板化的样板代码。为了在PythonOperator中实现这种模板化,我们需要使用该操作符的templates_dict参数传递任何应该被模板化的参数。然后,我们可以在函数内部从Airflow传递给我们的_context对象中检索模板化的值。
如果这一切进行得有点太快,不要担心;在下一章中,我们将更详细地深入讨论任务上下文。在这里需要理解的重要一点是,这些更改使我们能够通过每天仅处理数据的一小部分来逐步计算统计信息。
理解Airflow的执行日期
因为执行日期是Airflow的重要组成部分,让我们花点时间确保我们完全理解这些日期是如何定义的。
按固定长度间隔执行工作
正如我们所见,我们可以通过三个参数来控制Airflow何时运行DAG:开始日期、调度间隔和(可选的)结束日期。为了实际开始调度我们的DAG,Airflow使用这三个参数将时间划分为一系列的调度间隔,从给定的开始日期开始,可选地结束于结束日期(图3.5)。
在这种基于间隔的时间表示中,给定的DAG会在该间隔的时间槽已过去后立即执行。例如,图3.5中的第一个间隔将在2019年1月1日23:59:59后尽快执行,因为在那时间隔的最后一个时间点已经过去了。类似地,DAG将在2019年1月2日23:59:59后不久执行第二个间隔,以此类推,直到我们达到可选的结束日期。
使用这种基于间隔的方法的一个优势是,它非常适合执行前面部分所见的增量数据处理,因为我们确切地知道任务正在执行的时间间隔——对应间隔的开始和结束时间。这与基于时间点的调度系统(如cron)形成鲜明对比,在基于时间点的系统中,我们只知道任务当前正在执行的时间。这意味着在cron中,我们要么必须计算或猜测我们上一次执行的结束位置,假设任务正在执行前一天(图3.6)。
理解Airflow对时间的处理是围绕着调度间隔构建的,这也有助于理解Airflow中如何定义执行日期。例如,假设我们有一个按日调度间隔运行的DAG,然后考虑应该处理2019年1月3日数据的相应间隔。在Airflow中,这个间隔将在2019年1月4日00:00:00后不久运行,因为在那时我们知道不会再收到任何2019年1月3日的新数据。回想一下我们在前面部分解释的任务中使用执行日期的情况,你认为这个间隔的execution_date变量的值会是什么?
许多人认为这个DAG运行的execution date将是2019年1月4日,因为这是DAG实际运行的时刻。然而,如果我们查看任务执行时execution_date变量的值,我们实际上会看到一个执行日期为2019年1月3日。这是因为Airflow将DAG的执行日期定义为相应间隔的开始。从概念上讲,如果我们将执行日期标记为我们的调度间隔,而不是DAG实际执行的时刻,这是有道理的。不幸的是,命名可能有点令人困惑。
由于Airflow的执行日期被定义为相应调度间隔的开始,它们可以用来推导出特定间隔的开始和结束(图3.7)。例如,在执行任务时,相应间隔的开始和结束由execution_date(间隔的开始)和next_execution_date(下一个间隔的开始)参数定义。类似地,可以使用previous_execution_date和execution_date参数推导出上一个调度间隔。
然而,在任务中使用previous_execution_date和next_execution_date参数时需要注意一个要点,那就是这些参数仅对遵循调度间隔的DAG运行进行定义。因此,对于通过Airflow用户界面或命令行手动触发的运行,这些参数的值将是未定义的,因为如果您没有遵循调度间隔,Airflow无法提供关于下一个或上一个调度间隔的信息。
使用回填来填补过去的间隙
由于Airflow允许我们从任意的开始日期定义调度间隔,因此我们也可以从过去的某个开始日期定义过去的间隔。我们可以利用这个特性来对过去的数据集进行历史运行,加载或分析过去的数据集,这个过程通常被称为回填(backfilling)。
执行向过去回溯的工作
默认情况下,Airflow会调度和运行所有尚未运行的过去调度间隔。因此,如果指定了过去的开始日期并激活相应的DAG,将会执行所有已经过去但尚未执行的间隔。这种行为由DAG的catchup参数控制,可以通过将catchup设置为false来禁用。
dag = DAG(
dag_id="09_no_catchup",
schedule_interval="@daily",
start_date=dt.datetime(year=2019, month=1, day=1),
end_date=dt.datetime(year=2019, month=1, day=5),
catchup=False,
)
通过这个设置,DAG将只在最近的调度间隔中运行,而不会执行所有未执行的过去间隔(图3.8)。catchup的默认值可以通过在Airflow配置文件中为catchup_by_default配置设置一个值来控制。
虽然回填是一个强大的概念,但它受到源系统数据可用性的限制。例如,在我们的示例用例中,我们可以通过指定最多30天之前的开始日期从API加载过去的事件。然而,由于该API只提供最近30天的历史数据,我们无法使用回填来加载更早的数据。
回填还可以用于在我们对代码进行更改后重新处理数据。例如,假设我们对calc_statistics函数进行了更改以添加一个新的统计数据。使用回填,我们可以清除calc_statistics任务的过去运行,以便使用新代码重新分析我们的历史数据。请注意,在这种情况下,我们不受数据源30天限制的限制,因为我们在过去的运行中已经加载了这些较早的数据分区。
设计任务的最佳实践
虽然在回填和重新运行任务时,Airflow会处理大部分重要的工作,但我们需要确保我们的任务具备一定的关键特性,以确保得到正确的结果。在本节中,我们将深入探讨Airflow任务的两个最重要的特性:原子性和幂等性。
原子性(Atomicity)
原子性(Atomicity)这个术语经常在数据库系统中使用,其中原子事务被视为一系列不可分割和不可约的数据库操作,要么全部发生,要么一点都不发生。同样地,在Airflow中,任务应该被定义为要么成功并产生正确的结果,要么以一种不影响系统状态的方式失败(图3.9)。
作为例子,考虑对用户事件DAG进行简单扩展,我们希望在每次运行结束时发送一封包含前10位用户的电子邮件。一个简单的方法是通过在我们之前的函数中添加一个额外的调用来实现,调用某个函数以发送包含我们的统计信息的电子邮件。
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") ❶
❶ 在写入CSV后发送电子邮件会在一个单一函数中创建两个任务,这将破坏任务的原子性
不幸的是,这种方法的一个缺点是任务不再是原子性的。你能看出为什么吗?如果不能,考虑一下如果我们的_send_stats函数失败(如果我们的电子邮件服务器有点不稳定,这种情况可能发生)。在这种情况下,我们已经将统计数据写入到输出文件output_path中,使得任务似乎已经成功完成,即使实际上它以失败结束。
要以原子方式实现这个功能,我们只需要将电子邮件功能拆分为一个独立的任务即可。
def _send_stats(email, **context):
stats = pd.read_csv(context["templates_dict"]["stats_path"])
email_stats(stats, email=email) ❶
send_stats = PythonOperator(
task_id="send_stats",
python_callable=_send_stats,
op_kwargs={"email": "user@example.com"},
templates_dict={"stats_path": "/data/stats/{{ds}}.csv"},
dag=dag,
)
calculate_stats >> send_stats
❶ 将发送电子邮件的语句拆分成一个独立的任务,以保持原子性
这样,发送电子邮件失败将不再影响calculate_stats任务的结果,而只会导致send_stats失败,从而使得这两个任务都是原子性的。
从这个例子中,你可能认为将所有操作拆分为单独的任务就足以使所有任务都是原子性的。然而,这并不一定是正确的。要理解原因,想象一下如果我们的事件API在查询事件之前要求我们先进行登录。这通常需要额外的API调用来获取一些认证令牌,然后我们才能开始检索我们的事件。
按照之前的推理,一个操作=一个任务,我们将不得不将这些操作拆分为两个单独的任务。然而,这样做会在它们之间创建一个强依赖关系,因为第二个任务(获取事件)在前面的任务运行不久后失败。这种强依赖关系意味着我们最好将这两个操作放在一个单独的任务中,使得任务成为一个单一的、连贯的工作单元。
大多数Airflow操作符已经被设计为原子性的,这就是为什么许多操作符在内部包含了执行紧密耦合操作(如认证)的选项。然而,更灵活的操作符(如Python和Bash操作符)可能需要您仔细考虑您的操作,以确保您的任务保持原子性。
幂等性(Idempotency)
在编写Airflow任务时考虑的另一个重要特性是幂等性(Idempotency)。如果使用相同的输入多次调用同一个任务没有额外的效果,则称任务具有幂等性。这意味着重新运行任务而不更改输入不应该改变整体输出。
例如,考虑我们上一个fetch_events任务的实现,它获取一天的结果并将其写入我们的分区数据集。
fetch_events = BashOperator(
task_id="fetch_events",
bash_command=(
"mkdir -p /data/events && "
"curl -o /data/events/{{ds}}.json " ❶
"http://localhost:5000/events?"
"start_date={{ds}}&"
"end_date={{next_ds}}"
),
dag=dag,
)
❶ 通过设置模板化的文件名进行分区
对于给定日期再次运行这个任务会导致任务获取与之前执行相同的一组事件(假设日期在我们的30天窗口内),并覆盖/data/events文件夹中现有的JSON文件,从而产生相同的结果。因此,这个获取事件任务的实现显然是幂等的。
为了展示一个非幂等任务的例子,考虑使用单个JSON文件(/data/events.json),并简单地将事件附加到这个文件中。在这种情况下,重新运行任务会导致事件被简单地附加到现有的数据集中,从而重复当天的事件(图3.10)。因此,这个实现不是幂等的,因为任务的额外执行会改变整体的结果。
通常,编写数据的任务可以通过检查现有结果或确保任务覆盖以前的结果来实现幂等性。在时间分区的数据集中,这相对简单,因为我们可以简单地覆盖相应的分区。同样,在数据库系统中,我们可以使用upsert操作来插入数据,这允许我们覆盖之前任务执行写入的现有行。然而,在更一般的应用中,您应该仔细考虑任务的所有副作用,并确保它们以幂等的方式执行。
总结
- DAG可以通过设置调度间隔以规律性地运行。
- 一个间隔的工作会在间隔结束时开始。
- 调度间隔可以通过cron和timedelta表达式进行配置。
- 通过使用模板化动态设置变量,可以实现增量处理数据。
- 执行日期指的是间隔的开始时间,而不是实际执行的时间。
- 使用回填可以使DAG向过去运行。
- 幂等性确保任务可以重新运行,同时产生相同的输出结果。