持续集成在数据工程中的实践
企业在构建应用系统支持业务流程的同时,也会构建数据平台:构建合规性报告,进行财务和运行分析,构建数据产品供外部使用等。数据平台在本质上也是软件系统,因此不仅仅包含编程(编写数据转换)任务,还包含了修改和维护等工程任务。与软件工程类似,数据工程也是“随着时间而不断集成的编程”。
在设计、开发、测试和维护等软件系统的生命活动中,软件工程师们不断探索、尝试和取舍,试图提炼出通用的工程实践,以创建高质量、可靠和可维护的软件为目标。在过去的十多年,软件工程师们推动了很多工程实践的验证和落地,但是在数据工程领域,很多日常任务依然承受着低效、不可靠、难以长期维护等挑战,以及大量人工干预最终导致认知负载过高等难题。一个尴尬的场景是,一个经验丰富的软件工程师看到数据工程中的落地实践会难以置信,对数据工程师的习以为常不得其解,但深入到一线开发时,又往往力不存心。
本文举例指出数据平台项目面临的工程难题,试图给出一些变革的方向和落地的化解方法。希望给广大一线开发工程师有一些灵感。
持续交付中的工程挑战 🔗
该数据平台项目将上游微服务、第三方服务等来源的批数据接入到数据平台,然后构建分层的数据模型(比如暂存层、模型层、集市层),最终生成财务对账报表,或者给应用提供数据产品。这是一个典型的数据平台项目,包含了很多的批处理作业,使用的平台和技术包括:Spark(计算引擎),S3(数据存储),AWS Glue(数据目录,用于注册数据库表和管理表的模式),AWS Glue Workflow(作业编排)等。为了简化背景,我们的故事仅局限于数据平台中的数据转换,即构建数据模型的部分。这部分的编程工作,主要是使用使用Spark SQL编写数据转换逻辑,然后使用Pyspark创建执行上下文并进行数据转换,最后将这些有依赖关系的数据转换放在一个大的有向无环图(DAG)中,每天执行。
在经历数个月的开发和内部测试后,产品(包括应用系统和数据平台)成功地发布给了用户。此后,整个产品进入了持续交付的阶段。这意味着业务需求会进入每个迭代的开发计划中,团队会增量式地构建新的数据模型或者完善原有的数据模型,因此不能随意地丢弃数据再重建模型;同时,原来的数据模型和报表也会有缺陷,需要修复漏洞或者改进;上游系统时常会出现数据迟到和缺失的问题,因此还有排查错误和修复数据的琐事(toil)。
在首次发布的喜悦之后的几个月之后,项目在演进,人员也发生了变动。发生过几次生产事故,代码的坏味道逐渐变得不可逆,花费在琐事上的精力越来越多。越来越多的工程挑战暴露了出来:
- 未完成的代码意外地上线了。比如正在开发环境验证,但还没有到生产就绪的代码,进入了生产环境。项目采纳的是分支开发并在上线前合并,或者在上线前使用Cherry Pick的方式挑选需要上线的提交。这是一项重要但很容易出错的人工步骤。
- 缺乏静态检查。代码似乎缺少统一的编写风格。
- 难以为继的单元测试。在项目框架中还保留着针对数据转换的测试框架(包括数据准备和对比),但最终,单元测试的实践被弃用了。工程师不再编写数据转换的单元测试。
- 大量的复制粘贴和相似的SQL片段。比如,某些模型(尤其是指标模型)的转换非常相似,只是根据不同的字段进行分组,却有大量的重复代码。
- 巨大无比的SQL转换脚本和难以理解的逻辑。长达数百行的SQL语句,让作者也很难在几周后很快理解其逻辑。而重构变得让人心生畏惧。
- 文档的缺失,让数据模型越来越难以理解和维护。除了业务和技术文档,在数据平台中,数据目录和数据血缘都是不可或缺的文档。而这些文档的维护是手动在电子表格、画图软件、文档系统中手动维护和同步,往往变得不可靠。举一个数据刷新的例子。当前的数据平台是按照单体构建,采纳的是分层架构,模型的构建执行是按照分层来组织的,而不是面向领域的组织。当上游的某个数据源出现问题导致数据平台需要重新构建数据时,由于缺少缺少可靠的数据血缘,简单粗暴的方法就是运行整个数据转换流水线(而不是受影响的模型)。当数据模型越多时,就会花费越来越多的时间;而这又可能影响日常的数据流水线或者其他下游系统。
而总体上来看,度量软件交付效能的指标,比如部署频率、变更前置时间、平均修复时间等,都很难有改进的空间。
软件工程师熟悉的工程实践 🔗
面对这些在持续交付中的工程挑战,在应用软件工程的领域已经有了耳熟能详的“解药”。比如:
- 基于主干的开发和特性开关
- 模块化和代码复用
- 静态检查
- 自动化测试
- 代码即文档
落地实现 🔗
基于主干的开发和特性开关 🔗
基于主干的分支策略是团队的共识,但迟迟未能付诸实践。原因特性开关的缺失。这里的特性开关至少包含两类:
- 在模型层面的开关。比如某些模型需要在Dev或者UAT环境执行,但不应该发布到生产环境。
- 在转换逻辑层面的开关。比如在某个模型的实现中(即SQL脚本中),部分的转换逻辑还在开发和验证中,可以发布到Dev环境,但不应该出现在UAT和Prod环境中。
由于每个模型对应到了工作流中的一个节点,所以模型层面的开关,其实是可以在工作流的编排中实现的。开关的更新虽然这需要基础设施代码的改动,但至少是可行的。但是在在转换逻辑层面的开关,却成了难题。
在项目的基础框架中,开发了一套简单的SQL模板引擎,可以替换模板中的变量或者常量占位符,渲染成最终要执行的SQL脚本,以便在不同环境中运行作业,或者外部化配置等。对于特性开关而言,模板引擎至少需要支持“条件渲染”,以在同一份代码中,同时开关的状态,决定要执行的转换逻辑。所以,一个只支持变量替换的模板引擎,在解决复杂问题时有点儿捉襟见肘。因此加入特性开关的基本思路是,给模板引擎添加“条件渲染”的能力。
其实,开发一个简单的支持变量替换的模板引擎,可能需要一些正则表达式的知识就够了。但如果需要更多高级特性,那么可能需要一些编译原理和编程技巧了。这对于大部分开发者而言,不管创建者还是后期的维护者,还是有些难度的。幸运的是,大部分语言都有比较成熟的模板库。而使用模板引擎渲染声明式的配置和语言,已经是成熟的解题方向了。比如,在应用开发领域,Helm是一个广泛使用的工具,它的模板引擎极大地简化Kubernetes Spec文件的编写。而在Python语言中,Jinja 是最流行的模板引擎之一。它提供了包括“条件渲染”在内的丰富特性。
模块化和代码复用 🔗
在工程师熟悉的大部分语言中,都有类似于模块、函数、类等组件,用于封装逻辑、复用代码等目的。而在执行Spark SQL这种声明式编程语言,却找不到类似的基础能力。但理论上,我们可以模块化地编写SQL片段,然后编译成运行期的长SQL片段。如此这样将模型的编写和执行解耦。而模块化地编写SQL脚本,又需要一种有模块化能力的模板引擎,它可能需要能够引入模板片段,封装模版片段并接受参数等。编写这样的模板引擎也非易事。幸运的是,Jinja2模板就自带了这些能力。通过使用类似于与函数的macro(宏),对SQL片段进行封装,从而实现模块化和代码复用。这样代码量就会大幅减少,随之而来的认知负载也大幅减少。
静态检查 🔗
与其他编程语言类似,其实在业界针对SQL已经有成熟的静态分析和检查工具,比如SQLFluff,可以配置命名风格,语法错误等规则(https://docs.sqlfluff.com/en/stable/rules.html)。对于使用模板引擎编写的SQL片段,需要相应的插件支持。比如SQLFluff支持 jinja 模板;但如果使用私有的模板引擎,那么需要编写相应插件,或者寻找其他工具。
自动化测试 🔗
在非生产环境的持续集成过程中,可以通过单元测试保障转换逻辑按预期执行;而在生产环境中,可以通过数据测试,在转换之前检查源数据,在转换之后检查最终的统计结果,看看它们是否符合预期。后者有成熟工具支持,比如Great Expectations;但前者似乎尚未有流行的实践。
虽然模型的单元测试也在测试逻辑,但管理测试数据工作却有些繁琐。这可能是单元测试的最大痛点,也是最可能阻止编写单元测试的原因。使用Excel来管理表格式的测试数据是符合直觉的,而Python有读取Excel的第三方库。但是Excel常常会自动地推断和转换数据类型(比如日期、纯数字的字符串),让工程师很困惑;而且Excel文件是二进制的,难以进行代码对比。另一个方式是使用CSV文件管理表格式的测试数据,但是CSV文件没有数据类型的信息,需要某种方式定义模式(Schema)并进行类型转换。
一个可能的技巧是,使用Spark SQL的WITH VALUES语句,让CSV中的每一行是VALUES中的每条记录(可以包含SQL函数),然后拼接成CTE或者插入到源数据库表中。比如,CSV中的内容为:
id,count,timestamp
"100001",100,from_utc_timestamp('2017-07-14 02:40:00.0', 'GMT+8')
"100002",10,from_utc_timestamp('2017-07-14 03:40:00.0', 'GMT+8')
最终被渲染为:
WITH fixture AS (
SELECT id, count, timestamp
FROM VALUES
("100001",100,from_utc_timestamp('2017-07-14 02:40:00.0', 'GMT+8')),
("100002",10,from_utc_timestamp('2017-07-14 03:40:00.0', 'GMT+8'))
)
SELECT * FROM fixture
另一方面,如果使用类似Jinja的模版引擎,将数据转换模块化,使用Macro进行分装。那么就可以针对一个个小模块进行测试。同时,利用Jinja模版的查找路径,也可以轻松实现Macro(即函数的)的Stub。
至此,单元测试中的大部分技术问题都可以被解决。
代码即文档 🔗
除了在SQL代码片段中添加注释,数据目录和数据血缘也是文档的重要部分。数据目录包含了数据表表的名称、字段、描述信息,甚至标签等;而数据血缘给出了模型之间的依赖关系。虽然它们常常出现在数据治理活动中,但在工程任务中的重要性也是不言而喻的。但是,如果将代码和文档分开,或者将文档当作编程之外的活动,那么维护文档很容易变成无足轻重甚至被遗忘的事情。就像应用软件常常会使用Swagger等工具生成API接口文档,那么在数据工程中,能否自动地生成这些文档信息呢?
实际上,这是可行的。当前流行的数据转换工具DBT就可以自动地提取模型代码中的模型和血缘信息,然后生成数据目录和血缘信息,甚至生成一个静态的门户站点。它利用了Jinja模板,让工程师按照特定语法引用依赖表,而不是直接使用源表,就可以自动且可靠地提取血缘关系;同时允许工程师在模型中添加标签、PII标识等元数据,又可以添加字段注释意外地元数据。如果将生成文档的步骤加入到CI/CD的流程中,那么就可以自动地维护最新的数据目录和血缘。更强大的功能是,DBT提供的内建的能力,可以选择构建某个模型及其所有下游的模型,或者选择构建某个模型极其所有上游的模型。这样在刷新数据时,就算是在单体数据平台中,也可以只修复受影响的模型。而DBT还能给模型添加标签(比如对应领域的标签),就可以根据标签(比如领域)来执行部分的数据转换。如此这样,当工程师在编写数据转换的过程中,顺手就编写了文档,何乐而不为?
不过,试图实现这种自动生成文档的机制,不是引入模板引擎那么简单。这需要更高级的编程技能。DBT给出了一种实现思路,也给出了开箱即用的工具。
总结 🔗
不难看出,持续交付的实践在数据工程中是可以落地实现的。在使用SQL做数据转换的日常任务中,采纳成熟且功能强大的模版引擎,能够利用更好的相关的生态圈,更容易地落地软件开发实践,提高效能的上线;通过改善开发者体验,自动化地构建元数据,为了更好地工程化而构建元数据(而不是为了数据治理才去管理元数据),可以在项目的持续演进和维护阶段极大地提高生产力。
对于建议的工具,Jinja和DBT可以作为首选。Jinja可以作为SQL模板的首选渲染引擎;而基于Jinja的DBT,除了让工程师利用Jinja的各种特性,还扩展了数据工程实践的边界,有更多的工程支持。因此,除非编程能力足够优秀且有足够的投入保障,那么应该拒绝自行“手搓”模板。如果有合适且可靠的数据库适配器,那么可以尝试采纳DBT及周边工具,那些开箱即用的特性会让人眼前一亮。