本章涵盖
- 将 DAG 拆分为生产者 DAG 与消费者 DAG
- 使用资产定义 DAG 之间的依赖关系
- 在生产者 DAG 中更新资产并触发消费者 DAG
- 在生产者与消费者之间传递信息
- 为多个资产定义复杂依赖
在第 3 章中,我们重点讨论了基于时间的调度:任务在预先定义好的时间点或时间间隔执行。这种方法在很多场景下都很好用,但当工作流需要扩展到单个有向无环图(DAG)之外时,就可能出现问题。本章我们将深入介绍另一种事件驱动的方法——资产感知调度(asset-aware scheduling) 。这种方法将 DAG 之间的依赖关系显式建模为资产(assets) ,并在其所依赖的资产被更新时触发 DAG 运行。
4.1 基于时间的调度在扩展时面临的挑战
在第 3 章中,我们调度了一个 DAG,定期从 API 拉取用户事件数据。但如果有多个团队——比如分析团队、营销团队和性能监控团队——都需要使用同一份数据,会发生什么呢?
我们当然可以让每个团队都各自构建一条自己的流水线去拉取这份数据(图 4.1A)。但这种做法会带来几个问题:
- 多个 DAG 同时向同一个 API 请求相同数据,会增加源系统负载,可能导致性能问题或额外成本。
- 如果 API 接口发生变化,就必须在多个地方分别修改逻辑。
- 如果不同团队以不同方式实现数据拉取逻辑,就可能造成数据不一致,最终导致业务洞察互相冲突。
更好的做法是由一个团队在生产者 DAG 中统一准备数据,然后下游团队在各自的消费者 DAG 中使用这份数据(图 4.1B)。这样解决了数据一致性问题,但又引入了一个新挑战:消费者 DAG 怎么知道自己该在什么时候运行?
图 4.1 当多个团队依赖同一份数据时,每个团队都各自拉取数据(A)既低效又容易导致不一致。更高效的方式是由一个团队统一拉取数据,并在新数据写入后触发其他团队的工作流(B)。但生产者 DAG 与消费者 DAG 的调度如何对齐?
我们可以尝试对齐时间表,让消费者 DAG 在生产者 DAG 预计完成后不久运行,但这种方案既低效又脆弱。消费者 DAG 会按照固定时间表运行,即使源数据根本没有变化,也会白白浪费资源。而且,如果数据工程团队改了调度策略,或者其 DAG 的执行时长本身就不可预测,那么消费者 DAG 很可能会在数据还不完整、或者数据已经过时的情况下运行。
另一种思路是让生产者 DAG 直接触发消费者 DAG。但这会让工作流之间形成紧耦合。生产者 DAG 必须知道所有消费者是谁,而随着越来越多团队开始使用这份数据集,这种做法显然很难扩展——DAG 里不得不维护一份手工整理的下游消费者清单,而这本身就是一个高风险、易出错的过程。幸运的是,资产感知调度提供了一种更稳健、更高效的解决方案。
4.2 认识资产感知调度
资产感知调度允许你用资产来定义 DAG 之间的关系:哪些资产由 DAG 产出,哪些资产由 DAG 消费。正如你将看到的,这种显式关系建模非常强大,能够让我们轻松将生产者 DAG 与消费者 DAG 连接起来,同时避免上一节提到的那些问题。
资产(asset) 本质上是一个虚拟引用。它可以是任何你业务场景中有意义的对象:数据集、一张数据表、一个机器学习模型,等等。资产通过它的统一资源标识符(URI) 来唯一标识,这个 URI 通常反映该资产的唯一位置。例如,对于存储在 Amazon S3 上的一个数据集,你可以使用类似这样的 URI 来表示它:s3://example_bucket/example.csv。
资产的有效 URI 与 ID
尽管我们强烈建议你使用一个合法的 URI,但从实现上看,Airflow 只是把 URI 当作一个字符串 ID,并不会对 URI 指向的位置或内容做任何假设。下面这些都可以作为资产的合法 URI 或 ID:
s3://example_bucket/example.csvfile://tmp/data.csv/my_data.csvmy_dataset
在我们的事件数据场景里,可以为事件数据集创建一个资产,并使用该数据集文件所在的 URL 或本地路径作为其标识,例如:file:///data/events。在这个例子里,这个标识已经足够唯一了;但在更通用的环境中,我们可能更希望使用云存储桶或其他共享存储位置,以便能唯一标识该数据集,并且让所有消费者都能访问到。
有了这个资产之后,我们就可以把原来的事件处理 DAG 拆成两部分:
- 一个生产者 DAG:负责抓取并存储事件数据;
- 一个消费者 DAG:负责处理新事件并计算事件统计。
这两个 DAG 都连接到同一个“数据集资产”(图 4.2)。
对生产者 DAG 来说,这个资产是它的输出:也就是说,每当它更新数据集时,就会生成一个资产事件(asset event) 。
对消费者 DAG 来说,这个资产是它的输入:这意味着,每当该资产被更新时,Airflow 都会自动触发这个 DAG,并在触发时把相关的资产事件传给它。
图 4.2 将事件 DAG 拆成独立的生产者 DAG 和消费者 DAG,并分别由不同团队维护。生产者 DAG 创建并更新代表事件数据集的资产,下游其他团队拥有的 DAG 消费该资产。Airflow 会确保当该资产更新时,这些下游 DAG 会被自动触发。
如果有需要,我们还可以很容易把这个例子扩展到多个消费者:只需让更多消费者 DAG 也引用同一个资产即可。这为跨多个 DAG 扩展工作流提供了一种非常强大的方式,而且比单纯基于时间的调度脆弱性小得多。
注意
资产感知调度本身并不包含“时间”这个概念,因此它并不适合那些必须按固定时间表运行的 DAG。不过,这两种方式结合起来就非常强大:你既可以用基于时间的调度每天拉取数据,又可以用资产感知调度在下游动态触发相关 DAG。
4.3 生成资产事件
现在你已经了解了资产感知调度的基本原理,接下来就把它用起来。在这一节中,你将把第 3 章中已有的事件处理 DAG 改造成第 4.1 节所描述的“生产者/消费者”模式。也就是说,最终会形成两个 DAG:
- 一个生产者 DAG:负责创建并更新事件数据集资产;
- 一个消费者 DAG:在该资产发生更新时作出响应,并为新数据计算统计结果(图 4.3)。
图 4.3 我们这个场景中的生产者 DAG 与消费者 DAG。生产者 DAG 负责抓取事件数据并更新 file:///data/events 资产,进而触发消费者 DAG;消费者 DAG 则为新事件计算统计结果。
先来看生产者 DAG。我们先创建一个资产引用,用它表示事件数据集(dags/01a_basic_producer.py)。事件数据集使用 URI file:///data/events_01 来标识,它对应的是本地数据集路径。我们保留第 3 章中的按天调度策略,以确保这个生产者 DAG 仍然每天抓取一次新数据。
代码清单 4.1 为事件数据集定义一个资产
from airflow.sdk import DAG, Asset
from airflow.timetables.interval import CronDataIntervalTimetable
...
events_dataset = Asset("file:///data/events_01") #1
with DAG(
dag_id="01_producer",
schedule=CronDataIntervalTimetable("0 0 * * *", timezone="UTC"), #2
start_date=pendulum.yesterday(),
catchup=True
):
...
#1 定义数据集资产,URI 是唯一参数
#2 保持该 DAG 按日调度运行
接下来,为了在事件数据集被更新时发出一个事件,我们要把这个资产实例连接到真正更新该数据集的 Airflow 任务上。具体做法是:把该资产传给对应算子的 outlets 参数(dags/01a_basic_producer.py)。为了简洁起见,下面省略了 fetch_events 函数本身的实现,它和第 3 章中的版本是一样的。
代码清单 4.2 将资产作为 fetch_events 任务的输出
fetch_events = PythonOperator(
task_id="fetch_events",
python_callable=_fetch_events,
op_kwargs={
"start_date": "{{ data_interval_start | ds }}",
"end_date": "{{ data_interval_end | ds }}",
"output_path": "/data/events/{{ data_interval_start |
➥ ds }}.json",
},
outlets=[events_dataset], #1
)
#1 为该任务添加对 events_dataset 资产的输出引用
现在运行这个 DAG,在 Airflow UI 中启用它。Airflow 应该会为前一天触发一次运行,抓取那一天的数据,并发出一个资产事件,表示该数据集已被更新。
你可以在 Airflow UI 的 Assets 标签页查看生成了哪些资产事件。选择我们刚刚创建的 file:///data/events_01 资产后,会看到如图 4.4 所示的界面:左边展示生成该数据集的生产者 DAG,右边展示该 DAG 运行后所生成的资产事件列表。
图 4.4 Airflow UI 中的资产视图。左边展示资产与生产者 DAG 的关系,右边展示由生产者 DAG 的运行所生成的事件。
4.4 消费资产事件
现在我们已经有了一个能更新数据集的 DAG,接下来要把下游的消费者 DAG 接上。这个 DAG 会在数据集更新时自动触发,并计算相关统计。它不再需要固定的时间表,而是由事件数据集资产的更新来触发。
你应该还记得第 3 章里的统计计算代码:它读取某一天的事件数据,计算这些事件的统计结果,并把输出写入一个以该日期命名的 CSV 文件中,如下所示(chapter03/dags/06_interval_delta.py)。
代码清单 4.3 第 3 章中的 calculate_stats 代码
def _calculate_stats(input_path, output_path):
"""Calculates event statistics."""
...
with DAG(
dag_id="06_interval_delta",
schedule=CronDataIntervalTimetable("@daily", timezone="UTC"),
start_date=pendulum.datetime(year=2024, month=1, day=1, tz="Europe/Amsterdam"),
end_date=pendulum.datetime(year=2024, month=1, day=5),
catchup=True,):
...
calculate_stats = PythonOperator(
task_id="calculate_stats",
python_callable=_calculate_stats,
op_kwargs={
"input_path": "/data/06_interval_delta/events/
➥ {{ logical_date | ds }}.json",
"output_path": "/data/06_interval_delta/stats/
➥ {{ logical_date | ds }}.csv",
},
)
要把这个 DAG 改造成事件驱动的版本,我们需要做两件事:
- 定义对第 4.3 节中创建的那个资产的引用;
- 把这个资产引用设置为 DAG 的
schedule,如下所示(dags/01b_basic_consumer.py)。
代码清单 4.4 将调度改为引用事件数据集资产
from airflow.sdk import DAG, Asset
events_dataset = Asset("file:///data/events_01") #1
with DAG(
dag_id="01b_consumer",
schedule=[events_dataset], #2
start_date=pendulum.datetime(year=2024, month=1, day=1),
):
...
#1 定义该数据集资产,URI 与前面保持一致
#2 将 DAG 的调度设置为这个资产
就这么简单。现在,只要生产者 DAG 更新了 URI 为 file:///data/events 的资产,消费者 DAG 就会被触发。你可以通过启用消费者 DAG,然后清除生产者 DAG 中的某个任务来测试这一点。被清除的任务会重新运行,从而生成一个新的资产事件并触发消费者 DAG。
不过,此时你会遇到如下错误信息,这说明你还没有完全处理好这个场景:
Exception rendering Jinja template for task 'calculate_stats', field
➥ 'op_kwargs'. Template: {'input_path': '/data/events/{{ logical_date |
➥ ds }}.json', 'output_path': '/data/stats/{{ logical_date | ds
➥ }}.csv'}: source="airflow.sdk.definitions._internal.abstractoperator"
UndefinedError: 'logical_date' is undefined
之所以会报这个错误,是因为事件驱动的 DAG不像第 3 章中的时间驱动 DAG那样,会自动定义日期或时间区间相关参数。从概念上说,这很合理:资产驱动 DAG 并不遵循基于时间的调度,所以这些概念在这里并不天然成立。
这就带来了一个非常有意思的问题:如果我们不知道 DAG 这次是为哪一天运行的,那消费者 DAG 该读取哪一块数据呢?
一种可行的思路是去查看生成该资产事件的上游 DAG run——生产者 DAG 是有时间调度的,因此它理应包含这类信息。
Airflow 允许我们通过 Airflow 上下文中的 triggering_asset_events 变量访问这些信息。这个变量是一个事件字典:
- key 是资产本身(因为一个 DAG 可能被多个资产触发);
- value 是事件列表(因为一个 DAG 对同一个资产也可能生成多个事件)。
在我们的场景中,只有一个触发资产,并且理论上只有一个事件,所以我们可以像下面这样取出触发我们 DAG 的那个事件对应的生产者 DAG 的逻辑日期(dags/01b_basic_consumer.py)。其中:
| first | first用于取出该资产的第一个(也是唯一一个)事件;source_dag_run属性允许我们进一步访问生成这个事件的生产者 DAG run 的logical_date。
代码清单 4.5 基于源 DAG run 来定义 input_path / output_path
calculate_stats = PythonOperator(
task_id="calculate_stats",
python_callable=_calculate_stats,
op_kwargs={
"input_path": "/data/events/{{
➥ (triggering_asset_events.values() | first |
➥ first).source_dag_run.logical_date }}.json", #1
"output_path": "/data/stats/{{
➥ (triggering_asset_events.values() | first |
➥ first).source_dag_run.logical_date }}.csv", #2
},
)
#1 根据源 DAG run(即生产者 DAG 的那次运行)的逻辑日期来定义输入路径和输出路径
#2 通过生产者 DAG run 中的变量值来生成输出路径
Airflow 3 中的异常行为:缺失的 source_dag_run 属性
遗憾的是,在写作本书时,这个例子在 Airflow 3 中其实是跑不通的,因为 source_dag_run 属性似乎在从 Airflow 2 切换到 Airflow 3 的过程中被意外移除了(详见:https://github.com/apache/airflow/issues/52932)。由于官方文档里仍然保留着这一示例,我们预计这个问题很快会被修复。在此之前,你可以使用第 4.5 节中讨论的“元数据”方案作为替代。
现在,如果你重新运行这个消费者 DAG(可以清除消费者 DAG 的任务,也可以清除上游生产者 DAG 的任务),你应该能看到该任务成功读取正确的文件,并生成相应的统计结果。如果打开该资产的资产视图,你也会看到消费者 DAG 已经和这个资产建立了连接(图 4.5)。
图 4.5 事件数据集资产的视图,现在已经包含它与消费者 DAG 的关系
警告
你之所以能用 logical_date 来生成文件路径,只是因为生产者 DAG 本身是按时间调度运行的。如果这个生产者 DAG 也是由事件触发,或者是通过手动方式触发,那么它就不会包含这些时间相关信息。用事件驱动调度建立 DAG 之间关系时,必须考虑这种依赖:因为一旦上游生产者 DAG 的调度类型发生变化,下游消费者 DAG 也会受到影响——尤其是当消费者依赖这些时间调度信息时。
4.5 为事件附加额外信息
还有另一种办法。Airflow 允许你在资产事件中添加元数据(metadata) 。这些元数据会体现在事件的 extra 字段中,本质上就是一个字典,你可以根据需要放入任意键值对。
在我们的场景里,就可以为事件增加一个 date 元数据字段,用来表示本次加载的是哪一天的数据。为此,我们修改生产者 DAG 中的 fetch_events 任务,让它生成一个 Metadata 对象,并把所需信息写进去,如下所示(dags/02a_metadata_producer)。
代码清单 4.6 向资产事件中添加 Metadata 对象
from airflow.sdk import DAG, Asset, Metadata #1
...
def _fetch_events(start_date, end_date, output_path, logical_date): #2
...
yield Metadata(events_dataset, extra={"date": logical_date.strftime("%Y-%m-%d")}) #3
#1 导入 Metadata 类
#2 修改函数签名,把 Airflow 上下文中的逻辑日期也传进来
#3 返回一个 Metadata 实例,引用该资产,并基于逻辑日期定义一个 date 元数据属性
你可以运行这个新的 DAG,然后在 UI 的 Assets 标签页中查看生成的事件。此时,新生成的事件里会包含一个额外的元数据字段;如果展开这个字段,就能看到每次运行对应的日期(图 4.6)。
图 4.6 资产事件视图,现在包含了来自 extra 元数据字段的数据
在消费者 DAG 中,我们就可以通过资产事件的 extra 字段来引用这个新元数据值。这个值可以用来生成输入路径和输出路径,从而读取输入数据并写出统计结果(dags/02b_metadata_consumer.py)。
代码清单 4.7 在消费者 DAG 中提取新增的元数据
calculate_stats = PythonOperator(
task_id="calculate_stats",
python_callable=_calculate_stats,
op_kwargs={
"input_path": "/data/events_02/{{
➥ (triggering_asset_events.values() | first | first).extra.date
➥ }}.json",
"output_path": "/data/stats_02/{{
➥ (triggering_asset_events.values() | first |
➥ first).extra.date }}.csv",
},
)
警告
在定义这些元数据属性时,必须和下游消费者明确约定:会提供哪些字段、每个字段代表什么含义。你很容易把这些字段当成“顺手多带一点额外信息”,但实际上它们是你和下游消费者之间数据契约(data contract) 的重要组成部分。因为这些字段一旦变化,很可能就会直接导致下游流水线出错。
4.6 跳过更新
通常情况下,只要一个带有 outlets 的任务成功完成,Airflow 就会自动为其生成一个资产事件。但在某些场景下,你可能并不希望发送这个事件。最典型的例子是:任务虽然执行了,但底层数据集其实并没有变化——比如源系统里根本没有新数据。
为了处理这种情况,Airflow 允许你在生成事件的任务中抛出 AirflowSkipException,从而跳过这次更新(代码清单 4.8;dags/03a_skip_producer.py)。这样,任务会被标记为“跳过(skipped)”,也就不会生成资产事件,更不会去触发下游 DAG。
为了演示这一点,我们给生产者 DAG 中的 fetch_events 任务加一个判断:如果目标数据路径已经存在,就直接跳过任务。这样就只有在抓取到“某个此前从未见过的日期切片”时,才会真正触发下游消费者 DAG。换句话说,如果某个日期的数据文件已经存在,就假定消费者已经处理过它了,不需要再次触发——当然,这个假设在别的业务场景下不一定成立。
代码清单 4.8 如果数据路径已经被抓取过,就跳过这次资产更新
from pathlib import Path
from airflow.exceptions import AirflowSkipException
...
def _fetch_events(start_date, end_date, output_path):
if Path(output_path).exists():
raise AirflowSkipException()
else:
...
现在,如果你第一次运行这个 DAG,会看到它抓取数据并触发下游消费者 DAG。
接着,如果你把这个任务清除并重跑一次,那么这一次它就会被跳过,因为对应的数据文件已经存在了(图 4.7)。同样,下游 DAG 也不会被触发。
图 4.7 第二次运行该任务时,由于数据文件已经存在,因此任务被跳过。这样一来,监听这个资产的下游 DAG 也不会被触发,因为没有新的数据可处理。
4.7 消费多个资产
在更复杂的工作流中,Airflow 允许你在一个 DAG 中同时生成多个资产,或者同时消费多个资产。一个常见的例子是:某个消费者 DAG 需要读取多个输入数据集,将它们合并(join)后再生成一个或多个输出。为了说明这一点,我们把事件 DAG 扩展一下,让它同时消费两个输入数据集(图 4.8)。
图 4.8 添加第二个生产者,它从另一个来源抓取额外的事件数据。
这种情况在现实中很常见。比如,你突然有了多个事件源,所有这些事件都需要被合并成一个统一的输出。
为了模拟这个场景,我们新增一个额外的生产者 DAG,用来生成事件。为了简单起见,这里直接复制现有 DAG,只改两个地方:
- 改 DAG ID;
- 改数据写入路径。
在真实业务里,这个 DAG 当然也会从另一个不同的源(比如另一个 API)抓取事件;但为了简化演示,我们先让两个生产者都还是从同一个 API 拉数据(代码清单 4.9;dags/04b_multi_producer2.py)。
代码清单 4.9 创建第二个生产者 DAG
from airflow.sdk import DAG, Asset, Metadata
...
events_dataset = Asset("file:///data/events_04_2") #1
with DAG(
dag_id="04b_multi_producer2”,
...
):
fetch_events = PythonOperator(
...
op_kwargs={
...
"output_path": "/data/events_04_2/
➥ {{ data_interval_start | ds }}.json", #2
},
...
)
#1 通过修改资产路径,创建第二个资产
#2 将数据路径也改成与之对应的新路径
这样一来,我们就有了两个生产者 DAG:每个分别对应一个事件源,以及一个消费者 DAG。为了让消费者 DAG 同时消费这两个生产者的数据,我们需要在消费者 DAG 中同时引用这两个资产,并把它们都放进 schedule 中(dags/04c_multi_consumer.py)。
代码清单 4.10 同时引用多个资产的消费者 DAG
events_dataset_1 = Asset("file:///data/events_04_1")#1
events_dataset_2 = Asset("file:///data/events_04_2") #1
with DAG(
dag_id="04c_consumer",
schedule=[events_dataset_1, events_dataset_2],#2
start_date=pendulum.datetime(year=2024, month=1, day=1)
):
#1 分别为两个数据集创建引用
#2 调整 DAG 的调度,使其同时依赖这两个资产
接下来,我们还要保证消费者 DAG 真正会处理来自两个数据源的数据。为此,可以把 _calculate_stats 函数改成接受一个“输入文件列表”,而不是单个输入文件,如下所示(dags/04c_multi_consumer.py)。
代码清单 4.11 处理来自多个资产的数据
def _calculate_stats(input_paths, output_path): #1
"""Calculates event statistics."""
events = pd.concat(
pd.read_json(input_path, convert_dates=["timestamp"],
➥ lines=True) for input_path in input_paths
) #2
stats = (
events.assign(date=lambda df: df["timestamp"].dt.date).
➥ groupby(["date", "user"]).size().reset_index()
)
Path(output_path).parent.mkdir(exist_ok=True)
stats.to_csv(output_path, index=False)
#1 修改函数签名,使其接受多个路径
#2 读取多个文件并将其内容拼接成一个统一的数据集
这个修改后的函数会从一个或多个输入文件中读取数据,并将它们合并为一个整体数据集,之后的统计计算逻辑则保持不变。
最后,我们还需要调整 PythonOperator 任务,让它把一个 input_paths 参数传进去,并且显式引用两个数据集各自对应的事件。这里依然使用我们前面提到的 triggering_dataset_events 变量,不过这次我们要显式地为每个数据集取出对应的事件。
为了方便这件事,我们定义了一个小宏函数 get_event,用于从某个资产对应的事件列表中取出第一个事件(关于模板与宏,详见第 5 章)。在 DAG 中,我们可以在路径模板里调用这个宏函数,并传入对应资产的 URI,以确保拿到的是正确的数据事件(dags/04c_multi_consumer.py)。
代码清单 4.12 从特定资产中提取特定事件
def _get_event(triggering_asset_events, uri): #1
return triggering_asset_events[Asset(uri)][0]
with DAG(
...
user_defined_macros={"get_event": _get_event}, #2
):
...
calculate_stats = PythonOperator(
task_id="calculate_stats",
python_callable=_calculate_stats,
op_kwargs={
"input_paths": [
"/data/events_04_1/{{get_event(triggering_asset_events,
➥ 'file:///data/events_04_1').extra.date }}.json", #3
"/data/events_04_2/{{get_event(triggering_asset_events,
➥ 'file:///data/events_04_2').extra.date }}.json",
],
"output_path":
➥ "/data/stats_04/{{get_event(triggering_asset_events,
➥ 'file:///data/events_04_1').extra.date }}.csv",
},
)
#1 定义一个自定义宏函数,用来获取某个特定资产的第一个事件
#2 把这个宏注册到 DAG 中
#3 用这个宏从资产事件的 extra 字段中提取 date
现在我们已经把 DAG 改造成了可以处理多个输入数据集的版本,接下来就可以跑起来试试了。
如果打开 Airflow UI 中的 DAGs 视图,会看到新的 DAG(04c_multi_consumer)正在等待来自两个不同资产的事件(图 4.9)。同样,如果打开 Assets 视图并选择任意一个资产(比如 file:///data/events_04_1),就会看到这两个资产以及它们和两个生产者 DAG、一个消费者 DAG 之间的关系(图 4.10)。
图 4.9 DAG 列表视图,显示新 DAG 正在等待来自两个不同资产的事件。左边那个数字会在事件到来时增加,并在消费者 DAG 运行完成后重置。
图 4.10 资产视图,展示两个资产,以及它们与两个生产者 DAG 和一个消费者 DAG 之间的关系。
为了产生第一个事件,我们先启用消费者 DAG(04c_multi_consumer),再启用第一个生产者 DAG(04a_multi_producer)。这个生产者 DAG 运行后会生成一个事件。此时,这个变化应该能反映在消费者 DAG 的状态上,但还不会触发消费者 DAG,因为目前我们只有一个资产事件。
接下来,再启用第二个生产者 DAG(04b_multi_producer),让它生成第二个事件。这个时候,消费者 DAG 就应该被触发了,因为现在两个输入资产都已经各自有了事件。
默认情况下,Airflow 对“依赖多个输入资产”的 DAG 的触发逻辑是这样的:
只要所有输入资产自该 DAG 上次运行以来都至少有一个新事件,这个 DAG 就会被触发。
在我们的例子里,正是在第二个生产者产生事件时满足了这个条件,所以消费者 DAG 被触发。
这些事件不一定必须一一成对出现:如果某一个资产连续产生了多个事件,但另一个资产还没有新事件,那么消费者 DAG 仍然不会被触发。只有当第二个资产也出现了事件,它才会运行。图 4.11 对这种行为做了总结。
图 4.11 每个星号都表示第一个或第二个数据集的一次资产更新。只有当两个数据集都至少被更新过之后,消费者 DAG 才会触发运行。资产更新的顺序并不重要。
对于更复杂的场景,Airflow 还允许你通过条件逻辑来定义资产之间的关系(代码清单 4.13)。
- 使用
AND (&)连接两个资产,表示 Airflow 要等待两个资产都产生事件,这就对应了我们前面看到的默认行为。 - 使用
OR (|)连接两个资产,则表示只要任意一个资产产生事件,就触发该 DAG。
你也可以把这些运算符组合起来形成更复杂的表达式,不过要注意不要把逻辑设计得过于复杂。
代码清单 4.13 在资产之间定义复杂条件逻辑
with DAG(
schedule=(asset_1 | (asset_2 & asset_3)), #1
...,
):
...
#1 当资产 1 收到事件,或者资产 2 和资产 3 都收到了事件时,触发该 DAG
总体来说,使用多个资产进行资产感知调度时有一个需要特别注意的点:
Airflow 不会自动考虑上游 DAG 的时间调度语义。
它只会根据事件本身来触发 DAG,而不会关心这些事件分别对应的是哪个执行日期。也就是说,不管这些事件代表的是哪一天的数据,只要组合条件满足,Airflow 就会触发你的 DAG。
如果你希望输入数据必须来自同一个执行日期,那就必须自己在 DAG 中加上这层校验逻辑,并且妥善处理那些输入文件日期不匹配的情况。
4.8 结合时间调度与资产调度
在某些场景里,你既希望 DAG 能够在上游数据变化时尽快响应,又希望它按照固定时间表稳定运行。对于这种“混合型”需求,可以使用 AssetOrTimeSchedule 类,它允许你同时定义一个基于时间的 timetable 和一个资产表达式。
代码清单 4.14 组合时间调度与资产调度
from airflow.sdk import DAG, Asset
from airflow.timetables.assets import AssetOrTimeSchedule
from airflow.timetables.trigger import CronTriggerTimetable
example_dataset = Asset("file:///data/example")
with DAG(
schedule=AssetOrTimeSchedule(
timetable=CronTriggerTimetable("0 1 * * 3", timezone="UTC"),
assets=[example_dataset],
),
...
):
...
当 DAG 既需要对数据集更新快速作出反应,又必须按固定节奏执行检查或更新时,这个特性会特别有用。不过,在组合不同调度方式时一定要小心。因为不同 timetable 有不同的语义,比如有的包含数据区间,有的没有,你必须在 DAG 内部按各自语义正确处理它们。
小结
- Airflow 的资产功能支持事件驱动(或称资产感知)的调度:任务执行由数据可用性或数据变化触发,而不是严格依赖时间间隔。
- 在数据流水线中,资产感知调度非常适合把关注点拆分到不同 DAG 中。生产者 DAG 负责拉取/转换数据并产出一个或多个数据集,消费者 DAG 则在这些数据集更新时被触发,并进一步产出自己的数据集。
- 你可以通过传入消费者 DAG 的
triggering_asset_events变量,访问上游 DAG 的信息(例如上游 DAG 的调度区间)。 - 生产者 DAG 可以通过资产事件的
extra字段附带额外信息;任何被该事件触发的下游 DAG 都可以读取这些信息。 - 当生产者 DAG 没有真正修改某个资产时,可以通过抛出
AirflowSkipException来跳过下游更新。这在 DAG 运行了但资产内容并未变化、不应触发下游 DAG 时非常有用。 - 消费者 DAG 可以依赖多个资产。默认情况下,当所有输入资产都至少出现过一次新事件时,该 DAG 才会执行。对于更复杂的关系,可以使用资产条件逻辑。不管哪种方式,这种功能都不会自动考虑上游 DAG 的时间调度语义。
- 如果某个 DAG 既需要及时响应数据集更新,又需要定期运行,那么可以将时间调度与资产调度组合使用。