背景与摘要
eBay Data Services & Solutions团队管理eBay数以万计的生产表与其上近百万的各类业务字段,为此,DSS团队开发并维护了一套基于Hadoop和Spark的庞大数仓体系。在这套体系中,开发者时常会遇到诸如数据溯源、数据影响分析类型的问题,考虑通常ETL过程中牵扯SQL繁杂冗长,纯人工方式解决这几类问题费时费力且难以推而广之,无法一触即用地对数据进行追溯与来源分析。如何自动化地进行高质量的数据血缘分析成为了DSS团队亟需解决的问题,也是大数据从业者普遍面临的挑战。
目前业界有一些诸如druid与atlas的框架或一些基于hive执行时的扩展方案来处理这一问题。但这些框架或方案各自都有一定局限性,比如依赖spark或hive的运行环境,或是需要侵入其代码等。
它们无法满足目前从业者对于数据血缘分析的需求——即不希望直接插手执行器的行为。在执行器中添加挂钩进行血缘分析操作存在着影响执行器自身性能的风险;且与运行时的环境高度耦合将会让血缘提取显著受限于执行本身的生命周期,无法灵活的进行调整。故而,我们更倾向于提供一个轻量的、偏静态的方案。
另一方面,尽管血缘分析的范畴中也存在着通过Spark RDD或其他编程语言等操作数据存储这样的场景,但ebay数仓中的ETL标准流程皆是spark sql job,基于SQL的血缘提取具有足够的覆盖面。在这样两大前提下,我们进行了一系列对血缘分析方案的探究与选择。
本文将重点阐述eBay DSS团队是如何自动化地进行字段级别的数据血缘的分析。我们的方案大致可以划分为以下几个步骤:sql收集 - 元数据注册 - sql脚本的元数据绑定 - 抽象语法树的解析 - 血缘数据清洗。
01 SQL收集
对于SQL脚本的收集工作,我们需保证以下几个基本需求:
随后我们考察了DSS部门的传统方案,其中收集活跃sql的工作一般基于git仓库进行,也就是从git仓库中抓取生产环境的ETL过程中所用到的sql脚本,并重新建立sql的执行顺序,从而据此建立临时表等暂存在session中的上下文信息。
但在实践过程中,我们发现git仓库中的脚本中含有大量非sql语义的变量,这些变量与一些外部框架耦合,因此如果需要替换解析这些变量,则需要重现框架的行为;此外,一部分变量依托于当时的执行环境变量,这使得重现这一工作所需要解决的环节过多,可扩展性较差;同时,考虑到git仓库中存放的脚本难以考察其活跃情况(是否仍在被使用、或是已经废弃不用),且覆盖率并不足以包含整个链路上的所有脚本(位于链路末端的用户脚本与建立视图等非频繁性的操作可能未被收纳)。
几经考虑,我们决定不使用git仓库中的静态脚本,转而根据spark的执行历史日志抓取活跃sql。这个方案能够保证提取出来的sql具有足够的活跃度;另一方面,抓取的日志中包含了较为详细的执行时间。考虑重建元数据结构的需求(主要围绕临时表),我们在此仅根据执行时间的先后即能够满足。因而在通过一些指定规则对历史记录中的sql进行过滤后,我们以每日为周期、一月为范围进行了sql的收集工作。
02 语法树生成、元数据注册与resolve SQL
此处,我们已经得到了带有执行顺序的SQL集合,需要依次产生语法树并对语法树做元数据绑定,最终产出解析后的语法树。显然我们需要考虑如何生成语法树、如何处理元数据绑定这两个问题的方案。
下文的resolve广义包含将未解析的逻辑执行计划结合元数据进行处理的一系列操作,在血缘分析的背景下,则是主要解决CTAS(create table as等),select * 相关的语法,以及select的字段不指明表前缀这三种场景。
对于如何生成语法树,我们比较直接的选用了spark sql库,一方面我们的场景以spark sql为主,使用其原生库可以避免大量的方言语法的适配,一方面其产出的逻辑执行计划的语法树相对深度适中,便于遍历解析。
而在resolve这一块的选择就要复杂的多,业界较为通用的做法是挂钩的方式,也就是基于hive或是spark的执行过程进行扩展,在spark自身的执行引擎中添加一些过程,在过程中直接获得spark catalog提供的结构化信息与物理执行计划,并当场进行解析,从而提取具体的血缘信息,这样做就无需考虑resolve和元数据相关的工作,spark自身在执行过程中会完成这部分工作。但是采用这一类方案,则意味着和spark运行时环境需要较强的耦合,甚至直接注入进spark的执行器中。这样一来就会使得我们的方案变得过于沉重、无法独立运行,这与我们的初衷有些违背。因此我们最终舍弃纯hook型的方案,转而自行完成元数据注册以及后续的resolve工作。
随后,一个较为折中的第一眼方案是由我们提供sql文本,提交给spark执行引擎,并依靠其analyze语法,直接将收集到的sql进行解析并直接获得解析后的逻辑执行计划,这样做相比于hook方案,能够将分析血缘的工作与sql的实际执行工作两部分解开耦合,但其性能上有一定限制,且仍然依赖外部提供的服务(本质上仍然依赖spark的运行时环境)。考虑比较各方后,我们最终决定将第一步也就是sql到语法树的转化独立出来作为一个可替换内核的模块,一方面也考虑到后期需要扩展支持到其他的sql方言(oracle,mysql等等),这一环节的输入输出较为易于确定,也就是输入为一系列sql文本,输出为带有方言语法的语法树,在数仓的场景下即为未解析的执行计划。随后,我们引入sparksql的依赖(静态依赖,并不同时依赖catalog的运行时API)并做了一些封装,来完成语法树生成的工作。
考虑我们目前对执行层并不关心(广播、shift等执行期间由spark决策的纯数据行为,与sql本身的语义无关),且目前 HMS (Hive Metastore) 的存储可以简单的包装后作为一个相当轻量的提供元数据的提供方,易于读取和仿制,故而折中后决定自主实现部分resolve的能力,在我们的解析体系中,将这部分过程称之为元数据的结构注册(schema register,SR)。
这一部分工作主要分为两块,一块是临时表的结构注册,也就是将所有sql脚本中创建的临时表的字段结构以及这些字段的来源进行标记和连接。在DSS的ETL过程中,临时表在整个sql流程中是承载逻辑处理的主体。一个领域通常会将大量的计算与业务逻辑拆为一条条ETL管道,这些ETL管道在处理过程中会将大部分的计算中间结果放入临时表里,并在一系列sql的末尾将这些临时表的结果汇总入working表,这类流程通常是高度依赖sql的执行顺序的,前文已经提及,我们采集的sql根据spark log来确保执行顺序的准确性,因此无需担心sql顺序错乱导致丢失字段来源的情形出现。临时表的元数据结构需要在解析时动态注册进入我们的元数据存储(metastore)中,以供后续其他sql查询,其生命周期是跨sql的;另一块是生命周期在一段sql内产生的那些具有字段信息的子查询 (比如 select ... from (select ... from ...) as tmp,具体如何处理下文给出),我们同样需要构建出这些子查询中临时生成的字段并维护他们的生命周期,否则依靠子查询间接查询物理表的字段血缘就会丢失,但其与临时表不同之处是子查询的生命周期无需跨sql维护,用完即丢即可。以下是简单的过程示意:
至此,我们的方案基本已经确定:
采集spark执行日志的sql文本 -> 通过spark sql库来生成未解析执行计划 -> 抓取HMS提供字段级别元数据 -> 重现spark session内执行顺序进行sql解析并对临时表做结构注册与绑定->提取语法树内字段血缘信息。具体示意图如下:
03 提取语法树中的血缘信息
在上一环节中,我们已经得到了带有元数据信息的语法树,后续即需要对语法树进行遍历工作,产出合适的结构来方便进一步的字段血缘的提取。此处我们需要解决的问题是如何展平语法树的同时不丢失树中我们需要的信息,同时用什么样的结构和抽象来描述这些信息。
对于语法树的遍历和信息提取的工作主要集中于以下几个场景,其一是复杂的、嵌套的表达式,在ETL工作中,由于sql没有那么灵活,有时候包含多个条件的条件语句需要用相当长的case when来完成,这些case when中又会包含一些带有多元计算或是函数处理的子表达式,为了血缘的准确性,我们需要合理的对所有表达式中提及的字段做筛选和提取,并根据其语义区分字段之间的血缘类型;另一个是子查询的处理,上千行的sql并不罕见,而这些sql中时常会生成多个子查询且频繁引用子查询中的字段,上一个步骤中我们提取了子查询的字段结构,随后我们即需要将这些字段结构串联起来,构建血缘信息。
03-1 表达式
起初比较直觉的想法就是先遍历整个表达式的子树(如何遍历与遍历时的数据结构我们在下一部分详解),提取我们感兴趣的部分,也就是字段相关的信息。
表达式:
表达式展开的子树(省略函数):
上下文无关遍历得到的结果:
结合例子与图示,我们可以看到这个表达式最终只产生了一个字段(result_column),但其计算过程涉及的字段很多(condition_column_1, condition_column_2, condition_column_3, value_column_1, value_column_2),其中,一些字段仅仅用在判断条件的子句中(condition_column_*),一些字段仅在取数时出现(value_column_2),而一些字段既作为判断条件,也作为取数对象字段(value_column_1),显然他们对于最终产生字段的贡献是不同的,那么提取的血缘中也需要携带某些信息以此对他们进行一定的区分。由于一开始提到的提取方式是上下文无关的,我们发现只是通过这种初级的提取拿出来的数据信息量不足,很普遍的一个案例是图示表达式中的case when,其中作为条件的部分和作为真正数据来源的部分在叶节点是无法区分的,因此,我们需要识别一些模式(pattern),从中提取出字段之间的依赖关系的类型,并将该模式作为上下文通过某种方式进行传递,从而对字段出现的部分做具体的区分,才能在表达式级别得到更清晰的血缘关系。
因此,我们需要抽象一个结构,这个结构应当方便的表达“字段依赖”这一关系,也需要便于扩展,同时对去重(某些字段既作为判断条件,又作为数据来源,且在同一表达式中重复出现多次)相对友好。
我们目前使用的抽象结构命名为select pattern(下文简称为SP),该结构只会记录其当前的依赖字段的列表、依赖方式与当前所在上下文的指针;select pattern并不仅仅考察select相关部分,而是囊括了所有出现的字段相关操作和信息(包括group by,where条件语句等),它会跟随语法树的遍历而生成,但并不会在遍历时动态的递归解析这部分依赖,而是在完成遍历、生成血缘树之后统一进行递归的解析。选择事后统一方案的主要因素在于,如果遍历过程中进行处理依赖,意味着需要在递归过程中携带所有上下文,这部分的范围过宽,且会受到遍历顺序相当大的影响,无可避免的会产生一些悬空、无法当场解析的依赖节点,不如统一只做依赖树的生成,遍历结束后再进行血缘相关的分析。我们考虑以下例子:
其中table_1、table_2、table_3均为物理表或视图,各自包含如下字段:
该sql转化为语法树的结构大致如此(为便于理解,已略做更改并删减中间层节点):
我们需要提取的即是红色虚线标出的关系,可以看到,我们在遍历到达外层的column_3节点(即图中顶层的column_3)时,内部子查询的结构尚未建立,因此如果选择边遍历边解析的方式,此处column_3的来源即会产生悬空,或是需要通过将整个column_3作为遍历过程中的一部分信息持续携带至遍历直到子查询结构注册完成(途中右侧的SELECT部分),如果sql复杂,遍历过程中携带的信息结构即会爆发式增长,相应也会使得处理逻辑极度复杂,因此初步遍历时,我们仅做对表达式的解析与表达式中所用字段的记录,提取成后续需要使用的结构与该结构产生自哪个上下文(context,此处广义包含物理表与视图、临时表、子查询等),并不进一步解析或连结,构成select pattern如下图:
值得注意的是,由于子查询的存在,左侧的SP并无法直接关联到右侧子查询中的SP,这需要我们通过某种方式遍历sql中产生的所有子查询。这个过程的基本逻辑是从某个SP(作为起始点的SP)出发,根据其依赖的字段列表查询其上下文内包含的所有SP,如果没有,则深入更下层的上下文进一步查询相匹配的SP,这个过程如果直接在原语法树上进行,考虑到子查询的频繁存在,就需要携带大量的节点状态信息(包含其父节点和子节点在内的一组模式)来进行遍历,但是我们并不追求语法树中的所有详尽的信息,只关心血缘相关的信息部分。因此,我们需要设计一种抽象来更简洁、贴切地描述概括血缘相关信息,并基于该抽象实现这一遍历过程。
03-2 子查询
我们仍然考虑上图案例,如果在该例子中的子查询中额外提取了一些属于table_3的字段并做了计算,但由于一些原因这些作为计算结构的字段并未在后续的外层提取部分中被使用,那么该字段将只是存在于子查询中,这类字段不应该被记入在最终的字段产出里。我们的抽象结构需要一个在描述子查询的相关信息的同时,合理嫁接上下文的血缘关系,在最终的血缘产出结果中消去子查询的相关结构。
这里的抽象工作是比较困难的,因为原始的语法树的节点数量和复杂度都很高,考虑原始语法树中大部分结构本质是在表达语义,我们所在意的仅仅是上下文(物理表与视图、临时表、子查询等包含字段元数据的部分)之间的层级与依赖,以及字段归属于哪一个上下文,而非语法语义层面的细节,因此最终结构中我们在外层只暴露上下文本身,并根据上下文的SQL依赖关系构建了上下文的树。结合此前对SP的提取,我们最终能够得到两棵树来继承我们所需要的语义:
其一是上文select pattern(SP)之间的依赖树,其二是上下文(context)的依赖树。
select pattern与表之间有互相指向的指针,这会帮助我们用以获取select pattern出自哪张表这一信息来完善血缘,完成上一节提到的连结SP与其依赖SP的工作,这是血缘解析工作中间环节的核心,我们称之为血缘双树模型,上文案例的双树图示如下:
我们以SP3为例,其依赖字段列表中有column_3(深绿色框体所代表的依赖字段),查询到其所在上下文query,query中并不包含该字段,随后向下深入一层,对subquery这一上下文进行查询,其中的SP4即包含column_3作为字段产出,于是我们能够将SP3的依赖column_3与SP4相连:
我们对所有SP重复该操作,最终即可得到完整的双树。
左侧的select pattern在实际场景中的呈现更偏向于图(每个顶层的select pattern都有一棵自己的依赖树,但依赖的节点可能是同一上下文内部的同层级select pattern),结合前文的表达式解析中包含的多对一情况,实际的场景下产生的双树结构更类似于下图:
图示较为复杂,缘由实际场景下的ETL sql本身复杂度高,结构的复杂在所难免,故而我们的方案致力于使抽象本身尽可能的简单、一目了然,从而尽量减少对复杂结构的解析阻碍。
04 遍历
现在,我们得到了一个双树的结构,完成遍历后,就可以得到最终的字段血缘结果了。在遍历过程中,我们还有一些情况需要考虑与处理。
上文提到遍历语法树(非双树,指原始语法树,也即逻辑执行计划),我们目前使用的方式是前序遍历(自根节点起),也有许多方案是使用中序遍历的(左节点起),我们采用这种方案的一个主要原因是对创建上下文比较友好,自根节点开始遍历能够较为迅速的构建出上下文树,但相对的,对于一些特定的语法结构上需要额外的工作,考虑以下情况
我们会在遍历的起始遇到*这一语法,我们可以在遍历过程中先将之归于select pattern中,但是在最终遍历解析双树时,需要将*消去。此处,我们需要额外通过select pattern到上下文的指针,将该上下文的所有字段插入select pattern树中来替代*的select pattern。这一工作本身也是递归的,因为显然sql中可能存在不止一个*的使用,考虑在最终生成字段血缘的过程中动态消去*会使得实现变得杂乱,因为我们将之抽象为一个独立环节,位于在最终遍历双树前。后续如果有其他需要消去或是预处理的工作,也可以放在这一个环节中。
消去前:
消去后:
在完成消*之后,后续的工作就只剩下简单的遍历select pattern并抓取其上下文信息,并根据携带表达式信息决定血缘的边关系了。如此一来,我们就得到了一个完善的字段血缘结果。
完整方案如下:
采集spark执行日志的sql文本 -> 通过spark sql库来生成语法树(未解析的逻辑执行计划) -> 抓取HMS提供字段级别元数据 -> 重现spark session内执行顺序进行sql解析并对临时表做结构注册与绑定-> 遍历语法树,提取上下文(子查询)和表达式信息构建双树结构 -> 预处理双树,消去* -> 遍历双树,提取信息生成字段血缘图。
05 字段血缘图结构
最终生成的结构较为简单,是传统的点边关系图,点即字段,也相应包含字段所在的表信息,边关系描述字段之间的关系,用以区分是select或是condition(where,case when中的条件或是having等)。
该图包含所有的临时表与working表字段(数仓中用以中转数据的一类表),对用户而言可能过于繁杂,因此我们基于这张原始血缘图进行了一些定制化的工作,根据用户需求消去临时表与working表,主要描述生产表之间的字段血缘联系。
06 总结与展望
随着数据仓库中表、字段的数量的迅速增长,字段数据的来源、数据之间的计算逻辑相应越来越复杂。数据的血缘对于治理数据、污染的处理和影响分析都有着举足轻重的作用,提取准确可靠的数据血缘是作为基石的一步。
本文简要回顾了eBay DSS数仓部门在开发字段血缘的过程中,所面临的业务场景和需求、以及解决方案的利弊权衡。对于一些具体的案例进行了评估与解析,结合业界已有的一些思路进行方案上的最终取舍和选择,并基于此提出了一套轻量、高效、可扩展的解决方案。在设计层面提出了具有创造性的”双树模型“,有效将所需内容进行了合适的抽象,充分精简了字段血缘提取工作的主要流程和环节,保证了高质量的血缘输出。
后续我们还需要在此基础上,对“数据影响”这一核心内容进行扩展,从“数据怎么来”出发,解决“数据为什么是这样”的业务问题,从而更好的利用字段血缘携带的信息,对业务产生更深远而重要的价值。