想象你是一家米其林餐厅的主厨。某天,一位顾客对松露过敏,你必须立刻说清“松露烩饭”里的松露出自哪个农场、经过哪些加工环节、是否接触过其他过敏原。如果后厨从来没有记录,你只能干瞪眼。而在大数据世界里,这种“干瞪眼”每天都在发生:报表数字突然异常、模型效果莫名下跌、合规审计要求举证数据来源,却没有人能说清楚这份数据从哪来、经过哪些加工、又被谁使用过。 数据血缘(Data Lineage)就是数据世界的“食材溯源”——记录每一份数据从源头到成品的完整流转链。OpenLineage则是厨师协会(开源社区)制定的一套统一溯源标准,它规定每家后厨(数据处理引擎)按同样的格式记录“什么时间、谁、用了哪些食材、做出了什么菜”,最终拼出一张完整的“数据家谱”。这篇文章会用“后厨溯源”的故事,带你从概念、架构、收集机制到MLOps实践,一步步建立OpenLineage的完整知识地图。
对AI从业者而言,OpenLineage是你搭建企业级数据治理体系的“通用语言”。掌握它,你就能回答“数据从哪来、到哪去、被谁改过”这类灵魂拷问,这是数据工程师和MLOps岗位的核心竞争力。
目录
可视化演示
展示数据血缘DAG图(数据源->ETL->转换->ML/BI)及OpenLineage三元数据模型(Run/Job/Dataset),追踪数据在管道中的流动路径。
本节配有交互动效(进入视口后自动加载,约 3KB)。
OpenLineage血缘 SRC ETL Tfm ML BI 数据源到消费端全链路追踪 Run+Job+Dataset 三元模型记录数据流转元数据
血缘概念基础:给每道菜装上食材溯源
米其林主厨的看家本领不只是做菜,更是对每一道菜的源头了然于心。松露来自哪个农场、黄油是哪家牧场、酱汁熬了多久——这些信息构成了这道菜的“身份档案”。数据世界同样如此。一张报表、一个模型特征、一条业务指标,背后都有一长串加工链路。数据血缘(Data Lineage)就是为每份数据建立身份档案,记录它的完整生命旅程。
在技术层面,数据血缘用一张图来刻画。图中有两类基本元素:节点(Node)和边(Edge)。节点代表数据集(一张表、一个文件)或作业(一次清洗、一次聚合),边代表数据流转关系。把节点比作“菜”,边就是“传递动作”——厨师把半成品递给下一个工位。因为数据只会从源头流向终点、不会倒流,所以血缘图是一张有向无环图(DAG)。
有了这张“后厨流程图”,当报表数字异常时,你就能沿边逆流而上,找到最初出问题的“食材”——这就是血缘回溯。当你要修改一张底表时,也能顺流而下,看清会影响哪些下游报表——这就是影响分析。血缘图让数据治理从“出了事到处问”升级为“打开图一目了然”。
- 节点集合 V图中的每个点,对应一张数据表或一个处理作业。就像后厨里的每个工位和每份食材。
- 边集合 E点与点之间的有向连线,表示数据从 $v_i$ 流向 $v_j$。类似“食材从备菜台递到炒锅工位”。
- 有向无环性质边有方向且不形成环,保证数据流动有始有终。如同食材加工不会绕回前一道工序。
起源演进历程:从各家账本到厨师协会统一标准
每家餐厅都有自己的后厨记录习惯:A记在本子上,B拍在手机里,C贴满便签纸。当厨师们想互相交流食材供应商时,发现谁也看不懂谁的记录。大数据生态早期正是如此——每个计算引擎都有自己的血缘方案:Spark有QueryExecutionListener,Hive有钩子机制,Flink有内置血缘API。接口五花八门,数据格式互不兼容,统一治理成了奢望。
2018年,大数据公司Datakin(后并入Astronomer)启动了Marquez项目,最初只是一套开源的数据血缘服务。随着越来越多团队呼吁“别再重复造轮子”,社区决定把“记录格式”也做成标准。于是OpenLineage项目诞生,并捐赠给LF AI & Data基金会托管。它定义了一套中立的血缘事件规范,任何引擎——Spark、Flink、dbt、Airflow——只要按规范输出事件,就能被同一套后端服务接收和解析。
OpenLineage聪明的地方在于“兼容而非替代”:它不要求你改动现有引擎,而是通过插件式提取器适配各平台。就像厨师协会不强迫大家换锅,只是统一了标签格式。这一策略让标准快速普及,成为当下数据血缘领域事实上的“通用语言”。
- 已接入的作业数已经按OpenLineage规范输出事件的引擎种类。类似“已经用标准溯源标签的后厨数量”。
- 全部作业数公司内所有数据处理作业的总数。类似后厨所有工位加起来的总数。
- 采用率 r两者的比值,衡量血缘标准的覆盖程度。就像“贴了标准标签的菜品 ÷ 全部菜品”。
OpenLineage架构:后厨的溯源记录系统
一套完整的溯源记录系统需要三个环节:谁来记录、怎么传递、存到哪里。在OpenLineage中,答案分别是客户端采集器、事件传输层和后端服务。客户端采集器安装在每个“灶台”(Spark、Flink等引擎)上,像一位隐形记录员,自动捕捉作业开始、结束等关键动作,生成标准格式的事件。传输层通过网络将事件送往中央仓库,后端服务负责存储、索引,并提供血缘查询API。
这就像给每位厨师配了一本会自动填写的“工序登记簿”。过去厨师要腾出手记录“我用了什么料”,现在传感器自动完成,厨师只需专心炒菜。OpenLineage客户端内含多个提取器(Extractor),每个提取器对应一种引擎。Spark提取器监听Stage的提交,Flink提取器跟踪算子链,dbt提取器解析模型编译过程。它们各自独立,互不干扰。
后端服务通常采用消息队列(如Kafka)缓冲事件流,再由消费者写入图数据库或关系库。查询API则对外提供两个核心能力:给定一个数据集,回答“它的上游是什么”和“它的下游是什么”。整个架构解耦清晰——后厨只需专注做菜,中央档案室自动归档,管理层随时调阅。
- 事件总数一段时间内OpenLineage客户端发出的血缘事件量。类似后厨一天之内自动登记的工序条数。
- 时间窗口统计这些事件所跨的时间长度。类似从开门到打烊的营业时长。
- 吞吐率 Q两者相除,衡量采集链路的数据处理能力。像“一天登记了多少条工序记录”。
元数据收集机制:自动记录的传感器
为什么元数据要“自动收集”?因为人工登记永远不靠谱。忙起来时,厨师不会记得记;换人时,记录格式就变;更重要的是,人工记录没有统一的结构,事后难以分析。OpenLineage的解法是“零侵入自动监听”——利用计算引擎已有的生命周期回调机制,在作业执行的恰当时刻自动生成事件。
以Spark为例,OpenLineage客户端注册到Spark的ListenerBus上,当作业的Action启动或完成时,回调函数被触发,继而构造OpenLineage事件。事件里包含执行上下文:谁(Job)在什么时间(Run ID)读了哪些表(Inputs)、写了哪些表(Outputs)。这些信息以Facet的形式组织,Facet可以理解为“标签的附加属性”——比如写一个表时的模式(Schema)、行数、分区信息。
这套机制将元数据收集从“人肉填写”变成“机器上报”。副作用是带来少量性能开销——序列化事件、网络传输都需要耗时。因此生产实践通常采用异步上报队列,采集线程把事件塞进内存队列,后台批量推送,避免阻塞任务主流程,就像后厨的传感器先记录在本机、打烊后再统一上交。
- 启动事件数作业开始运行时自动发出的START事件数量。类似后厨记录“开始处理这道菜”的次数。
- 完成事件数作业运行结束后发出的COMPLETE事件数量。类似“这道菜已完成”的登记条数。
- 完成率 λ两者比值。如果启动多而完成少,说明有作业中途失败或事件丢失。
规范事件模型:统一标签的模板
厨师协会要做统一标签,首先要确定标签上印哪些字段。OpenLineage事件规范定义了三个核心实体:Run(一次运行实例)、Job(作业定义)和Dataset(数据集)。打个比方:Job是“红烧肉这道菜的做法”,Run是“今天中午王师傅实际做的那一锅”,Dataset则是“出锅装盘的那份肉”以及“下锅前的那份生肉”。
每次作业执行,客户端会发出多个事件:启动时发START、成功发COMPLETE、失败发FAIL。事件负载(payload)中包含Run的ID、Job的名称(带命名空间)、输入输出数据集列表,以及丰富的Facet信息。命名空间是防止重名的关键设计——两个团队可能都有“user_table“,但”dwh://core.user_table“和”ml://experiment.user_table“绝对不会混淆,就像”北京三里屯的俏江南“和”上海外滩的俏江南”是两家店。
事件类型方面,START表示开始、COMPLETE表示成功结束、FAIL表示失败退出,另外还有ABORT、OTHER等扩展类型。这套模型的美妙之处在于足够通用:无论底层是批处理还是流处理,只要表达“谁在何时用什么数据产出了什么数据”,就能拼接出一致的血缘图。
- Run 运行实例作业某次具体执行的唯一标识。类似“今天下午2点王师傅炒的那锅”。
- Job 作业定义稳定的作业模板,与具体某次执行无关。类似“红烧肉这道菜的标准做法”。
- 输入输出集合本次运行读取和写入的数据集清单,是血缘图中连边的来源。
MLOps实践融合:实验菜品同样可溯
如果说传统ETL是稳定的招牌菜,那机器学习模型就是不断迭代的实验菜。实验菜更要讲究溯源——同一份数据今天训练和明天训练,模型效果可能完全不同。当模型上线后效果下跌,ML工程师最需要回答的是:“这个版本用了哪份训练数据?特征从哪张表来的?超参数是什么?” OpenLineage恰好能为这些问题提供标准答案。
在MLOps流水线中,训练作业是一个标准的OpenLineage Job:输入是特征表和标签表,输出是模型文件。当训练完成,一条COMPLETE事件把“特征版本、数据分区、模型路径”全部关联起来。配合MLflow等模型注册工具,血缘图还能延伸到模型部署环节——哪个服务在用哪个版本的模型,模型又依赖哪份数据,形成“数据→特征→模型→服务”的全链路血脉。
有了这层血缘,MLOps团队可以从“玄学调参”走向“科学工程”。效果下降时,先查训练数据是否被底层的清洗逻辑改动;合规审计时,能出示模型训练所用的完整数据来源。菜品可以迭代,但每一版的口味来源都清清楚楚,这正是OpenLineage带给MLOps的最大价值。
- 特征集合模型用来预测的输入特征列表,每个特征都能溯源到一张数据表。类似菜谱中的“调料清单”。
- 训练数据实际喂给模型的样本集,包括时间范围、分区、版本。
- 超参数与模型超参数记录训练配置,模型文件记录最终产物。
实施落地挑战:推行标准的后厨阻力
任何标准的落地都不会一帆风顺。OpenLineage推广中第一个坎是采集覆盖不全:公司可能有上千个作业,但只有部分引擎有现成提取器,老旧的调度脚本或自研框架得自己写提取器。血缘图出现“断点”,就像一张溯源链中间有一环没记录,整条链的可信度就打折。
第二个坎是性能开销。虽然异步上报能缓解压力,但高频作业场景下事件量依然巨大。如果后端存储设计不合理,查询血缘图会越来越慢。实践中需要做事件采样、聚合和分级存储——热数据用图数据库,冷数据归档到对象存储,并给血缘图设置保留期限。第三个坎是组织协作:血缘治理需要数据平台团队、业务数仓团队、算法团队一起参与,任何一方不配合,图就缺角。因此落地顺序通常是从核心链路开始,逐步扩展。
回看整个推行过程:标准统一是第一步,工具适配是第二步,组织协同是第三步。就像厨师协会发布再好的溯源标准,也得一家店一家店去说服、去培训、去检查。但只要度过了最艰难的初期,血缘图就会成为全公司数据资产的地基,让每次数据变更都有据可查、有迹可循。
- 可回溯节点数从该节点出发,能沿着血缘边追溯到数据源头并形成完整路径的节点数。
- 总节点数血缘图中的全部数据集节点。
- 完整度 C两者比值,目标逼近1但通常达不到,这暴露了采集覆盖的盲区。
AI 应用场景
场景一:数据质量事故的快速定位
阶段:数据治理与质检
解决的问题:传统模式下,报表数字异常时,数据工程师要拉上数仓、算法、业务各个团队开会排查,靠“人肉记忆”推断数据链路,动辄数小时甚至数天。接入OpenLineage后,每个数据集都有完整的血缘图,输入异常数据集即可秒级回溯所有上游表和作业,直接定位“是哪份源数据、哪个清洗逻辑出了问题”。
具体案例:某电商公司日活报表突然下降20%,数据团队通过OpenLineage血缘图发现“订单清洗任务”在上个版本中过滤条件从status=1误改为status=2,血缘图上该任务的下游影响一目了然,修复后5分钟内上线。
场景二:底层表变更的影响分析
阶段:数据工程与变更管理
解决的问题:数仓团队想对一张底表增加字段或修改清洗逻辑,但不知道这张表被多少下游任务引用。过去靠人工梳理Excel依赖清单,经常遗漏。OpenLineage血缘图顺流而下,列出该表所有下游作业和最终报告,变更前先看影响范围。
具体案例:某金融公司计划上线新客户分层逻辑,通过血缘图发现该表被下游32个作业引用,其中3个是监管报送报表。团队据此规划了分批次变更窗口,并提前通知业务方验证,避免了一次潜在的合规事故。
场景三:ML模型训练数据追溯
阶段:MLOps与模型治理
解决的问题:模型效果波动时,算法工程师需要知道当前模型版本用了哪些训练数据和特征。OpenLineage将训练作业的输入输出记录为标准事件,结合模型注册表,就能回答“这个模型是谁在什么时候用什么数据训练出来的”。
具体案例:某推荐团队发现线上模型AUC下降,通过模型血缘追溯,发现训练数据中的“用户行为表”因上游清洗逻辑改动,过滤掉了部分深夜行为数据。团队定位到数据变更点后,修正了特征数据源,模型效果一周内恢复。
Python 演示
# -*- coding: utf-8 -*-"""模拟OpenLineage事件生成与数据血缘图构建"""from collections import defaultdict class Dataset: """数据集,对应OpenLineage中的Dataset实体""" def __init__(self, name, namespace="spark://prod"): self.name = name self.namespace = namespace def __repr__(self): return self.name class Job: """作业,对应OpenLineage中的Job实体""" def __init__(self, name, namespace="spark://prod"): self.name = name self.namespace = namespace self.inputs = [] self.outputs = [] def add_io(self, inputs, outputs): self.inputs.extend(inputs) self.outputs.extend(outputs) def __repr__(self): return self.name class LineageGraph: """基于事件流还原的数据血缘图""" def __init__(self): self.jobs = {} self.upstream = defaultdict(set) self.downstream = defaultdict(set) def consume_event(self, event): """消费一个OpenLineage事件,更新血缘图""" job_name = event["job"]["name"] run_id = event["run"]["id"] if job_name not in self.jobs: self.jobs[job_name] = Job(job_name) job = self.jobs[job_name] inputs = [Dataset(i["name"]) for i in event["inputs"]] outputs = [Dataset(o["name"]) for o in event["outputs"]] job.add_io(inputs, outputs) for inp in inputs: for out in outputs: self.upstream[out.name].add(inp.name) self.downstream[inp.name].add(out.name) print(f"[{run_id[:8]}] {job_name}: {len(inputs)}个输入 -> {len(outputs)}个输出") def get_upstream(self, dataset_name): """血缘回溯:找出某数据集的所有祖先""" visited = set() stack = list(self.upstream.get(dataset_name, [])) while stack: cur_name = stack.pop() if cur_name in visited: continue visited.add(cur_name) stack.extend(self.upstream.get(cur_name, [])) return visited def get_downstream(self, dataset_name): """血缘追踪:找出某数据集的所有后代""" visited = set() stack = list(self.downstream.get(dataset_name, [])) while stack: cur_name = stack.pop() if cur_name in visited: continue visited.add(cur_name) stack.extend(self.downstream.get(cur_name, [])) return visited def build_sample_events(): """模拟Spark清洗+聚合两个作业产生的OpenLineage事件""" return [ { "eventType": "START", "run": {"id": "run-001"}, "job": {"name": "user_etl.clean"}, "inputs": [{"name": "orders_raw"}], "outputs": [{"name": "orders_clean"}], }, { "eventType": "COMPLETE", "run": {"id": "run-001"}, "job": {"name": "user_etl.clean"}, "inputs": [{"name": "orders_raw"}], "outputs": [{"name": "orders_clean"}], }, { "eventType": "START", "run": {"id": "run-002"}, "job": {"name": "user_etl.aggregate"}, "inputs": [{"name": "orders_clean"}], "outputs": [{"name": "user_daily_report"}], }, { "eventType": "COMPLETE", "run": {"id": "run-002"}, "job": {"name": "user_etl.aggregate"}, "inputs": [{"name": "orders_clean"}], "outputs": [{"name": "user_daily_report"}], }, ] def main(): graph = LineageGraph() for event in build_sample_events(): graph.consume_event(event) print("\n血缘回溯 orders_clean 的上游:") for name in sorted(graph.get_upstream("orders_clean")): print(f" <- {name}") print("\n血缘追踪 orders_clean 的下游:") for name in sorted(graph.get_downstream("orders_clean")): print(f" -> {name}") if __name__ == "__main__": main()
class LineageGraph:血缘图容器,负责消费事件并构建上下游关系,对应OpenLineage后端服务的核心逻辑。consume_event(self, event)消费单个OpenLineage事件,提取Job和输入输出表,类似客户端将事件POST到后端。self.upstream[out.name].add(inp.name)建立“输出表←输入表”的边,记录食材从哪个源流向哪个成品。get_upstream()用栈实现深度优先遍历,找出某张表的全部祖先,即血缘回溯查询。get_downstream()对称地找出某张表的全部后代,即影响分析查询。build_sample_events()构造两组作业的START/COMPLETE事件,模拟真实采集链路的数据格式。
常见误区
误区“我们公司有数据字典,每张表的字段含义都写了,这不就是数据血缘吗?”
正解数据字典是“静态的菜谱”——它告诉你每道菜用了什么食材,但不告诉你这道菜是哪位厨师、什么时候、在哪口锅里做出来的。血缘关注的是“数据如何流转”的动态过程,包含作业、运行实例、时间等多维信息。把菜谱当溯源记录,就会漏掉加工环节,永远回答不了“这个字段为什么变了”。
为何常见:两者都涉及元数据,初学者容易混淆“描述数据的元数据”和“记录数据流转的血缘”。
误区“OpenLineage要在Spark里跑回调、发网络请求,肯定会拖慢我的作业,不敢用。”
正解性能开销确实存在,但远没有想象中恐怖。客户端采用异步队列批量上报,事件生成在监听回调中完成,对主线程的阻塞微乎其微。实测常见的Spark SQL作业,开销通常在毫秒级。真正需要担心的是后端存储的容量规划,而不是采集本身。就好比后厨装了个自动记录仪,厨师手上功夫几乎不受影响。
为何常见:人们容易高估“额外记录”的成本,而低估“出了问题找不到根源”的损失。
误区“我们用的调度框架没有官方提取器,所以OpenLineage在我们这儿行不通。”
正解OpenLineage规范是开放的,任何引擎只要按照事件规范输出HTTP请求,就能接入血缘系统。官方提取器只是“现成的适配器”,没有的话你可以用OpenLineage的通用SDK自行实现,通常在几百行代码内。就像没有标准溯源标签时,后厨可以自己打印——只要格式符合协会规定就行。
为何常见:初学者把OpenLineage误解为一个“闭源软件”,而实际上它是一套开放规范,接入门槛比想象中低。
误区“把OpenLineage部署好,血缘图就自动有了,以后不用管了。”
正解血缘图是“活地图”,数据链路每天都在变——新作业上线、旧任务下线、表结构变更。如果没人维护血缘图的质量,断点和错误边会越积越多。生产实践需要定期校验血缘完整度、对账作业清单,就像后厨的溯源档案要定期抽查,否则记录与实情脱节,反而误导决策。
为何常见:工具能自动化生成血缘,但“保证血缘与真实链路一致”是持续运营问题,不能指望一次性部署解决。
面试题
Q1. 数据血缘的核心作用是什么?
- A.提升数据库存储容量
- B.记录数据全链路流转← 正确答案
- C.加速报表查询速度
- D.压缩数据文件体积
答案:B。数据血缘的价值在于记录数据从源头到成品的完整流转过程,让“数据从哪来、到哪去”有据可查。存储容量、查询速度、文件压缩都属于性能优化范畴,与血缘无关。类比“食材溯源”追踪的是一道菜的经历,而不是提升冰箱容量。
Q2. OpenLineage的本质是什么?
- A.一套数据存储引擎
- B.一套开源血缘标准← 正确答案
- C.一个数据可视化平台
- D.一种任务调度框架
答案:B。OpenLineage是一套开放的血缘事件规范,定义事件格式和收集方式,不提供存储引擎本身,也不负责调度或可视化。它像厨师协会制定的溯源标签模板——你做菜、记录、张贴即可,协会不替你买菜做饭。
Q3. 血缘图中的“节点”通常表示什么?
- A.数据表或作业← 正确答案
- B.服务器或机房
- C.线程或进程
- D.网络或端口
答案:A。血缘图的节点分为两类:数据集节点(表、文件)和作业节点(清洗、聚合任务)。服务器、线程、网络属于基础设施层概念,表达的是计算资源而非数据流转关系。类比后厨流程图上的节点是“食材”和“工序”,不是灶台和管道。
Q4. OpenLineage的三大核心实体包括哪些?
- A.运行实例← 正确答案
- B.作业实体← 正确答案
- C.数据集实体← 正确答案
- D.调度配置
答案:ABC。OpenLineage事件模型的三大核心实体是Run(某次运行)、Job(作业定义)和Dataset(数据集)。调度配置不属于核心实体,它只是作业运行的环境参数。好比溯源记录三要素是“这一锅(Run)、这道菜的做法(Job)、那份食材(Dataset)”。
Q5. OpenLineage客户端中提取器(Extractor)的主要职责是什么?
- A.监听引擎事件并生成血缘事件← 正确答案
- B.存储所有业务明细数据
- C.替代计算引擎执行作业
- D.绘制血缘可视化图表
答案:A。提取器嵌入到Spark、Flink等引擎中,监听作业生命周期回调,并构造标准的OpenLineage事件。它不存储数据、不执行计算、也不负责展示,只是“传感器+翻译器”。如同后厨的记录仪把厨师的动作翻译成统一格式的登记条目。
Q6. 在OpenLineage事件中,一次Run可能处于哪些状态?
- A.开始运行← 正确答案
- B.成功完成← 正确答案
- C.失败中止← 正确答案
- D.注册状态
答案:ABC。Run的状态对应事件类型:START(开始)、COMPLETE(成功完成)、FAIL(失败)。“注册状态”不是OpenLineage定义的有效事件类型。完整的Run生命周期是“开始→成功或失败”,每个Job的多次Run会形成一条时间线,类似同一道菜被反复制作多次,每次的结果都被记录。
Q7. 在MLOps中,模型血缘追踪的核心价值包括哪些?
- A.追踪训练数据来源← 正确答案
- B.精准复现实验结果← 正确答案
- C.支撑合规审计需求← 正确答案
- D.提高GPU利用效率
答案:ABC。模型血缘把特征表、训练数据、超参数和模型产物关联起来,实现可溯源、可复现、可审计。GPU利用率属于资源优化范畴,与数据血缘无关。如同实验菜要记录用料和火候,才能复刻成功口味并说明食材合规性。
Q8. 命名空间(Namespace)在血缘图中的核心作用是什么?
- A.标识实体来源避免重名冲突← 正确答案
- B.提升底层存储压缩效率
- C.加快跨集群数据传输速度
- D.加密敏感字段的访问权限
答案:A。多个团队可能创建同名表,命名空间提供了完整的限定名(如dwh://core.user_table与ml://exp.user_table),确保血缘图中的每个实体全局唯一。这与存储压缩、传输加速、权限加密无关,如同两家餐厅都叫“俏江南”,加上城市名才能区分。
Q9. 在生产环境落地OpenLineage时,最主要的工程挑战是什么?
- A.平衡采集覆盖率和性能开销← 正确答案
- B.优化前端可视化交互体验
- C.提升数据库表的索引效率
- D.压缩血缘事件存储空间
答案:A。采集覆盖率决定了血缘图的完整度,但全面采集会带来事件量和性能开销的增长。工程上需要设计异步上报、采样策略和分级存储。前端交互、索引效率、存储压缩都是次要工程问题,核心矛盾是“采集多全”与“代价多大”之间的权衡。
Q10. 同一Job的多个Run之间,血缘关系如何正确关联?
- A.通过runId顺序串联成链
- B.通过jobName聚合共享定义← 正确答案
- C.通过input数据集精确匹配
- D.通过时间戳全局排序
答案:B。Run是Job的一次执行实例,每次Run都有唯一的runId,但血缘图上它们是同一个Job节点下的多条时间线,通过jobName聚合到同一作业。runId每次不同,无法串联;input数据集可能变化,不能作为关联键;时间戳只能排序,不能表达血缘归属。如同“红烧肉”这道菜被王师傅做了三次,三次是三个Run,但都归属于“红烧肉”这个Job。