数据流水线口袋参考-全-

数据流水线口袋参考(全)

原文:zh.annas-archive.org/md5/14fd5f4331592e0087f86ed4501e7cc6

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

数据管道是数据分析和机器学习成功的基础。从多个不同来源移动数据并处理以提供上下文,这是拥有数据与从中获取价值之间的区别。

我在数据分析领域担任数据分析师、数据工程师和领导者已有 10 多年。在此期间,我见证了该领域的迅速变化和增长。云基础设施的出现,特别是云数据仓库,为重新思考数据管道的设计和实施创造了机会。

本书描述了我认为是在现代构建数据管道的基础和最佳实践。我基于自己的经验以及我认识和关注的行业领袖的意见和观察。

我的目标是本书既作为蓝图,又作为参考书。虽然您的需求特定于您的组织及您设定解决的问题,但我已多次通过这些基础的变体取得成功。希望您在构建和维护支撑数据组织的数据管道的旅程中找到它是一个有价值的资源。

本书适合的读者

本书的主要受众是现有和有抱负的数据工程师以及希望了解数据管道是什么以及如何实施的分析团队成员。他们的职称包括数据工程师、技术负责人、数据仓库工程师、分析工程师、商业智能工程师以及分析领导者/副总裁级别。

我假设您对数据仓库概念有基本的了解。要实施讨论的示例,您应该熟悉 SQL 数据库、REST API 和 JSON。您应该精通一种脚本语言,如 Python。基本了解 Linux 命令行和至少一种云计算平台也是理想的。

所有代码示例都是用 Python 和 SQL 编写,并使用许多开源库。我使用 Amazon Web Services (AWS)演示本书中描述的技术,许多代码示例中使用了 AWS 服务。在可能的情况下,我还注意到其他主要云提供商(如 Microsoft Azure 和 Google Cloud Platform (GCP))上的类似服务。所有代码示例都可以根据您选择的云提供商进行修改,也可以用于本地使用。

本书中使用的约定

本书使用以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序列表,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应按字面意思输入的命令或其他文本。

常量宽度斜体

显示应由用户提供的值或由上下文确定的值替换的文本。

使用示例代码

补充材料(示例代码、练习等)可在https://oreil.ly/datapipelinescode下载。

如果您有技术问题或使用代码示例中的问题,请发送电子邮件至bookquestions@oreilly.com

本书的目的是帮助您完成工作。一般情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分内容,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序无需许可。销售或分发 O'Reilly 图书的示例代码则需要许可。引用本书回答问题并引用示例代码无需许可。将本书的大量示例代码合并到您产品的文档中则需要许可。

我们感谢您的支持,但通常不需要您署名。署名通常包括标题、作者、出版商和 ISBN。例如:“数据管道口袋参考,作者 James Densmore(O’Reilly)。版权所有 2021 James Densmore,978-1-492-08783-0。”

如果您认为您使用的代码示例超出了公平使用范围或上述许可,请随时与我们联系:permissions@oreilly.com

O’Reilly 在线学习

注意

40 多年来,O'Reilly Media提供技术和商业培训、知识和见解,帮助企业取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和 200 多家其他出版商的广泛的文本和视频内容。更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(在美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们有这本书的网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/data-pipelines-pocket-ref查看此页面。

通过电子邮件bookquestions@oreilly.com 发表评论或提出有关本书的技术问题。

关于我们的图书和课程的新闻和信息,请访问http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

观看我们的 YouTube 频道:http://www.youtube.com/oreillymedia

致谢

感谢所有在 O’Reilly 帮助这本书变为可能的人,特别是 Jessica Haberman 和 Corbin Collins。三位出色的技术审阅者 Joy Payton、Gordon Wong 和 Scott Haines 提供了宝贵的反馈,对整本书进行了关键改进。最后,感谢我的妻子阿曼达在书提出时的鼓励,以及我的狗伊齐在无数小时写作过程中一直陪伴在我身边。

第一章:数据管道简介

在每个华丽的仪表板、机器学习模型和改变业务的深刻洞见背后都是数据。不仅仅是原始数据,而是从多个来源收集的数据,必须经过清洗、处理和组合才能提供价值。著名的短语“数据是新的石油”已被证明是正确的。就像石油一样,数据的价值在于它在被精炼和传递到消费者之后的潜力。也像石油一样,需要高效的管道通过其价值链的每个阶段传递数据。

这本袖珍参考书讨论了这些数据管道是什么,并展示了它们如何融入现代数据生态系统。它涵盖了在实施管道时的常见考虑因素和关键决策点,如批处理与流处理数据摄入、构建与购买工具等。尽管它不专属于单一语言或平台,但它讨论了数据专业人士在讨论适用于自制解决方案、开源框架和商业产品的基础概念时做出的最常见决策。

数据管道是什么?

数据管道是一组过程,将数据从各种来源移动和转换到一个可以派生新价值的目的地。它们是分析、报告和机器学习能力的基础。

数据管道的复杂性取决于源数据的大小、状态和结构,以及分析项目的需求。在其最简单的形式中,管道可能仅从一个源(如 REST API)提取数据,并加载到目的地(如数据仓库中的 SQL 表)中。然而,在实践中,管道通常包括多个步骤,包括数据提取、数据预处理、数据验证,有时在将数据传递到最终目的地之前进行机器学习模型的训练或运行。管道通常包含来自多个系统和编程语言的任务。此外,数据团队通常负责并维护多个数据管道,这些管道共享依赖关系,并且必须进行协调。图 1-1 展示了一个简单的管道。

dppr 0101

图 1-1。一个简单的管道,将服务器日志数据加载到一个 S3 Bucket 中,进行基本处理和结构化,并将结果加载到 Amazon Redshift 数据库中。

谁构建数据管道?

随着云计算和软件即服务(SaaS)的普及,组织需要理解的数据源数量激增。与此同时,供应数据以满足机器学习模型、数据科学研究和时间敏感洞见的需求达到了空前高度。为了跟上这一需求,数据工程已经成为分析团队中的关键角色。数据工程师专注于构建和维护支撑分析生态系统的数据管道。

数据工程师的目标不仅仅是将数据加载到数据仓库中。数据工程师与数据科学家和分析师密切合作,理解数据将如何使用,并帮助将他们的需求转化为可扩展的生产状态。

数据工程师以确保他们交付的数据的有效性和及时性为荣。这意味着测试、警报以及为出现问题时创建备用计划。是的,问题最终会出现!

数据工程师的具体技能在某种程度上取决于其组织使用的技术堆栈。然而,所有优秀的数据工程师都具备一些共同的技能。

SQL 和数据仓库基础

数据工程师需要知道如何查询数据库,而 SQL 是执行此操作的通用语言。经验丰富的数据工程师懂得如何编写高性能的 SQL,并理解数据仓库和数据建模的基础知识。即使数据团队包括数据仓库专家,具备仓储基础的数据工程师也是更好的合作伙伴,能填补出现的更复杂的技术空白。

Python 和/或 Java

数据工程师精通的编程语言将取决于他们团队的技术堆栈,但无论如何,即使他们的工具库中有一些好的“无代码”工具,数据工程师也不会只凭这些工具完成工作。Python 和 Java 目前在数据工程领域占主导地位,但新兴的语言如 Go 正在崛起。

分布式计算

处理涉及大数据量和希望快速处理数据的问题,导致数据工程师与分布式计算平台合作。分布式计算结合了多个系统的力量,以高效存储、处理和分析大量数据。

在分析中的分布式计算的一个流行例子是 Hadoop 生态系统,其中包括通过 Hadoop 分布式文件系统(HDFS)进行分布式文件存储,通过 MapReduce 进行处理,通过 Pig 进行数据分析等。Apache Spark 是另一个流行的分布式处理框架,它在流行度上迅速超越了 Hadoop。

尽管并非所有的数据流水线都需要使用分布式计算,但数据工程师需要知道如何以及何时利用这样的框架。

基本系统管理

数据工程师预期在 Linux 命令行上熟练,并能执行诸如分析应用程序日志、调度 cron 作业以及解决防火墙和其他安全设置问题的任务。即使在完全依赖 AWS、Azure 或 Google Cloud 等云服务提供商的情况下,他们也会利用这些技能使云服务协同工作并部署数据流水线。

一个目标导向的心态

一个优秀的数据工程师不仅拥有技术技能。他们可能不会经常与利益相关者进行接口,但团队中的分析师和数据科学家肯定会。如果数据工程师了解他们首先构建流水线的原因,他们将做出更好的架构决策。

为什么要构建数据流水线?

就像一艘过往的船只只能看到冰山的尖端一样,大多数组织只看到分析工作流程的最终产品。高管们看到仪表板和精美的图表。营销部门在社交媒体上分享打包整齐的洞察力。客服根据预测需求模型的输出优化呼叫中心的人员配备。

多数不熟悉分析工作的人往往未能意识到,在外显数据背后存在着复杂的未曾见过的机制。对于数据分析师生成的每个仪表板和洞察力,以及数据科学家开发的每个预测模型,都有数据流水线在幕后运作。一个仪表板甚至一个单一指标往往可以来源于多个源系统中的数据。此外,数据流水线不仅仅是从源中提取数据并加载到简单的数据库表或平面文件供分析师使用。原始数据在此过程中被加工,进行清洗、结构化、标准化、合并、聚合,有时还会进行匿名化或其他安全处理。换句话说,在水面下面有更多事情在进行中。

如何构建流水线?

随着数据工程师一同涌现的,是近年来出现的大量用于构建和支持数据流水线的工具。有些是开源的,有些是商业的,还有些是自家制作的。有些流水线是用 Python 编写的,有些是用 Java,还有些用其他语言,甚至有些根本不需要编程。

本便携参考手册中,我探讨了一些用于构建流水线的最受欢迎的产品和框架,以及讨论了如何根据组织的需求和约束条件来确定使用哪种。

尽管我并未详尽涵盖所有这类产品,但我确实提供了一些例子和样例代码。本书中的所有代码均使用 Python 和 SQL 编写。这些是构建数据流水线最常见、也是我认为最易接触的语言。

此外,流水线不仅仅是建立起来就完事了——它们还需要监控、维护和扩展。数据工程师的任务不仅仅是一次性交付数据,而是构建可靠、安全且及时交付和处理数据的流水线和支持基础设施。这并非易事,但如果做得好,组织数据的价值将会真正被释放出来。

第二章:一个现代化的数据基础设施

在决定构建管道的产品和设计之前,了解构成现代数据堆栈的内容是很重要的。就像技术领域的大多数事物一样,设计您的分析生态系统或选择产品和供应商并没有单一正确的方法。但是,已经形成了一些行业标准和概念,这些标准为实施管道的最佳实践奠定了基础。

让我们来看看这种基础设施的关键组件,如图 2-1 所示。未来的章节将探讨每个组件如何影响数据管道的设计和实施。

数据源的多样性

大多数组织在进行分析工作时,有数十甚至数百个数据源。这些数据源在本节覆盖的多个维度上有所不同。

dppr 0201

图 2-1 一个现代化数据基础设施的关键组件。

源系统的所有权

对于分析团队而言,从组织构建并拥有的源系统以及从第三方工具和供应商那里摄入数据是很典型的。例如,电子商务公司可能会在其网站后台使用 PostgreSQL(也称为 Postgres)数据库存储购物车中的数据。他们还可能使用第三方网络分析工具,如 Google Analytics,来跟踪其网站的使用情况。这两种数据源的结合(如图 2-2 所示)是了解客户在购买前行为的完整理解所必需的。因此,从这两个源的数据摄入开始的数据管道,最终以分析此类行为告终。

dppr 0202

图 2-2 一个简单的管道示例,将多个来源的数据加载到 S3 存储桶,然后加载到 Redshift 数据库。
注意

术语 数据摄入 指的是从一个源中提取数据并加载到另一个源的过程。

理解源系统的所有权对于多个原因非常重要。首先,对于第三方数据源,您可能会受到限制,不能访问所有数据或以您希望的方式访问数据。大多数供应商提供 REST API,但很少有供应商会直接以 SQL 数据库的形式向您提供数据访问权。甚至更少的供应商会允许您对可以访问的数据进行多少程度上的自定义或以何种粒度访问数据。

内部构建的系统为分析团队提供了更多定制可用数据和访问方法的机会。然而,它们也带来了其他挑战。这些系统是否考虑了数据摄入?通常答案是否定的,这会导致摄入将意外负载放在系统上,或者无法增量加载数据。如果你很幸运,拥有源系统的工程团队可能会有时间和愿意与你合作,但在资源限制的现实中,你可能会发现这与与外部供应商合作并没有太大不同。

数据摄入接口和数据结构

无论数据的所有者是谁,建立新的数据摄入时,数据工程师首先会检查如何获取数据以及以何种形式获取。首先,数据的接口是什么?一些最常见的包括以下几种:

  • 应用程序背后的数据库,如 Postgres 或 MySQL 数据库

  • 在系统顶部的一个抽象层,比如 REST API

  • 诸如 Apache Kafka 的流处理平台

  • 包含日志、逗号分隔值(CSV)文件和其他平面文件的共享网络文件系统或云存储桶

  • 数据仓库或数据湖

  • HDFS 或 HBase 数据库中的数据

除了接口外,数据的结构会有所不同。以下是一些常见的例子:

  • 来自 REST API 的 JSON

  • 来自 MySQL 数据库的良好结构化数据

  • MySQL 数据库表的列内 JSON

  • 半结构化日志数据

  • CSV、固定宽度格式(FWF)和其他平面文件格式

  • 平面文件中的 JSON

  • 从 Kafka 的流输出

每个接口和数据结构都有其自己的挑战和机遇。良好结构化的数据通常最容易处理,但通常是为了应用程序或网站的利益而结构化的。除了数据的摄入外,管道中进一步的步骤可能需要进行数据清理和转换,以便更适合分析项目的结构。

类似 JSON 的半结构化数据越来越普遍,并且具有属性-值对的结构和对象的嵌套的优势。然而,与关系数据库不同的是,同一数据集中的每个对象并不保证具有相同的结构。正如本书后文所述,如何处理数据管道中的缺失或不完整数据是依赖于上下文的,并且随着数据结构的刚性减少而变得越来越必要。

对于某些分析项目来说,非结构化数据很常见。例如,自然语言处理(NLP)模型需要大量的自由文本数据来训练和验证。计算机视觉(CV)项目需要图像和视频内容。即使是从网页上抓取数据这样较不艰难的项目,也需要来自网页的自由文本数据,以及网页的半结构化 HTML 标记。

数据量

尽管数据工程师和招聘经理都喜欢吹嘘拥有 PB 级数据集,但事实是大多数组织既重视小数据集也重视大数据集。此外,常见的是同时接收和建模小和大数据集。尽管在管道的每个步骤中的设计决策必须考虑数据量,但高容量并不意味着高价值。

尽管如此,大多数组织至少拥有一个对分析需求和高容量至关重要的数据集。什么是高容量?没有简单的定义,但就管道而言,最好是以一个光谱而非二元定义来思考高-低-容量数据集。

注意

正如你将在本书中看到的那样,在简化数据摄取和处理时存在过度简化的危险,结果是运行时间长且效率低下,当数据量或任务复杂性较低时,过度工程化管道任务也同样存在风险。

数据的清洁度和有效性

就像数据源具有很大的多样性一样,数据源的质量也存在很大的差异。正如老话所说:“垃圾进,垃圾出。”理解数据源的局限性和缺陷,并在管道的适当部分加以解决是非常重要的。

“混乱数据”的常见特征有许多,包括但不限于以下内容:

  • 重复或模棱两可的记录

  • 孤立记录

  • 不完整或缺失的记录

  • 文本编码错误

  • 不一致的格式(例如,电话号码有或没有连字符)

  • 错误标记或未标记的数据

当然,在源系统的上下文中,还有许多其他数据有效性问题。

保证数据清洁和有效性并非有灵丹妙药,但在现代数据生态系统中,我们会看到本书中的关键特征和方法:

假设最坏的情况,期待最好的结果

完美的数据集只存在于学术文献中。假设你的输入数据集将包含大量的有效性和一致性问题,但构建能够识别和清洗数据的管道以获取干净输出是非常重要的。

在最适合的系统中清洁和验证数据

有时最好等到管道的后期再清理数据。例如,现代管道倾向于采用提取-加载-转换(ELT)而不是提取-转换-加载(ETL)的方法进行数据仓库操作(详见第三章)。将数据以相对原始的形式加载到数据湖中,并在管道的后期担心结构化和清洗有时是最优选择。换句话说,用合适的工具做合适的工作,而不是匆忙进行清洗和验证过程。

经常验证

即使在流水线的早期阶段不清理数据,也不要等到流水线的最后阶段才验证数据。确定问题发生的位置会更加困难。反过来,也不要在流水线早期验证一次然后假设后续步骤都会顺利进行。第八章深入探讨了验证的问题。

源系统的延迟和带宽

需要频繁地从源系统中提取大量数据是现代数据堆栈中的常见用例。然而,这样做也带来了挑战。在流水线中进行数据提取步骤必须应对 API 速率限制、连接超时、下载缓慢以及源系统所有者因其系统负担而感到不满。

注意

正如我在第四章和第五章中将详细讨论的那样,数据摄入是大多数数据管道中的第一步。因此,了解源系统及其数据的特性是设计管道并在下游基础设施方面做出决策的第一步。

云数据仓库和数据湖

过去 10 年间,有三件事情改变了分析和数据仓库领域的格局,这三件事情都与主要公共云服务提供商(亚马逊、谷歌和微软)的出现有关:

  • 在云中构建和部署数据管道、数据湖、数据仓库以及分析处理变得更加简便。不再需要等待 IT 部门和预算批准大笔前期成本。托管服务,特别是数据库,已经成为主流。

  • 云端存储成本持续下降。

  • 出现了高度可扩展的列式数据库,比如 Amazon Redshift、Snowflake 和 Google Big Query。

这些变化为数据仓库注入了新的活力,并引入了数据湖的概念。尽管第五章详细介绍了数据仓库和数据湖,但现在简要定义两者,以便澄清它们在现代数据生态系统中的位置。

数据仓库是一个数据库,用于存储和建模来自不同系统的数据,以支持与之相关的分析和其他活动。数据仓库中的数据是结构化的,并且经过优化,用于报告和分析查询。

数据湖是数据存储的地方,但没有数据仓库的结构或查询优化。它可能包含大量数据以及各种数据类型。例如,单个数据湖可能包含存储为文本文件的博客文章集合,来自关系数据库的扁平文件提取,以及工业系统传感器生成的 JSON 对象事件。它甚至可以存储结构化数据,如标准数据库,尽管其未优化用于报告和分析查询。

在同一个数据生态系统中,数据仓库和数据湖都有各自的位置,数据管道经常在两者之间移动数据。

数据摄入工具

将数据从一个系统摄取到另一个系统的需求几乎是所有数据管道的共同需求。正如本章之前讨论的,数据团队必须处理各种各样的数据源。幸运的是,在现代数据基础设施中,有许多商业和开源工具可供选择。

在这本《便携参考指南》中,我讨论了一些最常见的工具和框架,包括:

  • Singer

  • Stitch

  • Fivetran

尽管这些工具普遍存在,一些团队决定编写自定义代码来进行数据摄取。有些甚至开发了他们自己的框架。组织机构选择这样做的原因各不相同,但通常与成本、构建文化胜过购买、以及对于信任外部供应商的法律和安全风险的担忧有关。在第五章中,我讨论了数据摄取工具独特的构建与购买权衡。特别感兴趣的是商业解决方案的价值是否在于让数据工程师更容易将数据摄取集成到他们的流水线中,还是在于让非数据工程师(如数据分析师)自行构建摄取过程。

正如第四章和第五章所述,数据摄取传统上包括 ETL 或 ELT 过程中的 提取加载 步骤。一些工具专注于这些步骤,而另一些还提供一些 转换 能力给用户。在实际应用中,我发现大多数数据团队选择在数据摄取过程中限制转换数量,并且使用擅长于从源中提取数据并加载到目的地的摄取工具。

数据转换和建模工具

尽管本章的大部分内容集中在源和目的地之间移动数据(数据摄取)上,但数据管道和数据移动还包括更多内容。管道还包括转换和建模数据以实现新目的,如机器学习、分析和报告的任务。

数据建模数据转换 这两个术语常常可以互换使用;然而,在本文中,我将对它们进行区分:

数据转换

转换数据是 ETL 或 ELT 过程中 T 的广义术语。转换可以是将表中存储的时间戳从一个时区转换到另一个时区这样简单的操作。它还可以是一个更复杂的操作,通过某些业务逻辑聚合和过滤多个源列,创建一个新的度量指标。

数据建模

数据建模是数据转换的一种更具体的类型。数据模型以数据分析理解和优化的格式结构化和定义数据。数据模型通常以一个或多个表的形式存在于数据仓库中。有关创建数据模型的过程,将在第六章中详细讨论。

与数据摄取类似,现代数据基础设施中存在许多方法和工具。如前所述,一些数据摄取工具提供一定程度的数据转换能力,但这些通常非常简单。例如,为了保护个人身份信息(PII),可能希望将电子邮件地址转换为哈希值,然后存储在最终目标中。这种转换通常在摄取过程中执行。

对于更复杂的数据转换和数据建模,我发现寻找专门设计用于此任务的工具和框架很有必要,例如 dbt(参见第九章)。此外,数据转换通常是上下文特定的,可以使用熟悉的数据工程师和数据分析师使用的语言编写,如 SQL 或 Python。

用于分析和报告的数据模型通常是通过 SQL 或通过点对点用户界面定义和编写的。就像构建与购买的权衡一样,在选择使用 SQL 还是无代码工具构建模型时,需要考虑一些因素。SQL 是一种非常易于访问的语言,对数据工程师和分析师都很常见。它使分析师能够直接处理数据,并优化模型设计以满足其需求。它几乎在每个组织中使用,因此为新员工加入团队提供了熟悉的入门点。在大多数情况下,选择支持使用 SQL 构建数据模型的转换框架比通过点对点用户界面更可取。您将获得更多的可定制性,并从头到尾拥有自己的开发过程。

第六章详细讨论了数据转换和建模。

工作流编排平台

随着组织中数据管道的复杂性和数量的增长,将工作流编排平台引入到您的数据基础设施中变得至关重要。这些平台管理管道中任务的调度和流动。想象一下一条管道,其中包括从 Python 编写的数据摄取到 SQL 编写的数据转换的十几个任务,必须在一天中特定的顺序中运行。安排和管理每个任务之间的依赖关系并不是一个简单的挑战。每个数据团队都面临这一挑战,但幸运的是,有许多工作流编排平台可用来减轻这种痛苦。

注意

工作流编排平台也被称为工作流管理系统(WMSs)、编排平台编排框架。在本文中,我将这些术语互换使用。

一些平台,如 Apache Airflow、Luigi 和 AWS Glue,设计用于更通用的用例,因此用于各种数据管道。另一些平台,如 Kubeflow Pipelines,则设计用于更具体的用例和平台(在 Kubeflow Pipelines 的情况下,是构建在 Docker 容器上的机器学习工作流)。

有向无环图

几乎所有现代编排框架将任务的流程和依赖关系表示为图形管道。然而,管道图具有一些特定的约束条件。

管道步骤始终是有向的,这意味着它们从一个一般任务或多个任务开始,并以一个特定的任务或任务结束。这是为了确保执行路径。换句话说,它确保任务在其所有依赖任务成功完成之前不会运行。

管道图还必须是无环的,这意味着任务不能指向之前已完成的任务。换句话说,它不能循环。如果可以的话,管道可能会无限运行!

在考虑这两个约束条件的基础上,编排管道生成称为有向无环图(DaGs)的图形。图 2-3 展示了一个简单的 DAG。在这个例子中,任务 A 必须在任务 B 和 C 开始之前完成。一旦它们都完成了,任务 D 就可以开始。一旦任务 D 完成,整个管道也完成了。

dppr 0203

图 2-3. 一个有四个任务的 DAG。任务 A 完成后,任务 B 和任务 C 运行。当它们都完成后,任务 D 运行。

DAG 是一组任务的表示,并不是任务逻辑定义的位置。编排平台能够运行各种任务。

例如,考虑一个有三个任务的数据管道。在图 2-4 中表示为一个 DAG。

  • 第一个执行一个 SQL 脚本,从关系型数据库中查询数据,并将结果存储在 CSV 文件中。

  • 第二个运行一个 Python 脚本,加载 CSV 文件,清理数据,然后重塑数据后保存新版本的文件。

  • 最后,第三个任务运行 SQL 中的 COPY 命令,将第二个任务创建的 CSV 加载到 Snowflake 数据仓库中。

dppr 0204

图 2-4. 一个有三个任务的 DAG,按顺序从 SQL 数据库提取数据,使用 Python 脚本清理和重塑数据,然后将结果数据加载到数据仓库中。

编排平台执行每个任务,但任务的逻辑存在于 SQL 和 Python 代码中,在数据基础设施的不同系统上运行。

第七章更详细地讨论了工作流编排平台,并提供了 Apache Airflow 中管道编排的实际示例。

自定义数据基础设施

很少能找到两个完全相同数据基础设施的组织。大多数选择符合其特定需求的工具和供应商,并自行构建其余部分。尽管本书中详细介绍了一些最流行的工具和产品,但每年市场上还有更多新产品问世。

如前所述,根据您组织中的文化和资源情况,您可能会被鼓励大部分数据基础设施自行构建,或者依赖于 SaaS 供应商。无论您在构建与购买的权衡上倾向于哪一方,您都可以构建高质量的数据基础设施,这对于构建高质量的数据流水线至关重要。

重要的是理解你的约束条件(资金、工程资源、安全性和法律风险容忍度),以及由此产生的权衡。我在整个文本中都会谈及这些,并且指出在选择产品或工具时的关键决策点。

第三章:常见数据管道模式

即使对于经验丰富的数据工程师来说,设计新的数据管道每次都是一次新的旅程。正如在第二章中讨论的,不同的数据来源和基础设施既是挑战也是机遇。此外,管道的构建目标和约束也各不相同。数据是否需要几乎实时处理?可以每天更新吗?将其建模用于仪表盘还是作为机器学习模型的输入?

幸运的是,数据管道中存在一些常见模式已被证明成功,并且可以扩展到许多用例。在本章中,我将定义这些模式。后续章节将基于这些模式实现管道。

ETL 和 ELT

或许没有比 ETL 及其现代化的姊妹 ELT 更为人熟知的模式了。这两者都是广泛用于数据仓库和商业智能的模式。近年来,它们还为运行在生产中的数据科学和机器学习模型的管道模式提供了灵感。它们如此著名,以至于许多人将这些术语与数据管道同义使用,而不是许多管道遵循的模式。

考虑到它们源自数据仓库,最容易在这个背景下描述它们,这也是本节所做的。本章后续部分将描述它们在特定用例中的使用方式。

这两种模式都是用于将数据输入数据仓库并使其对分析师和报告工具有用的数据处理方法。它们之间的区别在于其最后两个步骤(转换和加载)的顺序,但在选择它们之间的设计影响方面存在重大差异,正如我将在本章中详细解释的那样。首先,让我们探讨 ETL 和 ELT 的步骤。

提取 步骤从各种来源收集数据,为加载和转换做准备。第二章讨论了这些来源的多样性和提取方法。

加载 步骤将原始数据(在 ELT 的情况下)或完全转换后的数据(在 ETL 的情况下)带入最终目的地。无论哪种方式,最终结果都是将数据加载到数据仓库、数据湖或其他目的地中。

转换 步骤是从每个源系统的原始数据中合并和格式化数据,使其对分析师、可视化工具或管道服务的任何用例都有用。无论您将流程设计为 ETL 还是 ELT,此步骤都有很多内容,所有这些内容都将在第六章中详细探讨。

ELT 的兴起超越了 ETL

几十年来,ETL 一直是数据管道模式的黄金标准。尽管现在仍在使用,但最近 ELT 已经成为首选模式。为什么呢?在现代云端主导的数据仓库之前(参见第 2 章),数据团队没有访问具备足够存储或计算能力的数据仓库,无法处理大量原始数据加载和转换成可用数据模型的需求。此外,当时的数据仓库是面向事务使用场景的行存储数据库,但对于分析中常见的大容量批量查询并不适用。因此,数据首先从源系统中提取出来,然后在另一个系统上进行转换,最后再加载到数据仓库中供分析师和可视化工具查询和模型化使用。

当今大多数数据仓库基于高度可扩展的列存储数据库构建,可以在成本效益高的情况下存储和运行大规模数据转换操作。得益于列存储数据库的 I/O 效率、数据压缩能力以及能够跨多个节点分发数据和查询的能力,情况已经发生了变化。现在更加适合专注于提取数据并加载到数据仓库,然后在那里执行必要的转换操作来完成数据管道。

行存储和列存储数据仓库之间的差异影响深远。图 3-1 展示了如何在行存储数据库(如 MySQL 或 Postgres)中将记录存储在磁盘上的示例。数据库的每一行都整体存储在磁盘上的一个或多个块中,具体取决于每条记录的大小。如果记录小于一个块或者无法整除块大小,则会导致一些磁盘空间未被使用。

dppr 0301

图 3-1. 存储在行存储数据库中的表格。每个块包含表中的一条记录(行)。

考虑一个在线事务处理(OLTP)数据库用例,比如利用 MySQL 数据库存储的电子商务网站应用程序。Web 应用程序从 MySQL 数据库读取和写入数据,通常涉及每条记录中的多个值,比如订单确认页面上的订单详情。同时,可能仅查询或更新一个订单。因此,行存储是最优选择,因为应用程序所需的数据在磁盘上的靠近位置存储,并且每次查询的数据量较小。

由于记录在块中留下空间导致的磁盘空间利用不佳,在这种情况下是可以接受的平衡,因为频繁读写单个记录的速度是最重要的。然而,在分析中情况恰恰相反。我们通常不需要频繁读写少量数据,而是偶尔需要读写大量数据。此外,分析查询很少需要表中的许多列,而是一个具有许多列的表中的单个列。

例如,在我们虚构的电子商务应用程序中考虑订单表。除其他事项外,它包含订单金额以及正在发货的国家。与按顺序处理订单的 Web 应用程序不同,使用数据仓库的分析师将希望批量分析订单。此外,数据仓库中包含订单数据的表还包含来自我们的 MySQL 数据库中多个表的值的额外列。例如,它可能包含下订单的客户的信息。也许分析师想要总结所有由当前活跃账户的客户下的订单。这样的查询可能涉及数百万条记录,但仅需读取两列,OrderTotal 和 CustomerActive。毕竟,分析不是关于创建或更改数据(例如在 OLTP 中)而是关于推导指标和理解数据。

如图 Figure 3-2 所示,例如 Snowflake 或 Amazon Redshift 这样的列数据库,将数据按列而非按行存储在磁盘块中。在我们的使用案例中,分析师编写的查询只需访问存储 OrderTotal 和 CustomerActive 值的块,而不是存储像 MySQL 数据库那样基于行的记录的块。因此,需要访问的磁盘 I/O 较少,需要加载到内存中执行分析查询所需的筛选和求和操作的数据也较少。最终的好处是存储量减少,因为块可以充分利用并进行最佳压缩,因为每个块中存储的是相同的数据类型,而不是在单个基于行的记录中常见的多种类型。

总的来说,列式数据库的出现意味着在数据仓库中高效存储、转换和查询大型数据集。数据工程师可以利用这一优势,通过构建专门用于从数据仓库中提取和加载数据的流水线步骤,让分析师和数据科学家可以在数据库的限制范围内进行数据转换、建模和查询。因此,ELT 已成为数据仓库流水线以及机器学习和数据产品开发中其他用例的理想模式。

dppr 0302

图 3-2. 存储在基于列的存储数据库中的表。每个磁盘块包含来自同一列的数据。我们示例查询涉及的两列已经突出显示。只有这些块需要访问才能运行查询。每个块包含相同类型的数据,使压缩效果最佳。

EtLT 子模式

当 ELT 模式成为主流模式时,明确的是在加载之前执行一些转换仍然是有益的。但是,与涉及业务逻辑或数据建模的转换不同,这种类型的转换范围更有限。我将其称为小写 t转换,或EtLT

符合 EtLT 子模式的转换类型的一些示例包括以下内容:

  • 在表中去重记录

  • 将 URL 参数解析为各个组件

  • 掩盖或以其他方式混淆敏感数据

这些类型的转换要么完全与业务逻辑脱节,要么(例如,对敏感数据进行掩码)有时由于法律或安全原因需要尽早在管道中执行。此外,使用合适的工具完成合适的工作也有其价值。正如第四章和第五章详细说明的那样,如果准备充分,大多数现代数据仓库可以以最高效的方式加载数据。在处理大量数据或关键性能时延要求高的管道中,在提取和加载步骤之间执行一些基本的转换是值得的。

您可以假设剩余的 ELT 相关模式设计的目的是包含 EtLT 子模式。

用于数据分析的 ELT

对于为数据分析构建的管道来说,ELT 已经成为最常见且我认为是最优化的模式。正如前面讨论的那样,列存储数据库非常适合处理大量数据。它们还设计用于处理宽表,即具有许多列的表,这要归功于仅扫描磁盘上用于特定查询的列数据并将其加载到内存中的事实。

除了技术考虑因素外,数据分析员通常精通 SQL。使用 ELT 时,数据工程师可以专注于管道中的提取和加载步骤(数据摄取),而分析员可以利用 SQL 根据需要转换已被摄入的数据以供报告和分析。这种清晰的分离在 ETL 模式中是不可能的,因为数据工程师在整个管道中都是必需的。如图 3-3 所示,ELT 允许数据团队成员专注于各自的优势,减少了相互依赖和协调的需要。

此外,ELT 模式减少了在构建提取和加载过程时需要准确预测分析员将如何使用数据的必要性。虽然了解一般用例是提取和加载适当数据的要求,但是将转换步骤推迟到后面为分析员提供了更多选择和灵活性。

dppr 0303

图 3-3. ELT 模式允许数据工程师和数据分析师(或数据科学家)在工具和语言上独立工作。
注意

随着 ELT 的出现,数据分析师变得更加自主,并能够通过自己编写和部署作为 SQL 编写的转换代码,从数据中提取价值而不被数据工程师“阻塞”。数据工程师可以专注于数据引入和支持基础设施。随着这种赋权,出现了新的工作职称,如分析工程师。第六章 讨论了这些数据分析师和分析工程师如何转换数据以构建数据模型。

数据科学的 ELT

为数据科学团队构建的数据管道与数据仓库中为数据分析构建的管道类似。与分析用例类似,数据工程师专注于将数据引入数据仓库或数据湖。然而,数据科学家对数据的需求与数据分析师不同。

尽管数据科学是一个广泛的领域,但总体来说,数据科学家需要比数据分析师更详细和有时更原始的数据访问。虽然数据分析师建立的数据模型能生成指标并支持仪表板,数据科学家则花费大部分时间探索数据和构建预测模型。虽然本书不涵盖数据科学家的具体角色细节,但这种高层次的区别对于为数据科学家设计流水线是重要的。

如果你正在构建管道以支持数据科学家,你会发现 ELT 模式中的提取和加载步骤与支持分析的步骤基本相同。第四章 和 5 章 详细介绍了这些技术步骤。数据科学家在 ELT 管道的转换步骤中也可能受益于与分析师构建的某些数据模型一起工作(第六章),但他们可能会分支出去,并使用在提取加载过程中获取的大部分数据。

数据产品和机器学习的 ELT

数据不仅用于分析、报告和预测模型,还用于支持数据产品。一些常见的数据产品示例包括以下内容:

  • 驱动视频流首页的内容推荐引擎

  • 电子商务网站上的个性化搜索引擎

  • 一个应用程序,对用户生成的餐厅评论进行情感分析

这些数据产品中的每一个可能都由一个或多个机器学习(ML)模型驱动,这些模型需要训练和验证数据。这些数据可能来自各种源系统,并经过一定程度的转换以准备用于模型。类似 ELT 的模式非常适合这些需求,尽管面向数据产品设计的管道的所有步骤都存在一些具体的挑战。

机器学习流水线中的步骤

像本书主要专注的分析管道一样,构建用于机器学习的管道也遵循与 ELT 类似的模式,至少在管道的开始阶段是如此。不同之处在于,转换步骤不再专注于将数据转换为数据模型,而是一旦数据被提取并加载到数据仓库或数据湖中,就涉及到多个步骤来构建和更新机器学习模型。

如果您熟悉机器学习开发,这些步骤可能看起来也很熟悉:

数据摄入

这一步骤遵循我在第四章和 5 章中概述的相同过程。尽管您输入的数据可能不同,但对于分析和机器学习构建的管道,逻辑基本保持一致,但对于机器学习管道还有一个额外考虑的因素。即,确保您输入的数据以可以供后续机器学习模型引用的特定数据集版本化。有多种工具和方法可以对数据集进行版本管理。建议参考 “ML 管道进一步阅读” 以了解更多信息。

数据预处理

摄入的数据不太可能直接用于机器学习开发。预处理是数据清理和为模型准备数据的过程。例如,在管道中,这是文本标记化、特征转换为数值以及输入值归一化的步骤。

模型训练

在新数据被摄入和预处理后,机器学习模型需要重新训练。

模型部署

将模型部署到生产环境可能是从以研究为导向的机器学习到真正数据产品的最具挑战性的部分。在这里,不仅需要对数据集进行版本控制,还需要对训练过的模型进行版本控制。通常情况下,会使用 REST API 来允许查询已部署模型,并将用于各个模型版本的 API 端点。这是一个需要跟踪和协调数据科学家、机器学习工程师和数据工程师共同努力达到生产状态的过程。一个设计良好的管道是将这一切粘合在一起的关键。

在管道中整合反馈

任何优秀的机器学习管道还将包括收集反馈以改进模型的步骤。以视频流服务的内容推荐模型为例。为了在未来衡量和改进模型,您需要跟踪其推荐给用户的内容、用户点击的推荐以及点击后他们享受的推荐内容。为此,您需要与负责在流服务主屏上使用模型的开发团队合作。他们需要实施一些类型的事件收集,以跟踪每个用户推荐的推荐内容,推荐时使用的模型版本,以及点击后的行为,并将点击行为与他们可能已经收集的有关用户内容消费的数据联系起来。

所有这些信息都可以被摄入到数据仓库中,并被纳入到未来模型的版本中,可以作为训练数据,也可以被数据科学家(也许是数据科学家)分析和考虑,以便将来纳入到模型或实验中。

此外,收集到的数据可以被数据分析师在本书中描述的 ELT 模式中摄入、转换和分析。分析师通常被委托测量模型的有效性,并构建仪表板来显示组织的模型关键指标。利益相关者可以使用这些仪表板来理解各种模型对业务和客户的效果如何。

有关 ML 流水线的进一步阅读

为机器学习模型构建流水线是一个强大的主题。根据您的基础设施选择和 ML 环境的复杂性,我建议您阅读以下几本书进行进一步学习:

  • 构建机器学习流水线 by Hannes Hapke 和 Catherine Nelson (O’Reilly, 2020)

  • 使用 Scikit-Learn、Keras 和 TensorFlow 进行机器学习,第二版,作者 Aurélien Géron (O’Reilly, 2019)

此外,以下书籍是一个极其易于理解的机器学习入门:

  • Python 机器学习入门 by Andreas C. Müller 和 Sarah Guido (O’Reilly, 2016)

第四章:数据摄取:提取数据

正如第三章中讨论的那样,ELT 模式是为数据分析、数据科学和数据产品构建的数据流水线的理想设计。ELT 模式的前两个步骤,提取和加载,通常被称为数据摄取。本章讨论了为这两个步骤设置开发环境和基础设施的具体步骤,并详细介绍了从各种源系统提取数据的细节。第五章讨论了将结果数据集加载到数据仓库中。

注意

本章的提取和加载代码示例完全解耦。协调这两个步骤以完成数据摄取是第七章讨论的主题。

正如第二章中讨论的那样,有许多类型的源系统可以提取数据,以及许多目的地可以加载数据。此外,数据以多种形式呈现,每种形式都对其摄取提出了不同的挑战。

本章和下一章包括了从常见系统中导出和摄取数据的代码示例。这些代码简化了高度,仅包含了最小的错误处理。每个示例都旨在作为数据摄取的易于理解的起点,但完全功能且可扩展到更可伸缩的解决方案。

注意

本章的代码示例将提取的数据写入 CSV 文件,以加载到目标数据仓库中。在某些情况下,将提取的数据存储为其他格式(如 JSON)可能更合理。在适用的情况下,我会指出您可能考虑进行这样的调整。

第五章还讨论了一些您可以构建的开源框架,以及为数据工程师和分析师提供“低代码”选项的商业替代方案,用于摄取数据。

设置您的 Python 环境

所有后续的代码示例都是用 Python 和 SQL 编写的,并使用今天数据工程领域常见的开源框架。为简单起见,源和目的地的数量有限。但在适用的情况下,我提供了如何修改为类似系统的说明。

要运行示例代码,您需要运行 Python 3.x 的物理或虚拟机。您还需要安装和导入一些库。

如果您的计算机上没有安装 Python,可以直接从官网获取适合您操作系统的分发版和安装程序。

注意

以下命令适用于 Linux 或 Macintosh 命令行。在 Windows 上,您可能需要将 Python 3 可执行文件添加到您的 PATH 中。

在安装本章使用的库之前,最好创建一个 虚拟环境 来安装它们。为此,你可以使用一个名为 virtualenv 的工具。virtualenv 有助于管理不同项目和应用程序的 Python 库。它允许你在特定于你的项目的范围内安装 Python 库,而不是全局安装。首先,创建一个名为 env 的虚拟环境。

$ python -m venv env

现在你已经创建了虚拟环境,请使用以下命令激活它:

$ source env/bin/activate

你可以通过两种方式验证你的虚拟环境是否已激活。首先,你会注意到你的命令提示符现在前缀为环境名称:

(env) $

你还可以使用 which python 命令验证 Python 查找库的位置。你应该会看到类似于这样的输出,显示了虚拟环境目录的路径:

(env) $ which python
env/bin/python

现在安全地安装接下来代码示例需要的库。

注意

在某些操作系统(OS)上,你必须使用python3而不是python来运行 Python 3.x 可执行文件。较旧的 OS 版本可能默认使用 Python 2.x。你可以通过输入python --version来查看你的操作系统使用的 Python 版本。

在本章中,你将使用 pip 来安装代码示例中使用的库。pip 是大多数 Python 发行版附带的工具。

第一个你将使用 pip 安装的库是 configparser,它将用于读取稍后添加到文件中的配置信息。

(env) $ pip install configparser

接下来,在接下来的几节中创建的 Python 脚本所在的同一目录中创建一个名为 pipeline.conf 的文件。现在先保持文件为空。本章的代码示例将需要向其中添加内容。在 Linux 和 Mac 操作系统中,你可以使用以下命令在命令行中创建空文件:

(env) $ touch pipeline.conf

设置云文件存储

在本章的每个示例中,你将使用一个名为亚马逊简单存储服务(Amazon Simple Storage Service,简称 S3)的存储桶来进行文件存储。S3 托管在 AWS 上,正如其名称所示,S3 是一种简单的存储和访问文件的方式。它还非常经济高效。截至本文撰写时,AWS 提供新 AWS 账户的首个 12 个月免费 5 GB 的 S3 存储,并在此后的标准 S3 存储类型中每个千兆字节不到 3 美分的费用。考虑到本章示例的简单性,如果你在创建 AWS 账户的前 12 个月内,你可以免费在 S3 中存储所需数据;或者在此后每月不到 1 美元的费用下进行存储。

要运行本章中的示例,您将需要一个 S3 存储桶。幸运的是,创建 S3 存储桶很简单,最新的说明可以在AWS 文档中找到。设置适当的访问控制以访问 S3 存储桶取决于您使用的数据仓库。通常情况下,最好使用 AWS 身份和访问管理(IAM)角色进行访问管理策略。有关为 Amazon Redshift 和 Snowflake 数据仓库设置此类访问的详细说明将在接下来的章节中提供,但现在请按照指示创建一个新的存储桶。可以使用默认设置来命名它,包括保持存储桶私有。

每个抽取示例从给定的源系统中提取数据,并将输出存储在 S3 存储桶中。第五章中的每个加载示例将数据从 S3 存储桶加载到目标中。这是数据管道中的常见模式。每个主要的公共云提供商都有类似 S3 的服务。其他公共云上的等价服务包括 Microsoft Azure 中的 Azure 存储和 GCP 中的 Google Cloud Storage(GCS)。

还可以修改每个示例以使用本地或本地存储。但是,需要额外的工作来从特定云提供商以外的存储加载数据到您的数据仓库中。无论您使用哪个云提供商,或者选择在本地托管您的数据基础设施,本章描述的模式都是有效的。

在我继续每个示例之前,还有一个 Python 库,您需要安装它,以便您的抽取和加载脚本可以与您的 S3 存储桶进行交互。Boto3 是 AWS 的 Python SDK。确保您在前一节中设置的虚拟环境处于活动状态,并使用pip安装它:

(env) $ pip install boto3

在接下来的示例中,您将被要求像这样将boto3导入到您的 Python 脚本中:

import boto3

因为您将使用boto3 Python 库与您的 S3 存储桶进行交互,所以您还需要创建一个 IAM 用户,为该用户生成访问密钥,并将密钥存储在配置文件中,以便您的 Python 脚本可以读取。这些都是必需的,以便您的脚本具有权限在您的 S3 存储桶中读取和写入文件。

首先,创建 IAM 用户:

  1. 在 AWS 控制台的服务菜单(或顶部导航栏)下,导航至 IAM。

  2. 在导航窗格中,点击“用户”,然后点击“添加用户”。键入新用户的用户名。在本例中,将用户命名为data_pipeline_readwrite

  3. 为此 IAM 用户选择访问类型。选择“程序化访问”,因为此用户不需要登录到 AWS 控制台,而是通过 Python 脚本以编程方式访问 AWS 资源。

  4. 点击“下一步:权限”。

  5. 在“设置权限”页面上,点击“直接为用户附加现有策略”选项。添加 AmazonS3FullAccess 策略。

  6. 点击“下一步:标签”。在 AWS 中,向各种对象和服务添加标签以便稍后查找是最佳实践。但这是可选的。

  7. 点击“下一步:审阅”以验证您的设置。如果一切正常,请点击“创建用户”。

  8. 您需要保存新 IAM 用户的访问密钥 ID 和秘密访问密钥。为此,请单击“下载 .csv” 然后将文件保存到安全位置,以便稍后使用。

最后,在 pipeline.conf 文件中添加一个名为 [aws_boto_credentials] 的部分,以存储 IAM 用户和 S3 存储桶信息的凭证。您可以在登录 AWS 网站后点击右上角的帐户名找到您的 AWS 帐户 ID。将您之前创建的 S3 存储桶的名称用作 bucket_name 的值。pipeline.conf 文件中的新部分将如下所示:

[aws_boto_credentials]
access_key = ijfiojr54rg8er8erg8erg8
secret_key = 5r4f84er4ghrg484eg84re84ger84
bucket_name = pipeline-bucket
account_id = 4515465518

从 MySQL 数据库提取数据

从 MySQL 数据库提取数据可以通过两种方式完成:

  • 使用 SQL 进行全量或增量提取

  • 二进制日志(binlog)复制

使用 SQL 进行全量或增量提取较为简单,但对于数据变化频繁的大型数据集来说,可扩展性较差。全量和增量提取之间也存在一些权衡,我将在下一节中讨论。

虽然二进制日志复制实现起来更复杂,但更适合源表数据变更量大或需要更频繁地从 MySQL 源中摄取数据的情况。

注意

二进制日志复制也是创建流式数据摄入的一种方式。有关这两种方法之间以及实现模式的区别,请参阅本章的“批处理与流摄入”部分。

本节内容适用于那些需要从 MySQL 数据源中提取数据的读者。但是,如果您想设置一个简单的数据库以便尝试代码示例,您有两个选择。首先,您可以免费在本地机器或虚拟机上安装 MySQL。您可以在MySQL 下载页面找到适用于您操作系统的安装程序。

或者,您可以在AWS中创建一个完全托管的 Amazon RDS for MySQL 实例。我发现这种方法更加直观,而且不会在本地机器上创建不必要的混乱!

警告

当您按照链接指南设置 MySQL RDS 数据库实例时,您将被提示将数据库设置为公共访问。这对于学习和处理示例数据非常合适。事实上,这样做可以更轻松地从您运行本节示例的任何计算机连接。但是,在生产环境中为了更强大的安全性,建议遵循Amazon RDS 安全最佳实践

请注意,就像前面提到的 S3 定价一样,如果您不再符合 AWS 的免费套餐条件,这将产生费用。否则,设置和运行是免费的!只需记得在完成时删除您的 RDS 实例,以免忘记并在免费套餐过期时产生费用。

本节中的代码示例非常简单,并参考了 MySQL 数据库中名为Orders的表。一旦有了 MySQL 实例可以使用,你可以通过运行以下 SQL 命令创建表并插入一些示例行:

CREATE TABLE Orders (
  OrderId int,
  OrderStatus varchar(30),
  LastUpdated timestamp
);

INSERT INTO Orders
  VALUES(1,'Backordered', '2020-06-01 12:00:00');
INSERT INTO Orders
  VALUES(1,'Shipped', '2020-06-09 12:00:25');
INSERT INTO Orders
  VALUES(2,'Shipped', '2020-07-11 3:05:00');
INSERT INTO Orders
  VALUES(1,'Shipped', '2020-06-09 11:50:00');
INSERT INTO Orders
  VALUES(3,'Shipped', '2020-07-12 12:00:00');

MySQL 表的全量或增量抽取

当你需要将 MySQL 表中的所有或部分列导入到数据仓库或数据湖中时,可以使用全量抽取或增量抽取。

全量抽取中,每次运行抽取作业时都会提取表中的每条记录。这是最简单的方法之一,但对于高容量的表来说,运行时间可能会很长。例如,如果你想对名为Orders的表进行全量抽取,在源 MySQL 数据库上执行的 SQL 如下所示:

SELECT *
FROM Orders;

增量抽取中,只提取源表中自上次作业运行以来发生更改或新增的记录。上次抽取的时间戳可以存储在数据仓库中的抽取作业日志表中,或者通过查询仓库中目标表中LastUpdated列的最大时间戳来检索。以虚构的Orders表为例,执行在源 MySQL 数据库上执行的 SQL 查询如下所示:

SELECT *
FROM Orders
WHERE LastUpdated > {{ last_extraction_run} };
注意

对于包含不可变数据(即可以插入但不能更新记录的数据),可以使用记录创建时间戳而不是LastUpdated列。

{{ last_extraction_run }}变量是表示最近一次抽取作业运行的时间戳。通常情况下,它是从数据仓库中的目标表中查询的。在这种情况下,将在数据仓库中执行以下 SQL,并使用结果值作为{{ last_extraction_run }}使用:

SELECT MAX(LastUpdated)
FROM warehouse.Orders;

尽管增量抽取对于性能优化是理想的,但也存在一些缺点和可能使得某个表无法使用的原因。

首先,使用此方法删除时,不会捕获行。如果从源 MySQL 表中删除行,则不会知道,并且它将保留在目标表中,就像没有发生任何更改一样。

第二,源表必须具有可靠的时间戳来标记其上次更新的时间(如前面示例中的LastUpdated列)。源系统表中缺少这样的列或者列的更新不可靠并不少见。开发人员可以随意更新源表中的记录,而忘记更新LastUpdated时间戳。

然而,增量抽取确实使捕捉更新行变得更容易。在即将出现的代码示例中,如果Orders表中的特定行被更新,那么无论是全量还是增量抽取都将带回行的最新版本。在全量抽取中,这对表中的所有行都适用,因为抽取会检索表的完整副本。在增量抽取中,只检索已更改的行。

当进行加载步骤时,通常通过首先截断目标表并加载新抽取的数据来加载全量抽取数据。在这种情况下,您将仅在数据仓库中留下行的最新版本。

从增量抽取加载数据时,结果数据将追加到目标表中的数据中。在这种情况下,你将同时拥有原始记录和更新后的版本。当进行数据转换和分析时,同时拥有这两者可能非常有价值,正如我在第六章中讨论的那样。

例如,表 4-1 显示了 MySQL 数据库中订单 ID 为 1 的原始记录。当客户下订单时,订单处于缺货后补状态。表 4-2 显示了 MySQL 数据库中的更新记录。如您所见,该订单已更新,因为在 2020-06-09 发货。

表 4-1. 订单 ID 1 的原始状态

订单 ID 订单状态 最后更新时间
1 缺货后补 2020-06-01 12:00:00

表 4-2. 订单 ID 1 的更新状态

订单 ID 订单状态 最后更新时间
1 已发货 2020-06-09 12:00:25

当运行全量抽取时,数据仓库中的目标表首先被截断,然后加载抽取的输出数据。订单 ID为 1 的结果是只显示在表 4-2 中的单个记录。然而,在增量抽取中,抽取的输出数据简单地追加到数据仓库的目标表中。结果是,订单 ID 为 1 的原始和更新记录都在数据仓库中,如表 4-3 所示。

表 4-3. 数据仓库中订单 ID 1 的所有版本

订单 ID 订单状态 最后更新时间
1 缺货后补 2020-06-01 12:00:00
1 已发货 2020-06-09 12:00:25

您可以在第五章的相关部分了解更多关于加载全量和增量抽取的信息,包括“将数据加载到 Redshift 数据仓库”。

警告

永远不要假设源系统中的LastUpdated列会被可靠地更新。在依赖它进行增量抽取之前,请与源系统的所有者确认。

可以使用在数据库上执行的 SQL 查询实现 MySQL 数据库的完整和增量提取,但由 Python 脚本触发。除了在前几节安装的 Python 库外,您还需要使用pip安装PyMySQL库:

(env) $ pip install pymysql

您还需要向pipeline.conf文件添加新的部分,以存储 MySQL 数据库的连接信息:

[mysql_config]
hostname = my_host.com
port = 3306
username = my_user_name
password = my_password
database = db_name

现在创建一个名为extract_mysql_full.py的新 Python 脚本。您需要导入几个库,例如连接到 MySQL 数据库的pymysql,以及csv库,这样您可以在摄入过程的加载步骤中结构化并写出提取的数据为易于导入数据仓库的平面文件。还导入boto3,这样您可以将生成的 CSV 文件上传到您的 S3 桶,以便稍后加载到数据仓库:

import pymysql
import csv
import boto3
import configparser

现在你可以初始化到 MySQL 数据库的连接:

parser = configparser.ConfigParser()
parser.read("pipeline.conf")
hostname = parser.get("mysql_config", "hostname")
port = parser.get("mysql_config", "port")
username = parser.get("mysql_config", "username")
dbname = parser.get("mysql_config", "database")
password = parser.get("mysql_config", "password")

conn = pymysql.connect(host=hostname,
        user=username,
        password=password,
        db=dbname,
        port=int(port))

if conn is None:
  print("Error connecting to the MySQL database")
else:
  print("MySQL connection established!")

从之前的示例中完全提取Orders表。以下代码将提取整个表的内容并将其写入管道分隔的 CSV 文件中。为执行提取操作,它使用pymysql库中的cursor对象执行 SELECT 查询:

m_query = "SELECT * FROM Orders;"
local_filename = "order_extract.csv"

m_cursor = conn.cursor()
m_cursor.execute(m_query)
results = m_cursor.fetchall()

with open(local_filename, 'w') as fp:
  csv_w = csv.writer(fp, delimiter='|')
  csv_w.writerows(results)

fp.close()
m_cursor.close()
conn.close()

现在 CSV 文件已在本地编写,需要将其上传到 S3 桶中,以便稍后加载到数据仓库或其他目的地。从“设置云文件存储”中回忆,您已设置了 IAM 用户供 Boto3 库使用,以对 S3 桶进行身份验证。您还将凭证存储在pipeline.conf文件的aws_boto_credentials部分。以下是将 CSV 文件上传到您的 S3 桶的代码:

# load the aws_boto_credentials values
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
access_key = parser.get("aws_boto_credentials", "access_key")
secret_key = parser.get("aws_boto_credentials", "secret_key")
bucket_name = parser.get("aws_boto_credentials", "bucket_name")

s3 = boto3.client('s3', aws_access_key_id=access_key, aws_secret_access_key=secret_key)

s3_file = local_filename

s3.upload_file(local_filename, bucket_name, s3_file)

你可以按照以下步骤执行脚本:

(env) $ python extract_mysql_full.py

当执行脚本时,Orders表的整个内容现在包含在等待加载到数据仓库或其他数据存储中的 S3 桶中的 CSV 文件中。有关加载到所选数据存储的更多信息,请参见第五章。

如果要增量提取数据,您需要对脚本进行一些更改。建议将extract_mysql_full.py的副本命名为extract_mysql_incremental.py作为起点。

首先,查找从源Orders表中提取的最后记录的时间戳。为此,请查询数据仓库中Orders表的MAX(LastUpdated)值。在本示例中,我将使用 Redshift 数据仓库(参见“配置亚马逊 Redshift 仓库作为目标”),但您也可以使用您选择的仓库。

要与您的 Redshift 集群交互,请安装psycopg2库(如果尚未安装)。

(env) $ pip install psycopg2

这里是连接并从 Redshift 集群查询Orders表中的MAX(LastUpdated)值的代码:

import psycopg2

# get db Redshift connection info
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
dbname = parser.get("aws_creds", "database")
user = parser.get("aws_creds", "username")
password = parser.get("aws_creds", "password")
host = parser.get("aws_creds", "host")
port = parser.get("aws_creds", "port")

# connect to the redshift cluster
rs_conn = psycopg2.connect(
    "dbname=" + dbname
    + " user=" + user
    + " password=" + password
    + " host=" + host
    + " port=" + port)

rs_sql = """SELECT COALESCE(MAX(LastUpdated),
 '1900-01-01')
 FROM Orders;"""
rs_cursor = rs_conn.cursor()
rs_cursor.execute(rs_sql)
result = rs_cursor.fetchone()

# there's only one row and column returned
last_updated_warehouse = result[0]

rs_cursor.close()
rs_conn.commit()

使用存储在 last_updated_warehouse 中的值,修改在 MySQL 数据库上运行的抽取查询,仅拉取自上次运行抽取作业以来已更新的 Orders 表记录。新查询包含一个占位符 %s,用于 last_updated_warehouse 值。然后将该值作为元组传递给游标的 .execute() 函数(一种用于存储数据集合的数据类型)。这是向 SQL 查询添加参数的正确和安全方式,以避免可能的 SQL 注入。以下是在 MySQL 数据库上运行 SQL 查询的更新代码块:

m_query = """SELECT *
 FROM Orders
 WHERE LastUpdated > %s;"""
local_filename = "order_extract.csv"

m_cursor = conn.cursor()
m_cursor.execute(m_query, (last_updated_warehouse,))

用于增量抽取的整个 extract_mysql_incremental.py 脚本(使用 Redshift 集群作为 last_updated 值)如下所示:

import pymysql
import csv
import boto3
import configparser
import psycopg2

# get db Redshift connection info
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
dbname = parser.get("aws_creds", "database")
user = parser.get("aws_creds", "username")
password = parser.get("aws_creds", "password")
host = parser.get("aws_creds", "host")
port = parser.get("aws_creds", "port")

# connect to the redshift cluster
rs_conn = psycopg2.connect(
    "dbname=" + dbname
    + " user=" + user
    + " password=" + password
    + " host=" + host
    + " port=" + port)

rs_sql = """SELECT COALESCE(MAX(LastUpdated),
 '1900-01-01')
 FROM Orders;"""
rs_cursor = rs_conn.cursor()
rs_cursor.execute(rs_sql)
result = rs_cursor.fetchone()

# there's only one row and column returned
last_updated_warehouse = result[0]

rs_cursor.close()
rs_conn.commit()

# get the MySQL connection info and connect
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
hostname = parser.get("mysql_config", "hostname")
port = parser.get("mysql_config", "port")
username = parser.get("mysql_config", "username")
dbname = parser.get("mysql_config", "database")
password = parser.get("mysql_config", "password")

conn = pymysql.connect(host=hostname,
        user=username,
        password=password,
        db=dbname,
        port=int(port))

if conn is None:
  print("Error connecting to the MySQL database")
else:
  print("MySQL connection established!")

m_query = """SELECT *
 FROM Orders
 WHERE LastUpdated > %s;"""
local_filename = "order_extract.csv"

m_cursor = conn.cursor()
m_cursor.execute(m_query, (last_updated_warehouse,))
results = m_cursor.fetchall()

with open(local_filename, 'w') as fp:
  csv_w = csv.writer(fp, delimiter='|')
  csv_w.writerows(results)

fp.close()
m_cursor.close()
conn.close()

# load the aws_boto_credentials values
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
access_key = parser.get(
    "aws_boto_credentials",
    "access_key")
secret_key = parser.get(
    "aws_boto_credentials",
    "secret_key")
bucket_name = parser.get(
    "aws_boto_credentials",
    "bucket_name")

s3 = boto3.client(
    's3',
    aws_access_key_id=access_key,
    aws_secret_access_key=secret_key)

s3_file = local_filename

s3.upload_file(
    local_filename,
    bucket_name,
    s3_file)
警告

要注意大型的抽取作业,无论是完整的还是增量的,可能会给源 MySQL 数据库造成压力,甚至会阻塞生产查询的执行。请与数据库所有者咨询,并考虑设置一个副本用于抽取,而不是直接从主源数据库抽取。

MySQL 数据的二进制日志复制

虽然实施起来更复杂,但使用 MySQL 数据库的 MySQL binlog 内容复制变更在高容量摄入需求的情况下效率高。

注意

Binlog 复制是一种变更数据捕获(CDC)形式。许多源数据存储都有一些 CDC 形式,可以用来使用。

MySQL binlog 是一个日志,记录数据库中执行的每个操作。例如,根据其配置方式,它将记录每个表的创建或修改的具体信息,以及每个 INSERTUPDATEDELETE 操作。尽管最初是用于将数据复制到其他 MySQL 实例,但很容易理解为什么数据工程师对 binlog 的内容如此感兴趣,希望将数据摄入数据仓库中。

因为您的数据仓库可能不是 MySQL 数据库,所以不能简单地使用内置的 MySQL 复制功能。为了利用 binlog 将数据摄入到非 MySQL 源中,需要采取一些步骤:

  1. 启用并配置 MySQL 服务器上的 binlog。

  2. 运行初始的完整表抽取和加载。

  3. 持续从二进制日志中进行抽取。

  4. 将二进制日志的抽取内容翻译并加载到数据仓库中。

注意

第三步没有详细讨论,但要使用 binlog 进行摄入,您必须首先使用当前 MySQL 数据库的内容填充数据仓库中的表,然后使用 binlog 摄入后续的变更。通常需要对要抽取的表进行 LOCK,运行这些表的 mysqldump,然后将 mysqldump 的结果加载到数据仓库中,然后再打开 binlog 摄入。

虽然最好参考最新的 MySQL binlog 文档 获取有关启用和配置二进制日志的指导,我将介绍关键的配置值。

在 MySQL 数据库中,关于 binlog 配置,有两个关键设置需要确保。

首先,请确保启用了二进制日志。通常情况下,默认情况下启用了它,但是您可以通过在数据库上运行以下 SQL 查询来检查(确切的语法可能因 MySQL 分发而异):

SELECT variable_value as bin_log_status
FROM performance_schema.global_variables
WHERE variable_name='log_bin';

如果启用了二进制日志,您将看到以下内容。如果返回的状态是OFF,则需要查阅相关版本的 MySQL 文档以启用它。

+ — — — — — — — — — — — — — — — — — — -+
| bin_log_status :: |
+ — — — — — — — — — — — — — — — — — — -+
| ON |
+ — — — — — — — — — — — — — — — — — — -+
1 row in set (0.00 sec)

接下来,请确保二进制日志格式设置正确。在最近版本的 MySQL 中支持三种格式:

  • STATEMENT

  • ROW

  • MIXED

STATEMENT 格式记录了将 SQL 语句插入或修改行的每个 SQL 语句到 binlog 中。如果您想要从一个 MySQL 数据库复制数据到另一个,这种格式是有用的。为了复制数据,您可以运行所有语句以重现数据库的状态。但是,由于提取的数据可能用于运行在不同平台上的数据仓库,MySQL 数据库中生成的 SQL 语句可能与您的数据仓库不兼容。

使用ROW格式,表中每行的每个更改都表示为 binlog 的一行,而不是 SQL 语句本身。这是首选的格式。

MIXED 格式在 binlog 中记录了STATEMENTROW格式的记录。尽管以后可以筛选出只有ROW数据,但除非 binlog 用于其他目的,否则没有必要启用MIXED,因为它会占用额外的磁盘空间。

您可以通过运行以下 SQL 查询来验证当前的 binlog 格式:

SELECT variable_value as bin_log_format
FROM performance_schema.global_variables
WHERE variable_name='binlog_format';

该语句将返回当前活动的格式:

+ — — — — — — — — — — — — — — — — — — — -+
| bin_log_format :: |
+ — — — — — — — — — — — — — — — — — — — -+
| ROW |
+ — — — — — — — — — — — — — — — — — — — -+
1 row in set (0.00 sec)

binlog 格式以及其他配置设置通常在特定于 MySQL 数据库实例的 my.cnf 文件中设置。如果打开文件,您会看到包括以下内容的行:

[mysqld]
binlog_format=row
........

再次强调,在修改任何配置之前,最好先与 MySQL 数据库的所有者或最新的 MySQL 文档进行咨询。

现在二进制日志以ROW格式启用,您可以构建一个流程来从中提取相关信息,并将其存储在一个文件中,以便加载到您的数据仓库中。

有三种不同类型的ROW格式事件,您希望从 binlog 中提取。在此摄取示例中,您可以忽略日志中找到的其他事件,但在更高级的复制策略中,提取修改表结构的事件也是有价值的。您将处理的事件如下:

  • WRITE_ROWS_EVENT

  • UPDATE_ROWS_EVENT

  • DELETE_ROWS_EVENT

紧接着,是时候从 binlog 获取事件了。幸运的是,有一些开源的 Python 库可以帮助你入门。其中最流行的之一是python-mysql-replication项目,可以在GitHub上找到。要开始使用,可以使用pip安装它:

(env) $ pip install mysql-replication

要了解 binlog 输出的样子,可以连接到数据库并从 binlog 中读取。在这个例子中,我将使用早期在本节中添加到pipeline.conf文件中的 MySQL 连接信息,作为完整和增量摄取示例。

注意

以下示例从 MySQL 服务器的默认 binlog 文件中读取。默认的 binlog 文件名和路径在 MySQL 数据库的my.cnf文件中通过log_bin变量设置。在某些情况下,binlog 会随时间旋转(可能是每天或每小时)。如果是这样,您将需要根据 MySQL 管理员选择的日志旋转方法和文件命名方案确定文件路径,并在创建BinLogStreamReader实例时将其作为log_file参数的值传递。有关更多信息,请参阅BinLogStreamReader 类的文档

from pymysqlreplication import BinLogStreamReader
from pymysqlreplication import row_event
import configparser
import pymysqlreplication

# get the MySQL connection info
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
hostname = parser.get("mysql_config", "hostname")
port = parser.get("mysql_config", "port")
username = parser.get("mysql_config", "username")
password = parser.get("mysql_config", "password")

mysql_settings = {
    "host": hostname,
    "port": int(port),
    "user": username,
    "passwd": password
}

b_stream = BinLogStreamReader(
            connection_settings = mysql_settings,
            server_id=100,
            only_events=[row_event.DeleteRowsEvent,
                        row_event.WriteRowsEvent,
                        row_event.UpdateRowsEvent]
            )

for event in b_stream:
    event.dump()

b_stream.close()

在代码示例中实例化的BinLogStreamReader对象有几个需要注意的地方。首先,它连接到pipeline.conf文件中指定的 MySQL 数据库,并从特定的 binlog 文件中读取。接下来,通过resume_stream=True设置和log_pos值的组合告诉它从指定点开始读取 binlog。在这种情况下,位置是 1400。最后,我告诉BinLogStreamReader只读取DeleteRowsEventWriteRowsEventUpdateRowsEvent事件,使用only_events参数。

接下来,脚本会迭代所有事件,并以人类可读的格式打印它们。对于你的包含Orders表的数据库,输出将类似于这样:

=== WriteRowsEvent ===
Date: 2020-06-01 12:00:00
Log position: 1400
Event size: 30
Read bytes: 20
Table: orders
Affected columns: 3
Changed rows: 1
Values:
--
* OrderId : 1
* OrderStatus : Backordered
* LastUpdated : 2020-06-01 12:00:00

=== UpdateRowsEvent ===
Date: 2020-06-09 12:00:25
Log position: 1401
Event size: 56
Read bytes: 15
Table: orders
Affected columns: 3
Changed rows: 1
Affected columns: 3
Values:
--
* OrderId : 1 => 1
* OrderStatus : Backordered => Shipped
* LastUpdated : 2020-06-01 12:00:00 => 2020-06-09 12:00:25

正如你所见,有两个事件代表了OrderId为 1 的INSERTUPDATE,这在 Table 4-3 中有显示。在这个虚构的例子中,这两个连续的 binlog 事件相隔几天,但实际上之间会有许多事件,代表数据库中的所有更改。

注意

告诉BinLogStreamReader从何处开始的log_pos值是一个你需要在自己的表中存储的值,以便在下一次提取时从该位置开始。我发现最好将该值存储在数据仓库中的日志表中,在提取开始时读取该值,并在提取完成时写入最终事件的位置值。

尽管代码示例显示了事件在人类可读的格式中的样子,为了方便加载到数据仓库中,还需要做一些额外的工作:

  • 解析并以不同格式写入数据。为了简化加载,下一个代码示例将每个事件写入 CSV 文件的一行。

  • 每个想要提取和加载的表都要写一个文件。尽管示例 binlog 仅包含与Orders表相关的事件,但在实际的 binlog 中,很可能也包含与其他表相关的事件。

为了解决第一个变更,我将不再使用.dump()函数,而是解析事件属性并将其写入 CSV 文件。至于第二个变更,为了简化起见,我只会将与Orders表相关的事件写入名为orders_extract.csv的文件中。在完全实施的提取过程中,您可以修改此代码示例,将事件按表分组,并写入多个文件,每个表一个文件。最后一个代码示例的最后一步将 CSV 文件上传到 S3 存储桶中,以便加载到数据仓库中,详细描述请参阅第五章。

from pymysqlreplication import BinLogStreamReader
from pymysqlreplication import row_event
import configparser
import pymysqlreplication
import csv
import boto3

# get the MySQL connection info
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
hostname = parser.get("mysql_config", "hostname")
port = parser.get("mysql_config", "port")
username = parser.get("mysql_config", "username")
password = parser.get("mysql_config", "password")

mysql_settings = {
    "host": hostname,
    "port": int(port),
    "user": username,
    "passwd": password
}

b_stream = BinLogStreamReader(
            connection_settings = mysql_settings,
            server_id=100,
            only_events=[row_event.DeleteRowsEvent,
                        row_event.WriteRowsEvent,
                        row_event.UpdateRowsEvent]
            )

order_events = []

for binlogevent in b_stream:
  for row in binlogevent.rows:
    if binlogevent.table == 'orders':
      event = {}
      if isinstance(
            binlogevent,row_event.DeleteRowsEvent
        ):
        event["action"] = "delete"
        event.update(row["values"].items())
      elif isinstance(
            binlogevent,row_event.UpdateRowsEvent
        ):
        event["action"] = "update"
        event.update(row["after_values"].items())
      elif isinstance(
            binlogevent,row_event.WriteRowsEvent
        ):
        event["action"] = "insert"
        event.update(row["values"].items())

      order_events.append(event)

b_stream.close()

keys = order_events[0].keys()
local_filename = 'orders_extract.csv'
with open(
        local_filename,
        'w',
        newline='') as output_file:
    dict_writer = csv.DictWriter(
                output_file, keys,delimiter='|')
    dict_writer.writerows(order_events)

# load the aws_boto_credentials values
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
access_key = parser.get(
                "aws_boto_credentials",
                "access_key")
secret_key = parser.get(
                "aws_boto_credentials",
                "secret_key")
bucket_name = parser.get(
                "aws_boto_credentials",
                "bucket_name")

s3 = boto3.client(
    's3',
    aws_access_key_id=access_key,
    aws_secret_access_key=secret_key)

s3_file = local_filename

s3.upload_file(
    local_filename,
    bucket_name,
    s3_file)

执行后,orders_extract.csv将如下所示:

insert|1|Backordered|2020-06-01 12:00:00
update|1|Shipped|2020-06-09 12:00:25

正如我在第五章中讨论的那样,生成的 CSV 文件格式经过优化,以便快速加载。解析提取出的数据是管道中的转换步骤的任务,在第六章中详细审查。

从 PostgreSQL 数据库中提取数据

就像 MySQL 一样,从 PostgreSQL(通常称为 Postgres)数据库中提取数据可以通过两种方式之一完成:使用 SQL 进行全量或增量抽取,或者利用数据库支持的复制功能将数据复制到其他节点。在 Postgres 的情况下,有几种方法可以实现这一点,但本章节将重点介绍一种方法:将 Postgres 的预写式日志(WAL)转换为数据流。

与前一节类似,本节适用于需要从现有的 Postgres 数据库中提取数据的人群。但是,如果您只想尝试代码示例,可以通过在本地安装Postgres 或使用RDS 实例在 AWS 上安装 Postgres,我推荐使用。有关 RDS MySQL 的定价和与安全相关的最佳实践的注意事项,请参阅前一节,因为它们同样适用于 RDS Postgres。

本节中的代码示例非常简单,涉及到 Postgres 数据库中名为Orders的表。一旦您有一个可用的 Postgres 实例,可以通过运行以下 SQL 命令创建表并插入一些示例行:

CREATE TABLE Orders (
  OrderId int,
  OrderStatus varchar(30),
  LastUpdated timestamp
);

INSERT INTO Orders
  VALUES(1,'Backordered', '2020-06-01 12:00:00');
INSERT INTO Orders
  VALUES(1,'Shipped', '2020-06-09 12:00:25');
INSERT INTO Orders
  VALUES(2,'Shipped', '2020-07-11 3:05:00');
INSERT INTO Orders
  VALUES(1,'Shipped', '2020-06-09 11:50:00');
INSERT INTO Orders
  VALUES(3,'Shipped', '2020-07-12 12:00:00');

全量或增量 Postgres 表抽取

这种方法与从 MySQL 数据库中提取数据的全量和增量抽取方法相似,详见“从 MySQL 数据库中提取数据”。它们如此相似,我在这里不会详细介绍,只提到代码中的一个差异。与前述示例类似,此示例将从源数据库中名为Orders的表中提取数据,将其写入 CSV 文件,然后将其上传到 S3 存储桶中。

这一部分唯一的区别在于我将使用的 Python 库来提取数据。与PyMySQL不同,我将使用pyscopg2连接到 Postgres 数据库。如果你尚未安装它,可以使用pip安装:

(env) $ pip install pyscopg2

你还需要向pipeline.conf文件添加一个新的部分,包含 Postgres 数据库的连接信息:

[postgres_config]
host = myhost.com
port = 5432
username = my_username
password = my_password
database = db_name

运行完整提取Orders表的代码与 MySQL 部分的示例几乎完全相同,但是你可以看到,它使用pyscopg2连接到源数据库并运行查询。以下是完整的代码:

import psycopg2
import csv
import boto3
import configparser

parser = configparser.ConfigParser()
parser.read("pipeline.conf")
dbname = parser.get("postgres_config", "database")
user = parser.get("postgres_config", "username")
password = parser.get("postgres_config",
    "password")
host = parser.get("postgres_config", "host")
port = parser.get("postgres_config", "port")

conn = psycopg2.connect(
        "dbname=" + dbname
        + " user=" + user
        + " password=" + password
        + " host=" + host,
        port = port)

m_query = "SELECT * FROM Orders;"
local_filename = "order_extract.csv"

m_cursor = conn.cursor()
m_cursor.execute(m_query)
results = m_cursor.fetchall()

with open(local_filename, 'w') as fp:
  csv_w = csv.writer(fp, delimiter='|')
  csv_w.writerows(results)

fp.close()
m_cursor.close()
conn.close()

# load the aws_boto_credentials values
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
access_key = parser.get(
                "aws_boto_credentials",
                "access_key")
secret_key = parser.get(
                "aws_boto_credentials",
                "secret_key")
bucket_name = parser.get(
                "aws_boto_credentials",
                "bucket_name")

s3 = boto3.client(
      's3',
      aws_access_key_id = access_key,
      aws_secret_access_key = secret_key)

s3_file = local_filename

s3.upload_file(
    local_filename,
    bucket_name,
    s3_file)

修改 MySQL 部分中显示的增量版本同样简单。你只需使用psycopg2而不是PyMySQL即可。

使用预写式日志复制数据

与 MySQL 二进制日志(如前一节所讨论的)类似,Postgres WAL 可用作 CDC 的一种方法。同样地,使用 WAL 在管道中进行数据摄取是非常复杂的。

尽管你可以采用类似简化的方法来处理 MySQL 二进制日志示例,但我建议使用一个名为 Debezium 的开源分布式平台来流式传输 Postgres WAL 的内容到 S3 存储桶或数据仓库。

尽管配置和运行 Debezium 服务的具体细节值得专门一本书来讨论,我在“使用 Kafka 和 Debezium 进行流数据摄取”中概述了 Debezium 的使用及其在 Postgres CDC 中的应用。你可以在那里了解更多关于如何在 Postgres CDC 中使用它的信息。

从 MongoDB 提取数据

本示例说明了如何从集合中提取 MongoDB 文档的子集。在这个示例的 MongoDB 集合中,文档代表从某些系统(如 Web 服务器)记录的事件。每个文档都有一个创建时的时间戳,以及一些属性,示例代码提取了其中的一个子集。提取完成后,数据将被写入 CSV 文件并存储在 S3 存储桶中,以便在后续步骤中加载到数据仓库中(参见第五章)。

要连接到 MongoDB 数据库,你首先需要安装 PyMongo 库。与其他 Python 库一样,你可以使用pip来安装它:

(env) $ pip install pymongo

当然,你可以修改以下示例代码以连接到自己的 MongoDB 实例并从文档中提取数据。但是,如果你想按原样运行示例,可以通过 MongoDB Atlas 免费创建一个 MongoDB 集群。Atlas 是一个完全托管的 MongoDB 服务,包括一个免费的终身套件,提供了足够的存储空间和计算能力,适用于学习和运行我提供的示例。你也可以升级到付费计划用于生产部署。

您可以按照这些说明学习如何在 Atlas 中创建一个免费的 MongoDB 集群,创建一个数据库,并进行配置,以便您可以通过在本地机器上运行的 Python 脚本进行连接。

您需要安装一个名为dnspython的 Python 库,以支持pymongo连接到 MongoDB Atlas 中托管的集群。您可以使用pip安装它:

(env) $ pip install dnspython

接下来,在pipeline.conf文件中添加一个新的部分,包含用于从 MongoDB 实例中提取数据的连接信息。请填写每一行中的自己的连接细节。如果您使用的是 MongoDB Atlas,并且无法从设置集群时记起这些值,您可以阅读Atlas 文档来了解如何找到它们。

[mongo_config]
hostname = my_host.com
username = mongo_user
password = mongo_password
database = my_database
collection = my_collection

在创建和运行提取脚本之前,您可以插入一些样本数据以进行工作。创建一个名为sample_mongodb.py的文件,并添加以下代码:

from pymongo import MongoClient
import datetime
import configparser

# load the mongo_config values
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
hostname = parser.get("mongo_config", "hostname")
username = parser.get("mongo_config", "username")
password = parser.get("mongo_config", "password")
database_name = parser.get("mongo_config",
                    "database")
collection_name = parser.get("mongo_config",
                    "collection")

mongo_client = MongoClient(
                "mongodb+srv://" + username
                + ":" + password
                + "@" + hostname
                + "/" + database_name
                + "?retryWrites=true&"
                + "w=majority&ssl=true&"
                + "ssl_cert_reqs=CERT_NONE")

# connect to the db where the collection resides
mongo_db = mongo_client[database_name]

# choose the collection to query documents from
mongo_collection = mongo_db[collection_name]

event_1 = {
  "event_id": 1,
  "event_timestamp": datetime.datetime.today(),
  "event_name": "signup"
}

event_2 = {
  "event_id": 2,
  "event_timestamp": datetime.datetime.today(),
  "event_name": "pageview"
}

event_3 = {
  "event_id": 3,
  "event_timestamp": datetime.datetime.today(),
  "event_name": "login"
}

# insert the 3 documents
mongo_collection.insert_one(event_1)
mongo_collection.insert_one(event_2)
mongo_collection.insert_one(event_3)

在执行时,这三个文档将被插入到您的 MongoDB 集合中:

(env) $ python sample_mongodb.py

现在创建一个名为mongo_extract.py的新 Python 脚本,以便您可以在其中添加以下代码块。

首先,导入 PyMongo 和 Boto3,以便您可以从 MongoDB 数据库中提取数据,并将结果存储在 S3 存储桶中。还导入csv库,以便您可以在摄取过程的加载步骤中将提取的数据结构化并写入一个易于导入到数据仓库的扁平文件中。最后,您还需要一些datetime函数,以便您可以在 MongoDB 集合中的示例事件数据中进行迭代:

from pymongo import MongoClient
import csv
import boto3
import datetime
from datetime import timedelta
import configparser

接下来,连接到pipelines.conf文件中指定的 MongoDB 实例,并创建一个collection对象,其中存储了您想要提取的文档:

# load the mongo_config values
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
hostname = parser.get("mongo_config", "hostname")
username = parser.get("mongo_config", "username")
password = parser.get("mongo_config", "password")
database_name = parser.get("mongo_config",
                    "database")
collection_name = parser.get("mongo_config",
                    "collection")

mongo_client = MongoClient(
                "mongodb+srv://" + username
                + ":" + password
                + "@" + hostname
                + "/" + database_name
                + "?retryWrites=true&"
                + "w=majority&ssl=true&"
                + "ssl_cert_reqs=CERT_NONE")

# connect to the db where the collection resides
mongo_db = mongo_client[database_name]

# choose the collection to query documents from
mongo_collection = mongo_db[collection_name]

现在是查询要提取的文档的时候了。您可以通过在mongo_collection上调用.find()函数来执行此操作,以查询您正在寻找的文档。在下面的示例中,您将获取所有具有脚本中定义的两个日期之间的event_timestamp字段值的文档。

注意

从数据存储中按日期范围提取不可变数据,例如日志记录或通用的“事件”记录,是一个常见的用例。尽管示例代码使用脚本中定义的日期时间范围,但更有可能的是,您将在脚本中传递日期时间范围,或者让脚本查询数据仓库以获取最后加载事件的日期时间,并从源数据存储中提取后续记录。请参阅“从 MySQL 数据库中提取数据”来了解如何执行此操作的示例。

start_date = datetime.datetime.today() + timedelta(days = -1)
end_date = start_date + timedelta(days = 1 )

mongo_query = { "$and":[{"event_timestamp" : { "$gte": start_date }}, {"event_timestamp" : { "$lt": end_date }}] }

event_docs = mongo_collection.find(mongo_query, batch_size=3000)
注意

在这个例子中,batch_size 参数设置为 3000。PyMongo 每个批次都会与 MongoDB 主机进行一次往返。例如,如果 result_docs 游标有 6,000 个结果,将需要两次与 MongoDB 主机的往返,以将所有文档拉取到运行 Python 脚本的机器上。将批次大小值设为多少取决于您,在提取过程中在内存中存储更多文档与进行多次与 MongoDB 实例的往返之间的权衡。

上述代码的结果是名为 event_docs 游标,我将用它来迭代结果文档。请记住,在这个简化的例子中,每个文档表示从诸如 Web 服务器之类的系统生成的事件。一个事件可能代表用户登录、查看页面或提交反馈表单等活动。尽管文档可能有几十个字段来表示诸如用户登录时使用的浏览器之类的信息,但在这个例子中,我只选择了几个字段:

# create a blank list to store the results
all_events = []

# iterate through the cursor
for doc in event_docs:
    # Include default values
    event_id = str(doc.get("event_id", -1))
    event_timestamp = doc.get(
                        "event_timestamp", None)
    event_name = doc.get("event_name", None)

    # add all the event properties into a list
    current_event = []
    current_event.append(event_id)
    current_event.append(event_timestamp)
    current_event.append(event_name)

    # add the event to the final list of events
    all_events.append(current_event)

doc.get() 函数调用中包含了一个默认值(-1 或 None)。为什么这样做?非结构化文档数据的特性意味着文档中的字段可能会完全丢失。换句话说,您不能假设您迭代的每个文档都有“event_name”或任何其他字段。在这些情况下,告诉 doc.get() 返回一个 None 值,而不是抛出错误。

event_docs 中迭代所有事件后,all_events 列表已准备好写入 CSV 文件。为此,您将使用标准 Python 分发中包含的 csv 模块,此模块已在此示例中导入:

export_file = "export_file.csv"

with open(export_file, 'w') as fp:
	csvw = csv.writer(fp, delimiter='|')
	csvw.writerows(all_events)

fp.close()

现在,上传 CSV 文件到您在“设置云文件存储”中配置的 S3 存储桶。为此,使用 Boto3 库:

# load the aws_boto_credentials values
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
access_key = parser.get("aws_boto_credentials",
                "access_key")
secret_key = parser.get("aws_boto_credentials",
                "secret_key")
bucket_name = parser.get("aws_boto_credentials",
                "bucket_name")

s3 = boto3.client('s3',
        aws_access_key_id=access_key,
        aws_secret_access_key=secret_key)

s3_file = export_file

s3.upload_file(export_file, bucket_name, s3_file)

就这样!从 MongoDB 集合中提取的数据现在已经位于 S3 存储桶中,等待加载到数据仓库或其他数据存储中。如果您使用了提供的示例数据,export_file.csv 文件的内容将类似于以下内容:

1|2020-12-13 11:01:37.942000|signup
2|2020-12-13 11:01:37.942000|pageview
3|2020-12-13 11:01:37.942000|login

参见第五章,了解更多将数据加载到您选择的数据存储中的信息。

从 REST API 中提取数据

REST API 是常见的数据提取来源。您可能需要从您的组织创建和维护的 API,或者从您的组织使用的外部服务或供应商(如 Salesforce、HubSpot 或 Twitter)的 API 中摄取数据。无论是哪种 API,数据提取的常见模式如下所示,我将在接下来的简单示例中使用:

  1. 向 API 端点发送 HTTP GET 请求。

  2. 接受响应,这些响应大多数情况下是以 JSON 格式进行格式化的。

  3. 解析响应并将其“展平”成一个后续可以加载到数据仓库的 CSV 文件。

注意

尽管我正在解析 JSON 响应并将其存储在平面文件(CSV)中,但您也可以将数据保存为 JSON 格式以加载到数据仓库中。为简单起见,我坚持使用 CSV 文件的模式。请参阅第五章或您的数据仓库文档,了解如何加载其他格式的数据更多信息。

在这个示例中,我将连接到一个名为 Open Notify 的 API。该 API 有几个端点,每个端点返回有关太空活动的 NASA 数据。我将查询返回给定地球位置上国际空间站(ISS)将经过的下五次时间的端点。

在分享用于查询端点的 Python 代码之前,您可以通过将以下 URL 键入浏览器来查看简单查询输出的样子:

http://api.open-notify.org/iss-pass.json?lat=42.36&lon=71.05

结果的 JSON 如下所示:

{
  "message": "success",
  "request": {
    "altitude": 100,
    "datetime": 1596384217,
    "latitude": 42.36,
    "longitude": 71.05,
    "passes": 5
  },
  "response": [
    {
      "duration": 623,
      "risetime": 1596384449
    },
    {
      "duration": 169,
      "risetime": 1596390428
    },
    {
      "duration": 482,
      "risetime": 1596438949
    },
    {
      "duration": 652,
      "risetime": 1596444637
    },
    {
      "duration": 624,
      "risetime": 1596450474
    }
  ]
}

此提取的目标是从响应中检索数据,并将其格式化为 CSV 文件,每行描述 ISS 经过的时间和持续时间。例如,CSV 文件的前两行将如下所示:

42.36,|71.05|623|1596384449
42.36,|71.05|169|1596390428

要在 Python 中查询 API 并处理响应,您需要安装requests库。requests使 Python 中的 HTTP 请求和响应操作变得简单。您可以使用pip安装它:

(env) $ pip install requests

现在,您可以使用requests查询 API 端点,获取响应并打印出结果 JSON 的样子,它看起来与您在浏览器中看到的类似:

import requests

lat = 42.36
lon = 71.05
lat_log_params = {"lat": lat, "lon": lon}

api_response = requests.get(
    "http://api.open-notify.org/iss-pass.json", params=lat_log_params)

print(api_response.content)

不再打印 JSON,而是迭代响应,解析出持续时间和经过时间的值,将结果写入 CSV 文件,并将文件上传到 S3 存储桶。

要解析 JSON 响应,我将导入 Python 的json库。无需安装它,因为它随标准 Python 安装而来。接下来,我将导入csv库,这也包含在标准 Python 发行版中,用于编写 CSV 文件。最后,我将使用configparser库获取 Boto3 库上传 CSV 文件到 S3 存储桶所需的凭据:

import requests
import json
import configparser
import csv
import boto3

接下来,像以前一样查询 API:

lat = 42.36
lon = 71.05
lat_log_params = {"lat": lat, "lon": lon}

api_response = requests.get(
    "http://api.open-notify.org/iss-pass.json", params=lat_log_params)

现在,是时候迭代响应,将结果存储在名为all_passes的 Python 列表中,并将结果保存到 CSV 文件中。请注意,尽管它们未包含在响应中,但我也会存储来自请求的纬度和经度。在加载到数据仓库时,这些信息在每行 CSV 文件中都是必需的,以便将经过时间与正确的纬度和经度关联起来:

# create a json object from the response content
response_json = json.loads(api_response.content)

all_passes = []
for response in response_json['response']:
    current_pass = []

    #store the lat/log from the request
    current_pass.append(lat)
    current_pass.append(lon)

    # store the duration and risetime of the pass
    current_pass.append(response['duration'])
    current_pass.append(response['risetime'])

    all_passes.append(current_pass)

export_file = "export_file.csv"

with open(export_file, 'w') as fp:
	csvw = csv.writer(fp, delimiter='|')
	csvw.writerows(all_passes)

fp.close()

最后,使用 Boto3 库将 CSV 文件上传到 S3 存储桶:

# load the aws_boto_credentials values
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
access_key = parser.get("aws_boto_credentials",
                "access_key")
secret_key = parser.get("aws_boto_credentials",
                "secret_key")
bucket_name = parser.get("aws_boto_credentials",
                "bucket_name")

s3 = boto3.client(
    's3',
    aws_access_key_id=access_key,
    aws_secret_access_key=secret_key)

s3.upload_file(
    export_file,
    bucket_name,
    export_file)

使用 Kafka 和 Debezium 进行流式数据接入

当涉及从 CDC 系统(如 MySQL binlog 或 Postgres WAL)摄取数据时,没有简单的解决方案,除非借助一个很棒的框架。

Debezium 是一个分布式系统,由几个开源服务组成,捕获常见 CDC 系统中的行级变更,然后将它们作为可被其他系统消费的事件流。Debezium 安装的三个主要组件包括:

  • Apache Zookeeper 管理分布式环境并处理每个服务的配置。

  • Apache Kafka 是一个分布式流处理平台,通常用于构建高度可扩展的数据流水线。

  • Apache Kafka Connect 是一个工具,用于连接 Kafka 与其他系统,以便通过 Kafka 轻松地流式传输数据。连接器针对诸如 MySQL 和 Postgres 等系统构建,将来自它们的 CDC 系统(binlog 和 WAL)的数据转换为 Kafka topics

Kafka 通过 topic 交换组织的消息。一个系统可能发布到一个主题,而一个或多个系统可能订阅该主题。

Debezium 将这些系统连接在一起,并包括常见 CDC 实现的连接器。例如,我讨论了在 “从 MySQL 数据库中提取数据” 和 “从 PostgreSQL 数据库中提取数据” 中 CDC 的挑战。幸运的是,已经建立了可以“监听”MySQL binlog 和 Postgres WAL 的连接器。然后,数据作为主题中的记录通过 Kafka 路由,并通过另一个连接器消耗到目的地,如 S3 存储桶、Snowflake 或 Redshift 数据仓库。图 4-1 演示了使用 Debezium 及其各个组件将 MySQL binlog 生成的事件发送到 Snowflake 数据仓库的示例。

dppr 0401

图 4-1. 使用 Debezium 组件从 MySQL 向 Snowflake 进行 CDC。

据我所知,已经为您可能需要从中摄取数据的源系统建立了多个 Debezium 连接器:

  • MongoDB

  • MySQL

  • PostgreSQL

  • Microsoft SQL Server

  • Oracle

  • Db2

  • Cassandra

还有 Kafka Connect 连接器适用于最常见的数据仓库和存储系统,如 S3 和 Snowflake。

尽管 Debezium 本身及 Kafka 本身是一个值得撰写专著的主题,但我确实希望指出,如果您决定使用 CDC 作为数据摄入的方法,它们的价值是显而易见的。本章 MySQL 提取部分中使用的简单示例是功能性的;然而,如果您希望大规模使用 CDC,我强烈建议使用像 Debezium 这样的现有平台,而不是自行构建一个现有平台!

提示

Debezium 文档 极好,是学习该系统的绝佳起点。

第五章:数据摄取:加载数据

在第四章中,您从所需的源系统提取了数据。现在是通过将数据加载到 Redshift 数据仓库来完成数据摄取的时候了。如何加载取决于数据提取的输出。在本节中,我将描述如何将提取出的数据加载到 CSV 文件中,其中值对应于表中的每列,以及包含 CDC 格式数据的提取输出。

配置 Amazon Redshift 数据仓库作为目的地

如果您正在使用 Amazon Redshift 作为数据仓库,那么在提取数据后使用 S3 加载数据的集成就非常简单。第一步是为加载数据创建 IAM 角色(如果还没有)。

注意

要了解如何设置 Amazon Redshift 集群,请查看最新的文档和定价信息,包括免费试用

要创建角色,请按照以下说明操作,或查看AWS 文档获取最新详情:

  1. 在 AWS 控制台的服务菜单(或顶部导航栏)下,导航到 IAM。

  2. 在左侧导航菜单中,选择“角色”,然后点击“创建角色”按钮。

  3. 将显示一个 AWS 服务列表供您选择。找到并选择 Redshift。

  4. 在“选择您的用例”下,选择 Redshift – Customizable。

  5. 在下一页(附加权限策略)上,搜索并选择 AmazonS3ReadOnlyAccess,然后点击“下一步”。

  6. 给您的角色命名(例如,“RedshiftLoadRole”),然后点击“创建角色”。

  7. 点击新角色的名称,并复制角色 Amazon 资源名称(ARN),以便您可以在本章后面使用。您还可以在 IAM 控制台的角色属性下找到它。ARN 的格式如下:arn:aws:iam::*<aws-account-id>*:role/*<role-name>*

现在,您可以将刚创建的 IAM 角色与您的 Redshift 集群关联起来。要执行此操作,请按照以下步骤操作,或查看Redshift 文档获取更多详细信息。

注意

您的集群将花费一到两分钟来应用这些更改,但在此期间仍然可以访问它。

  1. 返回 AWS 服务菜单,然后转到 Amazon Redshift。

  2. 在导航菜单中,选择“集群”,然后选择要加载数据的集群。

  3. 在操作下,点击“管理 IAM 角色”。

  4. 加载后会显示“管理 IAM 角色”页面,您可以在“可用角色”下拉菜单中选择您的角色。然后点击“添加 IAM 角色”。

  5. 点击完成。

最后,在您创建的 pipeline.conf 文件中添加另一部分,包括您的 Redshift 凭据和刚创建的 IAM 角色名称。您可以在 AWS Redshift 控制台页面上找到 Redshift 集群连接信息:

[aws_creds]
database = my_warehouse
username = pipeline_user
password = weifj4tji4j
host = my_example.4754875843.us-east-1.redshift.amazonaws.com
port = 5439
iam_role = RedshiftLoadRole

将数据加载到 Redshift 数据仓库

将从 S3 存储为 CSV 文件中每列对应于 Redshift 表中每列的值提取和存储的数据加载到 Redshift 相对简单。 这种格式的数据最常见,是从诸如 MySQL 或 MongoDB 数据库之类的源中提取数据的结果。 要加载到目标 Redshift 表中的每个 CSV 文件中的行对应于要加载到目标表中的记录,CSV 中的每列对应于目标表中的列。 如果您从 MySQL binlog 或其他 CDC 日志中提取了事件,请参阅下一节有关加载说明。

将数据从 S3 加载到 Redshift 的最有效方法是使用COPY命令。 COPY可以作为 SQL 语句在查询 Redshift 集群的 SQL 客户端中执行,或者在使用 Boto3 库的 Python 脚本中执行。 COPY将加载的数据追加到目标表的现有行中。

COPY命令的语法如下。 所有方括号([])项都是可选的:

COPY table_name
[ column_list ]
FROM source_file
authorization
[ [ FORMAT ] [ AS ] data_format ]
[ parameter [ argument ] [, .. ] ]
注意

您可以在AWS 文档中了解更多有关附加选项和COPY命令的一般信息。

在最简单的形式中,使用 IAM 角色授权如第四章中指定的,并且从 SQL 客户端运行时,S3 存储桶中的文件看起来像这样:

COPY my_schema.my_table
FROM 's3://bucket-name/file.csv’
iam_role ‘<my-arn>’;

正如您从“配置 Amazon Redshift Warehouse 作为目标”中回想起的那样,ARN 的格式如下:

arn:aws:iam::<aws-account-id>:role/<role-name>

如果您将角色命名为RedshiftLoadRole,则COPY命令语法如下所示。 请注意,ARN 中的数字值特定于您的 AWS 帐户:

COPY my_schema.my_table
FROM 's3://bucket-name/file.csv’
iam_role 'arn:aws:iam::222:role/RedshiftLoadRole’;

当执行时,file.csv的内容将追加到 Redshift 集群中my_schema模式下名为my_table的表中。

默认情况下,COPY命令将数据插入到目标表的列中,顺序与输入文件中字段的顺序相同。 换句话说,除非另有指定,否则您在此示例中加载的 CSV 中的字段顺序应与 Redshift 目标表中列的顺序匹配。 如果您想指定列顺序,可以通过按与输入文件匹配的顺序添加目标列的名称来执行此操作,如下所示:

COPY my_schema.my_table (column_1, column_2, ....)
FROM 's3://bucket-name/file.csv'
iam_role 'arn:aws:iam::222:role/RedshiftLoadRole';

也可以使用 Boto3 库在 Python 脚本中实现COPY命令。 实际上,按照第四章中数据提取示例的模板,通过 Python 加载数据可以创建更标准化的数据流水线。

要与本章早期配置的 Redshift 集群进行交互,需要安装psycopg2库:

(env) $ pip install psycopg2

现在可以开始编写 Python 脚本。 创建一个名为copy_to_redshift.py的新文件,并添加以下三个代码块。

第一步是导入boto3以与 S3 存储桶交互,psycopg2以在 Redshift 集群上运行COPY命令,以及configparser库以读取pipeline.conf文件:

import boto3
import configparser
import psycopg2

接下来,使用psycopg2.connect函数和存储在pipeline.conf文件中的凭据连接到 Redshift 集群:

parser = configparser.ConfigParser()
parser.read("pipeline.conf")
dbname = parser.get("aws_creds", "database")
user = parser.get("aws_creds", "username")
password = parser.get("aws_creds", "password")
host = parser.get("aws_creds", "host")
port = parser.get("aws_creds", "port")

# connect to the redshift cluster
rs_conn = psycopg2.connect(
    "dbname=" + dbname
    + " user=" + user
    + " password=" + password
    + " host=" + host
    + " port=" + port)

现在,你可以使用psycopg2Cursor对象执行COPY命令。运行与本节中手动运行的COPY命令相同的COPY命令,但是不要直接编码 AWS 账户 ID 和 IAM 角色名称,而是从pipeline.conf文件中加载这些值:

# load the account_id and iam_role from the
# conf files
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
account_id = parser.get("aws_boto_credentials",
              "account_id")
iam_role = parser.get("aws_creds", "iam_role")
bucket_name = parser.get("aws_boto_credentials",
              "bucket_name")

# run the COPY command to load the file into Redshift
file_path = ("s3://"
    + bucket_name
    + "/order_extract.csv")
role_string = ("arn:aws:iam::"
    + account_id
    + ":role/" + iam_role)

sql = "COPY public.Orders"
sql = sql + " from %s "
sql = sql + " iam_role %s;"

# create a cursor object and execute the COPY
cur = rs_conn.cursor()
cur.execute(sql,(file_path, role_string))

# close the cursor and commit the transaction
cur.close()
rs_conn.commit()

# close the connection
rs_conn.close()

在运行脚本之前,如果目标表还不存在,你需要先创建它。在本例中,我正在加载从“完整或增量 MySQL 表抽取”中提取出的数据,该数据保存在order_extract.csv文件中。当然,你可以加载任何你想要的数据。只需确保目标表的结构匹配即可。要在你的集群上创建目标表,请通过 Redshift 查询编辑器或其他连接到你的集群的应用程序运行以下 SQL:

CREATE TABLE public.Orders (
  OrderId int,
  OrderStatus varchar(30),
  LastUpdated timestamp
);

最后,按以下步骤运行脚本:

(env) $ python copy_to_redshift.py

增量加载与完整加载对比

在前面的代码示例中,COPY命令从提取的 CSV 文件直接加载数据到 Redshift 集群中的表中。如果 CSV 文件中的数据来自于不可变源的增量抽取(例如不可变事件数据或其他“仅插入”数据集),那么无需进行其他操作。然而,如果 CSV 文件中的数据包含更新的记录以及插入或源表的全部内容,则需要做更多工作,或者至少需要考虑一些因素。

以“完整或增量 MySQL 表抽取”中的Orders表为例。这意味着你从 CSV 文件中加载的数据可能是从源 MySQL 表中完整或增量提取出来的。

如果数据是完整提取的,那么在运行COPY操作之前,需要对 Redshift 中的目标表进行截断(使用 TRUNCATE)。更新后的代码片段如下所示:

import boto3
import configparser
import psycopg2

parser = configparser.ConfigParser()
parser.read("pipeline.conf")
dbname = parser.get("aws_creds", "database")
user = parser.get("aws_creds", "username")
password = parser.get("aws_creds", "password")
host = parser.get("aws_creds", "host")
port = parser.get("aws_creds", "port")

# connect to the redshift cluster
rs_conn = psycopg2.connect(
    "dbname=" + dbname
    + " user=" + user
    + " password=" + password
    + " host=" + host
    + " port=" + port)

parser = configparser.ConfigParser()
parser.read("pipeline.conf")
account_id = parser.get("aws_boto_credentials",
                  "account_id")
iam_role = parser.get("aws_creds", "iam_role")
bucket_name = parser.get("aws_boto_credentials",
                  "bucket_name")

# truncate the destination table
sql = "TRUNCATE public.Orders;"
cur = rs_conn.cursor()
cur.execute(sql)

cur.close()
rs_conn.commit()

# run the COPY command to load the file into Redshift
file_path = ("s3://"
    + bucket_name
    + "/order_extract.csv")
role_string = ("arn:aws:iam::"
    + account_id
    + ":role/" + iam_role)

sql = "COPY public.Orders"
sql = sql + " from %s "
sql = sql + " iam_role %s;"

# create a cursor object and execute the COPY command
cur = rs_conn.cursor()
cur.execute(sql,(file_path, role_string))

# close the cursor and commit the transaction
cur.close()
rs_conn.commit()

# close the connection
rs_conn.close()

如果数据是增量提取的,则不应该对目标表进行截断。如果截断了,那么最后一次运行抽取作业后剩下的只是更新的记录。有几种方法可以处理以这种方式提取的数据,但最好的方法是保持简单。

在这种情况下,你可以简单地使用COPY命令加载数据(不使用TRUNCATE!),并依靠时间戳来确定记录的最新状态或查看历史记录。例如,假设源表中的记录已修改并因此存在于正在加载的 CSV 文件中。加载完成后,你将在 Redshift 目标表中看到类似于表 5-1 的内容。

表 5-1. Redshift 中的订单表

OrderId 订单状态 最后更新时间
1 已备货 2020-06-01 12:00:00
1 已发货 2020-06-09 12:00:25

正如你在表 5-1 中所看到的,ID 值为 1 的订单在表中出现了两次。第一条记录存在于最新加载之前,并且第二条刚从 CSV 文件中加载。第一条记录是由于在 2020-06-01 更新了记录时订单处于Backordered状态时创建的。它在 2020-06-09 再次更新时Shipped,并包含在你最后加载的 CSV 文件中。

从历史记录保存的角度来看,在目标表中拥有这两条记录是理想的。在流水线的转换阶段后,分析师可以根据特定分析的需求选择使用其中一条或两条记录。也许他们想知道订单在缺货状态下的持续时间。他们需要这两条记录。如果他们想知道订单的当前状态,他们也可以得到这些信息。

虽然在目标表中为相同的OrderId拥有多条记录可能会让人感到不舒服,但在这种情况下,这样做是正确的选择!数据摄取的目标是专注于提取和加载数据。如何处理数据是流水线转换阶段的任务,在第六章 中有所探讨。

从 CDC 日志中提取加载数据

如果你的数据是通过 CDC 方法提取的,那么还有另一个考虑因素。尽管这与增量加载的数据类似,你不仅可以访问插入和更新的记录,还可以访问删除的记录。

以从第四章 中提取的 MySQL 二进制日志为例。回想一下代码示例的输出是一个名为orders_extract.csv的 CSV 文件,上传到了 S3 存储桶。其内容如下所示:

insert|1|Backordered|2020-06-01 12:00:00
update|1|Shipped|2020-06-09 12:00:25

就像本节前面的增量加载示例一样,OrderId 1 有两条记录。当加载到数据仓库时,数据看起来就像表 5-1 中的样子。然而,与之前的示例不同,orders_extract.csv 包含了文件中记录事件的列。在这个例子中,可以是insertupdate。如果这些是唯一的两种事件类型,你可以忽略事件字段,并最终得到在 Redshift 中看起来像表 5-1 的表格。从那里开始,分析师在后续流水线中构建数据模型时将可以访问这两条记录。然而,请考虑orders_extract.csv 的另一个版本,其中包含了一行额外的内容:

insert|1|Backordered|2020-06-01 12:00:00
update|1|Shipped|2020-06-09 12:00:25
delete|1|Shipped|2020-06-10 9:05:12

第三行显示订单记录在更新后的第二天被删除了。在完全提取中,该记录将完全消失,并且增量提取不会捕获到删除操作(请参阅“从 MySQL 数据库提取数据”以获取更详细的解释)。然而,使用 CDC,删除事件被捕获并包含在 CSV 文件中。

为了容纳已删除的记录,需要在 Redshift 仓库的目标表中添加一个列来存储事件类型。表 5-2 展示了 Orders 的扩展版本的外观。

表 5-2. Redshift 中具有 EventType 的 Orders 表

EventType OrderId OrderStatus LastUpdated
insert 1 已备货 2020-06-01 12:00:00
update 1 已发货 2020-06-09 12:00:25
delete 1 已发货 2020-06-10 9:05:12

数据管道中数据摄取的目标是有效地从源提取数据并加载到目标中。管道中的转换步骤是针对特定用例对数据建模的逻辑所在。第六章讨论了如何对通过 CDC 摄取加载的数据进行建模,例如本例。

将 Snowflake 仓库配置为目标

如果您将 Snowflake 作为数据仓库,有三种选项可配置从 Snowflake 实例访问 S3 存储桶的访问权限:

  • 配置 Snowflake 存储集成

  • 配置 AWS IAM 角色

  • 配置 AWS IAM 用户

其中,推荐第一种,因为在稍后从 Snowflake 中与 S3 存储桶交互时,使用 Snowflake 存储集成是多么无缝。由于配置的具体步骤包括多个步骤,建议参考有关该主题的最新 Snowflake 文档

在配置的最后一步中,您将创建一个 外部 stage。外部 stage 是指向外部存储位置的对象,以便 Snowflake 可以访问它。您之前创建的 S3 存储桶将作为该位置。

在创建 stage 之前,最好在 Snowflake 中定义一个 FILE FORMAT,您既可以为 stage 引用,也可以稍后用于类似的文件格式。由于本章的示例创建了管道分隔的 CSV 文件,因此创建以下 FILE FORMAT

CREATE or REPLACE FILE FORMAT pipe_csv_format
TYPE = 'csv'
FIELD_DELIMITER = '|';

当根据 Snowflake 文档的最后一步创建桶的 stage 时,语法将类似于:

USE SCHEMA my_db.my_schema;

CREATE STAGE my_s3_stage
  storage_integration = s3_int
  url = 's3://pipeline-bucket/'
  file_format = pipe_csv_format;

在 “将数据加载到 Snowflake 数据仓库” 中,您将使用 stage 将从 S3 存储桶中提取并存储的数据加载到 Snowflake 中。

最后,您需要向pipeline.conf文件中添加一个部分,其中包含 Snowflake 登录凭据。请注意,您指定的用户必须在您刚创建的阶段上具有USAGE权限。此外,account_name值必须根据您的云提供商和帐户所在地区进行格式化。例如,如果您的帐户名为snowflake_acct1,托管在 AWS 的美国东部(俄亥俄州)区域,则account_name值将是snowflake_acct1.us-east-2.aws。因为此值将用于使用 Python 通过snowflake-connector-python库连接到 Snowflake,您可以参考库文档以获取有关确定account_name正确值的帮助。

以下是添加到pipeline.conf的部分:

[snowflake_creds]
username = snowflake_user
password = snowflake_password
account_name = snowflake_acct1.us-east-2.aws

将数据加载到 Snowflake 数据仓库

将数据加载到 Snowflake 与之前加载数据到 Redshift 的模式几乎相同。因此,我不会讨论处理全量、增量或 CDC 数据提取的具体细节。相反,我将描述从已提取文件加载数据的语法。

将数据加载到 Snowflake 的机制是COPY INTO命令。COPY INTO将一个或多个文件的内容加载到 Snowflake 仓库中的表中。您可以在Snowflake 文档中了解有关该命令的高级用法和选项。

注意

Snowflake 还有一个名为Snowpipe的数据集成服务,允许从文件加载数据,这些文件一旦在 Snowflake 阶段(如本节示例中使用的阶段)中可用即可。您可以使用 Snowpipe 连续加载数据,而不是通过COPY INTO命令安排批量加载。

在第四章中的每个提取示例都将 CSV 文件写入了一个 S3 存储桶。在“将 Snowflake 仓库配置为目的地”中,您创建了一个名为my_s3_stage的 Snowflake 阶段,该阶段链接到该存储桶。现在,使用COPY INTO命令,您可以将文件加载到 Snowflake 表中,如下所示:

COPY INTO destination_table
  FROM @my_s3_stage/extract_file.csv;

还可以一次加载多个文件到表中。在某些情况下,由于数据量或自上次加载以来的多个提取作业运行的结果,数据被提取为多个文件。如果文件具有一致的命名模式(而且应该有!),您可以使用pattern参数加载它们所有:

COPY INTO destination_table
  FROM @my_s3_stage
  pattern='.*extract.*.csv';
注意

文件的格式在创建 Snowflake 阶段时已设置(管道分隔的 CSV 文件),因此不需要在COPY INTO命令语法中指定它。

现在您已经了解了COPY INTO命令的工作原理,现在是时候编写一个简短的 Python 脚本,以便安排并执行它,从而自动化管道中的加载。详情请参阅第七章,了解有关此及其他管道编排技术的更多详细信息。

首先,您需要安装一个 Python 库来连接到您的 Snowflake 实例。您可以使用pip完成此操作:

(env) $ pip install snowflake-connector-python

现在,您可以编写一个简单的 Python 脚本连接到您的 Snowflake 实例,并使用COPY INTO将 CSV 文件的内容加载到目标表中:

import snowflake.connector
import configparser

parser = configparser.ConfigParser()
parser.read("pipeline.conf")
username = parser.get("snowflake_creds",
            "username")
password =  parser.get("snowflake_creds",
            "password")
account_name = parser.get("snowflake_creds",
            "account_name")

snow_conn = snowflake.connector.connect(
    user = username,
    password = password,
    account = account_name
    )

sql = """COPY INTO destination_table
 FROM @my_s3_stage
 pattern='.*extract.*.csv';"""

cur = snow_conn.cursor()
cur.execute(sql)
cur.close()

使用文件存储作为数据湖

有时,从 S3 存储桶(或其他云存储)中提取数据而不加载到数据仓库中是有意义的。以这种方式以结构化或半结构化形式存储的数据通常称为数据湖

与数据仓库不同,数据湖以原始且有时非结构化的形式存储数据,可以存储多种格式的数据。它的存储成本较低,但并不像数据仓库中的结构化数据那样优化用于查询。

然而,近年来出现了一些工具,使得对数据湖中的数据进行查询变得更加可访问,通常对于熟悉 SQL 的用户来说也更加透明。例如,Amazon Athena 是一个 AWS 服务,允许用户使用 SQL 查询存储在 S3 中的数据。Amazon Redshift Spectrum 是一种服务,允许 Redshift 访问 S3 中的数据作为外部表,并在与 Redshift 仓库中的表一起查询时引用它。其他云提供商和产品也具有类似的功能。

在何时应考虑使用这种方法而不是结构化和加载数据到您的仓库中?有几种情况显得特别突出。

在基于云存储的数据湖中存储大量数据比在仓库中存储便宜(对于使用与 Snowflake 数据仓库相同存储的 Snowflake 数据湖不适用)。此外,由于它是非结构化或半结构化数据(没有预定义的模式),因此更改存储的数据类型或属性要比修改仓库模式容易得多。JSON 文档是您可能在数据湖中遇到的半结构化数据类型的示例。如果数据结构经常变化,您可能会考虑将其暂时存储在数据湖中。

在数据科学或机器学习项目的探索阶段,数据科学家或机器学习工程师可能尚不清楚他们需要数据呈现的确切“形状”。通过以原始形式访问湖中的数据,他们可以探索数据,并确定需要利用数据的哪些属性。一旦确定,您可以确定是否有意义将数据加载到仓库中的表中,并获得随之而来的查询优化。

实际上,许多组织在其数据基础设施中既有数据湖又有数据仓库。随着时间的推移,这两者已经成为互补而非竞争的解决方案。

开源框架

正如您现在已经注意到的,每个数据摄取中(提取和加载步骤都有)都存在重复的步骤。因此,近年来出现了许多框架,提供核心功能和与常见数据源和目标的连接。正如本节所讨论的,其中一些是开源的,而下一节则概述了一些流行的商业数据摄取产品。

一个流行的开源框架称为Singer。Singer 用 Python 编写,使用taps从源提取数据,并以 JSON 流方式传输到target。例如,如果您想从 MySQL 数据库提取数据并将其加载到 Google BigQuery 数据仓库中,您将使用 MySQL tap 和 BigQuery target。

就像本章中的代码示例一样,使用 Singer 仍然需要使用单独的编排框架来调度和协调数据摄取(有关更多信息,请参见第七章)。然而,无论您使用 Singer 还是其他框架,通过一个良好构建的基础,可以快速启动和运行。

作为一个开源项目,有大量可用的 taps 和 targets(请参阅表 5-3 中一些最受欢迎的)。您还可以向项目贡献自己的内容。Singer 有着良好的文档和活跃的SlackGitHub社区。

表 5-3. 流行的 singer taps 和 targets

Taps Targets
Google Analytics CSV
Jira Google BigQuery
MySQL PostgreSQL
PostgreSQL Amazon Redshift
Salesforce Snowflake

商业替代方案

有几种商业云托管产品可以实现许多常见的数据摄取,而无需编写一行代码。它们还具有内置的调度和作业编排功能。当然,这一切都是有代价的。

两个最受欢迎的商业数据摄取工具是StitchFivetran。两者都是完全基于 Web 的,数据工程师以及数据团队中的其他数据专业人员都可以访问。它们提供数百个预构建的“连接器”,用于常见数据源,如 Salesforce、HubSpot、Google Analytics、GitHub 等。您还可以从 MySQL、Postgres 和其他数据库中获取数据。还内置了对 Amazon Redshift、Snowflake 等数据仓库的支持。

如果您从支持的源中提取数据,将节省大量构建新数据摄取的时间。此外,正如第七章中详细概述的那样,调度和编排数据摄取并不是微不足道的任务。使用 Stitch 和 Fivetran,您可以在浏览器中构建、调度和对中断的摄取管道进行警报。

两个平台上选择的连接器还支持作业执行超时、重复数据处理、源系统架构更改等功能。如果你自行构建摄取,你需要自己考虑所有这些因素。

当然,这其中也存在一些权衡:

成本

Stitch 和 Fivetran 都采用基于数据量的定价模型。虽然它们在量化数据和每个定价层中包含的其他功能方面有所不同,但归根结底,你支付的费用取决于你摄取的数据量。如果你有多个高容量数据源需要摄取,费用将会增加。

锁定供应商

一旦你投资了某个供应商,未来要迁移到另一个工具或产品将需要大量工作。

定制化需要编码

如果你想从的源系统没有预构建的连接器,你就得自己写一些代码。对于 Stitch 来说,这意味着编写自定义的 Singer tap(参见前一节),而对于 Fivetran,则需要使用 AWS Lambda、Azure Function 或 Google Cloud Functions 编写云函数。如果你有许多自定义数据源,比如自定义构建的 REST API,你最终会不得不编写自定义代码,然后支付 Stitch 或 Fivetran 运行它。

安全和隐私

虽然这两款产品作为数据的透传器使用,并不会长时间存储数据,但它们仍然技术上可以访问你的源系统以及目标地点(通常是数据仓库或数据湖)。虽然 Fivetran 和 Stitch 都符合高安全标准,但一些组织由于风险容忍度、监管要求、潜在责任以及审查和批准新数据处理器的额外开销而不愿使用它们。

选择构建或购买对每个组织和使用案例来说都是复杂且独特的。值得注意的是,一些组织同时使用自定义代码和像 Fivetran 或 Stitch 这样的产品进行数据摄取。例如,编写自定义代码来处理一些在商业平台上运行成本高昂的高容量摄取可能更具成本效益,但使用 Stitch 或 Fivetran 进行具有预构建、供应商支持连接器的摄取也是值得的。

如果你选择了自定义和商业工具的混合使用,记住你需要考虑如何标准化日志记录、警报和依赖管理等事项。本书的后续章节将讨论这些主题,并触及跨多个平台管理流水线的挑战。

第六章:转换数据

在定义于第三章中的 ELT 模式中,一旦数据被摄入到数据湖或数据仓库中(见第四章),管道中的下一步是数据转换。数据转换可以包括对数据的非上下文操纵以及考虑业务上下文和逻辑建模的数据。

如果管道的目的是生成业务洞察或分析,则除了任何非上下文转换之外,数据还会进一步转换为数据模型。 从第二章回想起,数据模型以数据仓库中的一种或多种表形式来表示并定义数据,以便于数据分析。

尽管数据工程师有时在管道中构建非上下文转换,但现在数据分析师和分析工程师处理绝大部分数据转换已成为典型做法。 由于 ELT 模式的出现(他们在仓库中就有所需的数据!)以及支持以 SQL 为主要语言设计的工具和框架,这些角色比以往任何时候都更有权力。

本章既探讨了几乎每个数据管道都常见的非上下文转换,也讨论了用于仪表板、报告和业务问题一次性分析的数据模型。 由于 SQL 是数据分析师和分析工程师的语言,因此大多数转换代码示例都是用 SQL 编写的。 我包括了一些用 Python 编写的示例,以说明何时将非上下文转换紧密耦合到使用强大的 Python 库的数据摄入中是合理的。

就像第四章和五章中的数据摄取一样,代码样例极为简化,并作为管道中更复杂转换的起点。 要了解如何运行和管理转换与管道中其他步骤之间的依赖关系,请参见第八章。

非上下文转换

在第三章中,我简要提到了 EtLT 子模式的存在,其中小写字母t代表某些非上下文数据转换,例如以下内容:

  • 在表中去重记录

  • 将 URL 参数解析为单独的组件

虽然有无数例子,但通过提供这些转换的代码示例,我希望涵盖一些非上下文转换的常见模式。 下一节讨论何时以数据摄入(EtLT)和后摄入(ELT)的一部分执行这些转换是合理的。

在表中去重记录

尽管不是理想的,但在数据被摄入到数据仓库的表中存在重复记录是可能的。 出现这种情况有很多原因:

  • 增量数据摄取误以前摄取时间窗口重叠,并提取了在先前运行中已摄取的一些记录。

  • 在源系统中无意中创建了重复记录。

  • 在数据回填时,与后续加载到表中的数据重叠。

不管原因是什么,检查和删除重复记录最好使用 SQL 查询来执行。以下每个 SQL 查询都涉及到数据库中的Orders表,如表 6-1 所示。该表包含五条记录,其中两条是重复的。虽然对于OrderId为 1 的记录有三条,但第二行和第四行完全相同。本示例的目标是识别这种重复并解决它。虽然本示例有两条完全相同的记录,但以下代码示例的逻辑在表中有三条、四条甚至更多相同记录时也是有效的。

表 6-1. 带有重复记录的订单表

OrderId OrderStatus LastUpdated
1 缺货 2020-06-01
1 已发货 2020-06-09
2 已发货 2020-07-11
1 已发货 2020-06-09
3 已发货 2020-07-12

如果您想要创建一个用于示例 6-1 和 6-2 的Orders表,以下是可以使用的 SQL:

CREATE TABLE Orders (
  OrderId int,
  OrderStatus varchar(30),
  LastUpdated timestamp
);

INSERT INTO Orders
  VALUES(1,'Backordered', '2020-06-01');
INSERT INTO Orders
  VALUES(1,'Shipped', '2020-06-09');
INSERT INTO Orders
  VALUES(2,'Shipped', '2020-07-11');
INSERT INTO Orders
  VALUES(1,'Shipped', '2020-06-09');
INSERT INTO Orders
  VALUES(3,'Shipped', '2020-07-12');

简单地识别表中的重复记录。您可以使用 SQL 中的GROUP BYHAVING语句。以下查询返回任何重复记录以及它们的数量:

SELECT OrderId,
  OrderStatus,
  LastUpdated,
  COUNT(*) AS dup_count
FROM Orders
GROUP BY OrderId, OrderStatus, LastUpdated
HAVING COUNT(*) > 1;

当执行时,查询返回以下结果:

OrderId | OrderStatus | LastUpdated | dup_count
1       | Shipped     | 2020-06-09  | 2

现在您知道至少存在一个重复记录,可以删除这些重复记录。我将介绍两种方法来实现。您选择的方法取决于与数据库优化相关的许多因素以及您对 SQL 语法的偏好。我建议尝试两种方法并比较运行时间。

第一种方法是使用一系列查询。第一个查询使用DISTINCT语句从原始表创建表的副本。第一个查询的结果集只有四行,因为两个重复的行被DISTINCT转换为一个行。接下来,原始表被截断。最后,去重后的数据集被插入到原始表中,如示例 6-1 所示。

示例 6-1. distinct_orders_1.sql
CREATE TABLE distinct_orders AS
SELECT DISTINCT OrderId,
  OrderStatus,
  LastUpdated
FROM ORDERS;

TRUNCATE TABLE Orders;

INSERT INTO Orders
SELECT * FROM distinct_orders;

DROP TABLE distinct_orders;
警告

在对Orders表执行TRUNCATE操作后,直到完成以下INSERT操作之前,该表将为空。在此期间,Orders表为空,基本上不可由任何查询它的用户或进程访问。虽然INSERT操作可能不会花费太多时间,但对于非常大的表,您可能考虑删除Orders表,然后将distinct_orders重命名为Orders

另一种方法是使用窗口函数对重复行进行分组,并分配行号以标识哪些行应删除,哪些行应保留。我将使用ROW_NUMBER函数对记录进行排名,并使用PARTITION BY语句按每列分组记录。通过这样做,任何具有多个匹配项(我们的重复项)的记录将被分配一个大于 1 的ROW_NUMBER

如果您执行了示例 6-1,请确保使用本节早期的INSERT语句刷新Orders表,使其再次包含表 6-1 中显示的内容。您将希望有一个重复行用于以下示例!

当在Orders表上运行这样的查询时,会发生以下情况:

SELECT OrderId,
  OrderStatus,
  LastUpdated,
  ROW_NUMBER() OVER(PARTITION BY OrderId,
                    OrderStatus,
                    LastUpdated)
    AS dup_count
FROM Orders;

查询的结果如下:

orderid | orderstatus | lastupdated  |  dup_count
---------+-------------+-------------------+-----
      1 | Backordered | 2020-06-01   |     1
      1 | Shipped     | 2020-06-09   |     1
      1 | Shipped     | 2020-06-09   |     2
      2 | Shipped     | 2020-07-11   |     1
      3 | Shipped     | 2020-07-12   |     1

如您所见,结果集中的第三行具有dup_count值为2,因为它与其上方的记录是重复的。现在,就像第一种方法一样,您可以创建一个包含去重记录的表,截断Orders表,最后将清理后的数据集插入Orders中。示例 6-2 显示了完整的源代码。

示例 6-2. distinct_orders_2.sql
CREATE TABLE all_orders AS
SELECT
  OrderId,
  OrderStatus,
  LastUpdated,
  ROW_NUMBER() OVER(PARTITION BY OrderId,
                    OrderStatus,
                    LastUpdated)
    AS dup_count
FROM Orders;

TRUNCATE TABLE Orders;

-- only insert non-duplicated records
INSERT INTO Orders
  (OrderId, OrderStatus, LastUpdated)
SELECT
  OrderId,
  OrderStatus,
  LastUpdated
FROM all_orders
WHERE
  dup_count = 1;

DROP TABLE all_orders;

无论采用哪种方法,结果都是Orders表的去重版本,如表 6-2 所示。

表 6-2. Orders 表(无重复项)

OrderId OrderStatus LastUpdated
1 后订购 2020-06-01
1 已发货 2020-06-09
2 已发货 2020-07-11
3 已发货 2020-07-12

解析 URL

解析 URL 片段是一个与业务背景几乎无关的任务。有许多 URL 组件可以在转换步骤中解析,并存储在数据库表的各个列中。

例如,请考虑以下 URL:

https://www.mydomain.com/page-name?utm_content=textlink&utm_medium=social&utm_source=twitter&utm_campaign=fallsale

有六个有价值且可以解析并存储为单独列的组件:

  • 域名:www.domain.com

  • URL 路径:/page-name

  • utm_content 参数值:textlink

  • utm_medium 参数值:social

  • utm_source 参数值:twitter

  • utm_campaign 参数值:fallsale

解析 URL 可以在 SQL 和 Python 中进行。在运行转换时和 URL 存储的位置将指导您决定使用哪种语言。例如,如果您遵循 EtLT 模式并且可以在从源提取后但加载到数据仓库表之前解析 URL,则 Python 是一个非常好的选择。我将首先提供 Python 示例,然后是 SQL。

首先,使用pip安装urllib3 Python 库。(有关 Python 配置说明,请参见“设置 Python 环境”):

(env) $ pip install urllib3

接下来,使用urlsplitparse_qs函数解析 URL 的相关组件。在下面的代码示例中,我这样做并打印出结果:

from urllib.parse import urlsplit, parse_qs

url = """https://www.mydomain.com/page-name?utm_content=textlink&utm_medium=social&utm_source=twitter&utm_campaign=fallsale"""

split_url = urlsplit(url)
params = parse_qs(split_url.query)

# domain
print(split_url.netloc)

# url path
print(split_url.path)

# utm parameters
print(params['utm_content'][0])
print(params['utm_medium'][0])
print(params['utm_source'][0])
print(params['utm_campaign'][0])

当执行时,代码示例会产生以下结果:

www.mydomain.com
/page-name
textlink
social
twitter
fallsale

就像第四章和第五章中的数据摄入代码示例一样,你也可以解析并将每个参数写入 CSV 文件,以加载到数据仓库中完成摄入。示例 6-3 包含了完成此操作的代码示例,但你可能需要迭代处理多个 URL!

示例 6-3. url_parse.sql
from urllib.parse import urlsplit, parse_qs
import csv

url = """https://www.mydomain.com/page-name?utm_content=textlink&utm_medium=social&utm_source=twitter&utm_campaign=fallsale"""

split_url = urlsplit(url)
params = parse_qs(split_url.query)
parsed_url = []
all_urls = []

# domain
parsed_url.append(split_url.netloc)

# url path
parsed_url.append(split_url.path)

parsed_url.append(params['utm_content'][0])
parsed_url.append(params['utm_medium'][0])
parsed_url.append(params['utm_source'][0])
parsed_url.append(params['utm_campaign'][0])

all_urls.append(parsed_url)

export_file = "export_file.csv"

with open(export_file, 'w') as fp:
	csvw = csv.writer(fp, delimiter='|')
	csvw.writerows(all_urls)

fp.close()

如果你需要解析已加载到数据仓库中的 URL,使用 SQL 可能会更具挑战性。虽然一些数据仓库供应商提供解析 URL 的函数,但其他则没有。例如,Snowflake 提供了一个名为PARSE_URL的函数,将 URL 解析为其组件,并将结果作为 JSON 对象返回。例如,如果你想解析前面示例中的 URL,结果将如下所示:

SELECT parse_url('https://www.mydomain.com/page-name?utm_content=textlink&utm_medium=social&utm_source=twitter&utm_campaign=fallsale');
+-----------------------------------------------------------------+
| PARSE_URL('https://www.mydomain.com/page-name?utm_content=textlink&utm_medium=social&utm_source=twitter&utm_campaign=fallsale') |
|-----------------------------------------------------------------|
| {                               |
|   "fragment": null,             |
|   "host": "www.mydomain.com",   |
|   "parameters": {               |
|     "utm_content": "textlink",  |
|     "utm_medium": "social",   |
|     "utm_source": "twitter",    |
|     "utm_campaign": "fallsale"  |
|   },                            |
|   "path": "/page-name",         |
|   "query": "utm_content=textlink&utm_medium=social&utm_source=twitter&utm_campaign=fallsale",                                            |
|   "scheme": "HTTPS"              |
| }                                |
+-----------------------------------------------------------------+

如果你正在使用 Redshift 或其他没有内置 URL 解析功能的数据仓库平台,你需要使用自定义字符串解析或正则表达式。例如,Redshift 有一个名为REGEXP_SUBSTR的函数。考虑到大多数数据仓库中解析 URL 的难度,我建议在数据摄入时使用 Python 或其他语言进行解析,并加载结构化的 URL 组件。

何时进行转换?在摄入期间还是之后?

从技术角度来看,不像前面部分中没有业务背景的数据转换可以在数据摄入期间或之后运行。然而,有些原因你应该考虑将它们作为摄入过程的一部分运行(EtLT 模式):

  1. 使用除 SQL 以外的语言进行转换最简单:就像在前面的示例中解析 URL 一样,如果你发现使用 Python 库处理转换要容易得多,那么作为数据摄入的一部分就这样做吧。在 ELT 模式中,摄入后的转换仅限于数据建模,由通常在 SQL 中最为熟悉的数据分析师执行。

  2. 该转换正在解决数据质量问题:最好尽早在管道中解决数据质量问题(第九章更详细地讨论了这个主题)。例如,在前面的部分中,我提供了一个识别和删除已摄入数据中重复记录的示例。如果可以在摄入点捕捉并修复重复数据,就没有理由让数据分析师因为重复数据而遭受困扰。尽管该转换是用 SQL 编写的,但可以在摄入的最后阶段运行,而不必等待分析师转换数据。

当涉及到包含业务逻辑的转换时,最好将其与数据摄入分开。正如你将在下一节看到的,这种类型的转换被称为数据建模

数据建模基础

为了分析、仪表板和报告使用而建模的数据是一个值得专门撰写一本书的主题。但是,在 ELT 模式中建模数据的一些原则我在本节中进行了讨论。

不同于前一节,数据建模是 ELT 模式管道中的转换步骤考虑业务上下文的地方。数据模型理清了从各种来源在提取和加载步骤(数据摄入)中加载到仓库中的所有数据。

关键数据建模术语

在本节中,当我使用术语数据模型时,我指的是数据仓库中的单个 SQL 表。在样本数据模型中,我将专注于模型的两个属性:

度量

这些是你想要衡量的事物!例如客户数量和收入的金额。

属性

这些是你想在报告或仪表板中进行过滤或分组的内容。例如日期、客户名称和国家。

此外,我将谈到数据模型的粒度。粒度是存储在数据模型中的详细级别。例如,一个必须提供每天下订单数量的模型需要日粒度。如果它必须回答每小时下订单数量的问题,那么它需要小时粒度。

源表是通过数据摄入(如第四章和第五章描述)加载到数据仓库或数据湖中的表。在数据建模中,模型是从源表以及其他数据模型构建的。

完全刷新数据建模

当建模已完全重新加载的数据时,比如在“从 MySQL 数据库中提取数据”中描述的情况下,你会遇到包含源数据存储的最新状态的表(或多个表)。例如,表 6-3 展示了类似于表 6-2 中的Orders表的记录,但只包含最新的记录,而不是完整的历史记录。请注意,OrderId为 1 的Backordered记录在此版本中不存在。这就是如果从源数据库完整加载到数据仓库中,表看起来像源系统中的Orders表当前状态的原因。

与表 6-2 的其他差异是第四列名为CustomerId,存储下订单客户的标识符,以及第五列OrderTotal,即订单的金额。

表 6-3. 完全刷新的 Orders 表

OrderId OrderStatus OrderDate CustomerId OrderTotal
1 已发货 2020-06-09 100 50.05
2 已发货 2020-07-11 101 57.45
3 已发货 2020-07-12 102 135.99
4 已发货 2020-07-12 100 43.00

除了Orders表之外,还要考虑已在仓库中完全加载的Customers表,显示在表 6-4 中(即包含每个客户记录的当前状态)。

表 6-4. 完全刷新的 Customers 表

CustomerId CustomerName CustomerCountry
100 Jane 美国
101 Bob 英国
102 Miles 英国

如果您希望在接下来的几节中在数据库中创建这些表格,您可以使用以下 SQL 语句来执行。请注意,如果您创建了“在表中去重记录”版本的Orders表,您需要首先进行DROP操作。

CREATE TABLE Orders (
  OrderId int,
  OrderStatus varchar(30),
  OrderDate timestamp,
  CustomerId int,
  OrderTotal numeric
);

INSERT INTO Orders
  VALUES(1,'Shipped','2020-06-09',100,50.05);
INSERT INTO Orders
  VALUES(2,'Shipped','2020-07-11',101,57.45);
INSERT INTO Orders
  VALUES(3,'Shipped','2020-07-12',102,135.99);
INSERT INTO Orders
  VALUES(4,'Shipped','2020-07-12',100,43.00);

CREATE TABLE Customers
(
  CustomerId int,
  CustomerName varchar(20),
  CustomerCountry varchar(10)
);

INSERT INTO Customers VALUES(100,'Jane','USA');
INSERT INTO Customers VALUES(101,'Bob','UK');
INSERT INTO Customers VALUES(102,'Miles','UK');

考虑需要创建一个数据模型,以便查询来回答以下问题:

  • 某个国家在某个月份下的订单生成了多少收入?

  • 在给定的一天中有多少订单?

虽然示例表仅包含少量记录,但请想象一下,如果两个表包含数百万条记录的情况。虽然使用 SQL 查询回答这些问题非常直接,但当数据量较大时,通过在某种程度上对数据模型进行聚合可以减少查询执行时间和模型中的数据量。

如果这些问题是数据模型的唯二要求,它必须提供两个措施:

  • 总收入

  • 订单计数

此外,模型必须允许根据以下两个属性对数据进行过滤或分组查询:

  • 订单国家

  • 订单日期

最后,模型的粒度是按日,因为需求中的最小时间单位是按日。

在这个高度简化的数据模型中,我将首先定义模型的结构(一个 SQL 表),然后插入从两个表连接源的数据:

CREATE TABLE IF NOT EXISTS order_summary_daily (
order_date date,
order_country varchar(10),
total_revenue numeric,
order_count int
);

INSERT INTO order_summary_daily
  (order_date, order_country,
  total_revenue, order_count)
SELECT
  o.OrderDate AS order_date,
  c.CustomerCountry AS order_country,
  SUM(o.OrderTotal) as total_revenue,
  COUNT(o.OrderId) AS order_count
FROM Orders o
INNER JOIN Customers c on
  c.CustomerId = o.CustomerId
GROUP BY o.OrderDate, c.CustomerCountry;

现在,您可以查询模型来回答需求中列出的问题:

-- How much revenue was generated from orders placed from a given country in a given month?

SELECT
  DATE_PART('month', order_date) as order_month,
  order_country,
  SUM(total_revenue) as order_revenue
FROM order_summary_daily
GROUP BY
  DATE_PART('month', order_date),
  order_country
ORDER BY
  DATE_PART('month', order_date),
  order_country;

使用表 6-3 和 6-4 中的示例数据,查询返回以下结果:

order_month | order_country | order_revenue
-------------+---------------+---------------
          6 | USA           |         50.05
          7 | UK            |        193.44
          7 | USA           |         43.00
(3 rows)
-- How many orders were placed on a given day?
SELECT
  order_date,
  SUM(order_count) as total_orders
FROM order_summary_daily
GROUP BY order_date
ORDER BY order_date;

返回以下结果:

order_date | total_orders
------------+--------------
2020-06-09 |            1
2020-07-11 |            1
2020-07-12 |            2
(3 rows)

完全刷新数据的慢变化维度

因为完全刷新的数据(如Customers中的记录)会覆盖现有数据的更改,通常会实现更高级的数据建模概念以跟踪历史更改。

例如,在下一节中,您将使用已增量加载的Customers表格,并包含对客户号为 100 的更新。正如您将在 表 6-6 中看到的那样,该客户有第二条记录表明她的CustomerCountry值于 2020-06-20 从“美国”更改为“英国”。这意味着她在 2020-07-12 下订单 4 时不再居住在美国。

在分析订单历史时,分析师可能希望将客户的订单分配到订单时所居住的地方。使用增量刷新数据,这样做稍微容易一些,如下一节所示。对于完全刷新的数据,需要在每次摄入之间保留Customers表格的完整历史记录,并自行跟踪这些更改。

这种方法在 Kimball(维度)建模中定义,并称为慢变化维度SCD。在处理完全刷新的数据时,我经常使用 II 型 SCD,为实体的每次更改向表格中添加新记录,包括记录有效的日期范围。

简单来说,简的客户记录的 II 型 SCD 如 表 6-5 所示。请注意,最新记录的过期日期非常遥远。一些 II 型 SCD 使用 NULL 表示未过期记录,但远未来的日期使得查询表格时出错的可能性稍低,稍后您将看到。

表 6-5. 具有客户数据的 II 型 SCD

CustomerId CustomerName CustomerCountry ValidFrom Expired
100 Jane 美国 2019-05-01 7:01:10 2020-06-20 8:15:34
100 Jane 英国 2020-06-20 8:15:34 2199-12-31 00:00:00

你可以使用以下 SQL 语句在数据库中创建和填充此表:

CREATE TABLE Customers_scd
(
  CustomerId int,
  CustomerName varchar(20),
  CustomerCountry varchar(10),
  ValidFrom timestamp,
  Expired timestamp
);

INSERT INTO Customers_scd
  VALUES(100,'Jane','USA','2019-05-01 7:01:10',
    '2020-06-20 8:15:34');
INSERT INTO Customers_scd
  VALUES(100,'Jane','UK','2020-06-20 8:15:34',
    '2199-12-31 00:00:00');

您可以将此 SCD 与您之前创建的Orders表格连接起来,以获取订单时的客户记录属性。为此,除了使用CustomerId进行连接外,您还需要使用订单放置时 SCD 中的日期范围进行连接。例如,此查询将返回简的Customers_scd记录指示她当时住在的国家:

SELECT
  o.OrderId,
  o.OrderDate,
  c.CustomerName,
  c.CustomerCountry
FROM Orders o
INNER JOIN Customers_scd c
  ON o.CustomerId = c.CustomerId
    AND o.OrderDate BETWEEN c.ValidFrom AND c.Expired
ORDER BY o.OrderDate;
orderid |     orderdate     | customer | customer
                                name      country
---------+--------------------+--------+---------
      1 | 2020-06-09 00:00:00 | Jane   | USA
      4 | 2020-07-12 00:00:00 | Jane   | UK
(2 rows)

尽管此逻辑足以在数据建模中使用 SCD,但保持 SCD 的最新状态可能是一个挑战。对于Customers表格,您需要在每次摄入后对其进行快照,并查找任何已更改的CustomerId记录。如何处理这些取决于您使用的数据仓库和数据编排工具。如果您有兴趣实施 SCD,请学习 Kimball 建模的基础知识,这超出了本书的范围。如需更深入地了解此主题,请参阅 Ralph Kimball 和 Margy Ross 的书籍数据仓库工具包(Wiley, 2013)。

逐步建模增量摄入的数据

请回想来自第四章的信息,增量摄取的数据不仅包含源数据的当前状态,还包括自摄入开始以来的历史记录。例如,考虑与先前部分相同的Orders表,但是有一个名为Customers_staging的新客户表,它是增量摄入的。正如您在表 6-6 中所看到的,记录的UpdatedDate值有所更新,并且对于CustomerId 100 的新记录表明,简的CustomerCountry(她居住的地方)于 2020 年 06 月 20 日从美国变更为英国。

表 6-6. 增量加载的 Customers_staging 表

CustomerId CustomerName CustomerCountry LastUpdated
100 Jane 美国 2019-05-01 7:01:10
101 Bob 英国 2020-01-15 13:05:31
102 Miles 英国 2020-01-29 9:12:00
100 Jane 英国 2020-06-20 8:15:34

可以使用以下 SQL 语句在数据库中创建和填充Customers_staging表,以便在接下来的示例中使用:

CREATE TABLE Customers_staging (
  CustomerId int,
  CustomerName varchar(20),
  CustomerCountry varchar(10),
  LastUpdated timestamp
);

INSERT INTO Customers_staging
  VALUES(100,'Jane','USA','2019-05-01 7:01:10');
INSERT INTO Customers_staging
  VALUES(101,'Bob','UK','2020-01-15 13:05:31');
INSERT INTO Customers_staging
  VALUES(102,'Miles','UK','2020-01-29 9:12:00');
INSERT INTO Customers_staging
  VALUES(100,'Jane','UK','2020-06-20 8:15:34');

请回想前一节模型需要回答的问题,我将在本节中也应用到模型上:

  • 在给定月份内来自特定国家下的订单生成了多少收入?

  • 在特定日子下有多少订单?

在这种情况下,在构建数据模型之前,您需要决定如何处理Customer表中记录的更改。例如,在简的例子中,她在Orders表中的两个订单应分配给哪个国家?它们应该都分配给她当前的国家(英国)还是每个订单应该分配到下单时她所在的国家(分别是美国和英国)?

您所做的选择基于业务案例所需的逻辑,但每个实现都有些不同。我将以分配给她当前国家的示例开始。我将通过构建与前一节中相似的数据模型来完成这一点,但仅使用Customers_staging表中每个CustomerId的最新记录。请注意,由于模型要求的第二个问题需要每日粒度,我将在日期级别上构建模型:

CREATE TABLE order_summary_daily_current
(
  order_date date,
  order_country varchar(10),
  total_revenue numeric,
  order_count int
);

INSERT INTO order_summary_daily_current
  (order_date, order_country,
  total_revenue, order_count)
WITH customers_current AS
(
  SELECT CustomerId,
    MAX(LastUpdated) AS latest_update
  FROM Customers_staging
  GROUP BY CustomerId
)
SELECT
  o.OrderDate AS order_date,
  cs.CustomerCountry AS order_country,
  SUM(o.OrderTotal) AS total_revenue,
  COUNT(o.OrderId) AS order_count
FROM Orders o
INNER JOIN customers_current cc
  ON cc.CustomerId = o.CustomerId
INNER JOIN Customers_staging cs
  ON cs.CustomerId = cc.CustomerId
    AND cs.LastUpdated = cc.latest_update
GROUP BY o.OrderDate, cs.CustomerCountry;

当回答来自特定国家在给定月份内的订单生成了多少收入时,简的两个订单都分配给了英国,尽管您可能期望从她在美国生活时,将 6 月份的订单分配给美国,从而得到 50.05:

SELECT
  DATE_PART('month', order_date) AS order_month,
  order_country,
  SUM(total_revenue) AS order_revenue
FROM order_summary_daily_current
GROUP BY
  DATE_PART('month', order_date),
  order_country
ORDER BY
  DATE_PART('month', order_date),
  order_country;
order_month | order_country | order_revenue
-------------+---------------+---------------
          6 | UK            |         50.05
          7 | UK            |        236.44
(2 rows)

如果您希望根据客户下订单时所在国家来分配订单,那么构建模型就需要改变逻辑。不再查找每个Customers_staging中每个CustomerId的最近记录,而是查找每个客户下订单时更新时间在订单放置时间之前或同时的最近记录。换句话说,我希望获得客户下订单时的有效信息。该信息存储在他们的Customer_staging记录版本中,在该特定订单放置之后,直到后续更新为止。

以下示例中的customer_pitpit是“时间点”)CTE 包含每个CustomerId/OrderId对的MAX(cs.LastUpdated)。我在最终的SELECT语句中使用这些信息来填充数据模型。请注意,在此查询中,必须根据OrderIdCustomerId进行连接。以下是order_summary_daily_pit模型的最终 SQL:

CREATE TABLE order_summary_daily_pit
(
  order_date date,
  order_country varchar(10),
  total_revenue numeric,
  order_count int
);

INSERT INTO order_summary_daily_pit
  (order_date, order_country, total_revenue, order_count)
WITH customer_pit AS
(
  SELECT
    cs.CustomerId,
    o.OrderId,
    MAX(cs.LastUpdated) AS max_update_date
  FROM Orders o
  INNER JOIN Customers_staging cs
    ON cs.CustomerId = o.CustomerId
      AND cs.LastUpdated <= o.OrderDate
  GROUP BY cs.CustomerId, o.OrderId
)
SELECT
  o.OrderDate AS order_date,
  cs.CustomerCountry AS order_country,
  SUM(o.OrderTotal) AS total_revenue,
  COUNT(o.OrderId) AS order_count
FROM Orders o
INNER JOIN customer_pit cp
  ON cp.CustomerId = o.CustomerId
    AND cp.OrderId = o.OrderId
INNER JOIN Customers_staging cs
  ON cs.CustomerId = cp.CustomerId
    AND cs.LastUpdated = cp.max_update_date
GROUP BY o.OrderDate, cs.CustomerCountry;

运行与之前相同的查询后,您将看到 Jane 的第一笔订单的收入在 2020 年 6 月分配给了美国,而第二笔订单在 2020 年 7 月分配给了英国,正如预期的那样:

SELECT
  DATE_PART('month', order_date) AS order_month,
  order_country,
  SUM(total_revenue) AS order_revenue
FROM order_summary_daily_pit
GROUP BY
  DATE_PART('month', order_date),
  order_country
ORDER BY
  DATE_PART('month', order_date),
  order_country;
order_month | order_country | order_revenue
-------------+---------------+---------------
          6 | USA           |         50.05
          7 | UK            |        236.44
(2 rows)

建模追加数据

仅追加数据(或仅插入数据)是指不可变数据,用于导入数据仓库。这种表中的每条记录都是一些永不改变的事件。例如,网站上所有页面浏览的表格。每次数据导入运行时,都会将新的页面浏览追加到表中,但不会更新或删除之前的事件。过去发生的事情已经发生,无法改变。

建模追加数据类似于建模完全刷新数据。然而,您可以通过利用一旦记录插入,它们永远不会更改的事实来优化基于这种数据构建的数据模型的创建和刷新。

表 6-7 是一个名为PageViews的追加数据表的示例,其中记录了网站上的页面浏览。表中的每条记录代表客户在公司网站上浏览页面的情况。每次数据导入作业运行时,都会将新的记录追加到表中,代表上次导入以来记录的页面浏览。

表 6-7. 页面浏览表

CustomerId ViewTime UrlPath utm_medium
100 2020-06-01 12:00:00 /home social
100 2020-06-01 12:00:13 /product/2554 NULL
101 2020-06-01 12:01:30 /product/6754 search
102 2020-06-02 7:05:00 /home NULL
101 2020-06-02 12:00:00 /product/2554 social

您可以使用以下 SQL 查询在数据库中创建和填充PageViews表,以便在接下来的示例中使用。

CREATE TABLE PageViews (
  CustomerId int,
  ViewTime timestamp,
  UrlPath varchar(250),
  utm_medium varchar(50)
);

INSERT INTO PageViews
  VALUES(100,'2020-06-01 12:00:00',
    '/home','social');
INSERT INTO PageViews
  VALUES(100,'2020-06-01 12:00:13',
    '/product/2554',NULL);
INSERT INTO PageViews
  VALUES(101,'2020-06-01 12:01:30',
    '/product/6754','search');
INSERT INTO PageViews
  VALUES(102,'2020-06-02 7:05:00',
    '/home','NULL');
INSERT INTO PageViews
  VALUES(101,'2020-06-02 12:00:00',
    '/product/2554','social');

请注意,实际包含页面浏览数据的表可能包含几十个或更多列,存储有关浏览页面属性、引荐 URL、用户浏览器版本等的属性。

现在,我将定义一个数据模型,旨在回答以下问题。我将使用本章前文中定义的Customers表(表 6-4)来确定每位客户所居住的国家:

  • 每天站点上每个UrlPath的页面浏览量是多少?

  • 每天每个国家的客户生成多少页面浏览量?

数据模型的粒度是每天一次。有三个必需的属性。

  • 页面查看的日期(无需时间戳)

  • 页面查看的UrlPath

  • 查看页面的客户所在的国家

只有一个必需的度量标准:

  • 页面浏览量的计数

模型的结构如下:

CREATE TABLE pageviews_daily (
  view_date date,
  url_path varchar(250),
  customer_country varchar(50),
  view_count int
);

要首次填充模型,逻辑与本章“完全刷新数据建模”部分相同。从PageViews表中的所有记录都包括在pageviews_daily的填充中。示例 6-4 显示了 SQL。

示例 6-4. pageviews_daily.sql
INSERT INTO pageviews_daily
  (view_date, url_path, customer_country, view_count)
SELECT
  CAST(p.ViewTime as Date) AS view_date,
  p.UrlPath AS url_path,
  c.CustomerCountry AS customer_country,
  COUNT(*) AS view_count
FROM PageViews p
LEFT JOIN Customers c ON c.CustomerId = p.CustomerId
GROUP BY
  CAST(p.ViewTime as Date),
  p.UrlPath,
  c.CustomerCountry;

要回答模型要求的一个问题(每天每个国家的客户生成多少页面浏览量?),以下 SQL 将解决问题:

SELECT
  view_date,
  customer_country,
  SUM(view_count)
FROM pageviews_daily
GROUP BY view_date, customer_country
ORDER BY view_date, customer_country;
view_date  | customer_country | sum
------------+------------------+-----
2020-06-01 | UK               |   1
2020-06-01 | USA              |   2
2020-06-02 | UK               |   2
(3 rows)

现在考虑下次将数据摄入PageViews表时该做什么。会添加新记录,但所有现有记录保持不变。要更新pageviews_daily模型,有两个选项:

  • 截断pageviews_daily表,并运行与首次填充它时使用的相同INSERT语句。在这种情况下,您正在完全刷新模型。

  • 仅将新记录从PageViews加载到pageviews_daily中。在这种情况下,您正在增量刷新模型。

第一种选项最简单,而且在建模的分析师运行模型时不太可能导致逻辑错误。如果INSERT操作在您的使用情况下运行得足够快,请选择这条路线。但是要注意!虽然模型的完全刷新在初次开发时可能运行得足够快,但随着PageViewsCustomers数据集的增长,刷新的运行时间也会增加。

第二个选项稍微复杂一些,但在处理较大数据集时可能会导致更短的运行时间。在这种情况下,增量刷新的棘手之处在于pageviews_daily表粒度为天(无时间戳的日期),而摄入到PageViews表中的新记录粒度为完整时间戳。

为什么这是个问题?很可能您在记录一个完整的日期结束时没有刷新pageviews_daily。换句话说,虽然pageviews_daily有 2020-06-02 的数据,但可能会在下一次摄入运行中加载该日的新记录到PageViews中。

Table 6-8 就展示了这种情况。两条新记录已添加到之前版本的PageViews表格(来自Table 6-7)。第一次新页面浏览发生在 2020-06-02,第二次是第二天。

Table 6-8. PageViews 表格与额外记录

CustomerId ViewTime UrlPath utm_medium
100 2020-06-01 12:00:00 /home social
100 2020-06-01 12:00:13 /product/2554 NULL
101 2020-06-01 12:01:30 /product/6754 search
102 2020-06-02 7:05:00 /home NULL
101 2020-06-02 12:00:00 /product/2554 social
102 2020-06-02 12:03:42 /home NULL
101 2020-06-03 12:25:01 /product/567 social

在我尝试增量刷新pageviews_daily模型之前,先来看一下当前的快照:

SELECT *
FROM pageviews_daily
ORDER BY view_date, url_path, customer_country;
view_date  |   url_path   | customer | view_count
                            _country
------------+---------------+----------+---------
2020-06-01 | /home         | USA        |     1
2020-06-01 | /product/2554 | USA        |     1
2020-06-01 | /product/6754 | UK         |     1
2020-06-02 | /home         | UK         |     1
2020-06-02 | /product/2554 | UK         |     1
(5 rows)

现在,你可以使用以下 SQL 语句将Table 6-8中显示的两条新记录插入到你的数据库中:

INSERT INTO PageViews
  VALUES(102,'2020-06-02 12:03:42',
    '/home',NULL);
INSERT INTO PageViews
  VALUES(101,'2020-06-03 12:25:01',
    '/product/567','social');

作为第一次增量刷新的尝试,你可以简单地将PageViews中时间戳大于当前MAX(view_date)(2020-06-02)的记录加入到pageviews_daily中。我会尝试这样做,但不是直接插入到pageviews_daily,而是创建另一个名为pageviews_daily_2的副本并用于本示例。为什么呢?因为,正如你马上会看到的,这并不是正确的方法!对应的 SQL 代码如下所示:

CREATE TABLE pageviews_daily_2 AS
SELECT * FROM pageviews_daily;

INSERT INTO pageviews_daily_2
  (view_date, url_path,
  customer_country, view_count)
SELECT
  CAST(p.ViewTime as Date) AS view_date,
  p.UrlPath AS url_path,
  c.CustomerCountry AS customer_country,
  COUNT(*) AS view_count
FROM PageViews p
LEFT JOIN Customers c
  ON c.CustomerId = p.CustomerId
WHERE
  p.ViewTime >
  (SELECT MAX(view_date) FROM pageviews_daily_2)
GROUP BY
  CAST(p.ViewTime as Date),
  p.UrlPath,
  c.CustomerCountry;

然而,正如你在下面的代码中看到的,你最终会得到几个重复的记录,因为所有 2020-06-02 午夜及以后的事件都包含在刷新中。换句话说,之前在模型中已经计算过的 2020-06-02 的页面浏览又被计算了一次。这是因为我们在每日粒度的pageviews_daily(及其副本pageviews_daily_2)中没有存储完整的时间戳。如果这个模型版本用于报告或分析,页面浏览次数将被高估!

SELECT *
FROM pageviews_daily_2
ORDER BY view_date, url_path, customer_country;
view_date  |   url_path   | customer | view_count
                            _country
------------+--------------+---------+-----------
2020-06-01 | /home         | USA     |     1
2020-06-01 | /product/2554 | USA     |     1
2020-06-01 | /product/6754 | UK      |     1
2020-06-02 | /home         | UK      |     2
2020-06-02 | /home         | UK      |     1
2020-06-02 | /product/2554 | UK      |     1
2020-06-02 | /product/2554 | UK      |     1
2020-06-03 | /product/567  | UK      |     1
(8 rows)

如果按日期汇总view_count,你会看到 2020-06-02 有五次页面浏览,而不是来自Table 6-8的实际计数为三次。这是因为之前添加到pageviews_daily_2的两次页面浏览再次被添加进来:

SELECT
  view_date,
  SUM(view_count) AS daily_views
FROM pageviews_daily_2
GROUP BY view_date
ORDER BY view_date;
view_date  | daily_views
------------+-------------
2020-06-01 |           3
2020-06-02 |           5
2020-06-03 |           1
(3 rows)

许多分析师采取的另一种方法是存储PageViews表格中最后记录的完整时间戳,并将其用作增量刷新的下一个起始点。像上次一样,我会创建一个新表格(这次称为pageviews_daily_3),这并不是正确的解决方案:

CREATE TABLE pageviews_daily_3 AS
SELECT * FROM pageviews_daily;

INSERT INTO pageviews_daily_3
  (view_date, url_path,
  customer_country, view_count)
SELECT
  CAST(p.ViewTime as Date) AS view_date,
  p.UrlPath AS url_path,
  c.CustomerCountry AS customer_country,
  COUNT(*) AS view_count
FROM PageViews p
LEFT JOIN Customers c
  ON c.CustomerId = p.CustomerId
WHERE p.ViewTime > '2020-06-02 12:00:00'
GROUP BY
  CAST(p.ViewTime AS Date),
  p.UrlPath,
  c.CustomerCountry;

再次,如果你查看新版本的pageviews_daily_3,你会注意到一些非理想的地方。虽然 2020-06-02 的总页面浏览次数现在是正确的(3),但有两行是相同的(view_date2020-06-02url_path/homecustomer_countryUK):

SELECT *
FROM pageviews_daily_3
ORDER BY view_date, url_path, customer_country;
view_date  |   url_path   | customer | view_count
                            _country
------------+--------------+---------+------------
2020-06-01 | /home         | USA     |     1
2020-06-01 | /product/2554 | USA     |     1
2020-06-01 | /product/6754 | UK      |     1
2020-06-02 | /home         | UK      |     1
2020-06-02 | /home         | UK      |     1
2020-06-02 | /product/2554 | UK      |     1
2020-06-03 | /product/567  | UK      |     1
(7 rows)

幸运的是,在这种情况下,按日和国家计算的页面浏览量是正确的。然而,存储不需要的数据是浪费的。这两条记录本可以合并成一条,视图计数值为 2。尽管这种情况下示例表格很小,但在实际情况下,这样的表格通常会有数十亿条记录。不必要的重复记录数量会增加存储和未来查询时间的浪费。

更好的方法是假设在最近一天(或一周、一个月等,根据表格的粒度)中加载了更多数据。我将采取的方法如下:

  1. 创建名为tmp_pageviews_dailypageviews_daily副本,其中包含截止到当前包含的倒数第二天的所有记录。在本例中,这意味着所有数据都截至到 2020-06-01。

  2. 将所有源表(PageViews)的记录插入到从第二天(2020-06-02)开始的副本中。

  3. 截断pageviews_daily并将数据从tmp_pageviews_daily加载到其中。

  4. 删除tmp_pageviews_daily

最终,模型增量刷新的正确 SQL 如下:

CREATE TABLE tmp_pageviews_daily AS
SELECT *
FROM pageviews_daily
WHERE view_date
  < (SELECT MAX(view_date) FROM pageviews_daily);

INSERT INTO tmp_pageviews_daily
  (view_date, url_path,
  customer_country, view_count)
SELECT
  CAST(p.ViewTime as Date) AS view_date,
  p.UrlPath AS url_path,
  c.CustomerCountry AS customer_country,
  COUNT(*) AS view_count
FROM PageViews p
LEFT JOIN Customers c
  ON c.CustomerId = p.CustomerId
WHERE p.ViewTime
  > (SELECT MAX(view_date) FROM pageviews_daily)
GROUP BY
  CAST(p.ViewTime as Date),
  p.UrlPath,
  c.CustomerCountry;

TRUNCATE TABLE pageviews_daily;

INSERT INTO pageviews_daily
SELECT * FROM tmp_pageviews_daily;

DROP TABLE tmp_pageviews_daily;

最后,以下是适当的增量刷新结果。页面浏览总数正确,且数据存储尽可能高效,符合模型要求:

SELECT *
FROM pageviews_daily
ORDER BY view_date, url_path, customer_country;
view_date  |   url_path   | customer | view_count
                            _country
------------+--------------+---------+------------
2020-06-01 | /home         | USA     |     1
2020-06-01 | /product/2554 | USA     |     1
2020-06-01 | /product/6754 | UK      |     1
2020-06-02 | /home         | UK      |     2
2020-06-02 | /product/2554 | UK      |     1
2020-06-03 | /product/567  | UK      |     1
(6 rows)

模型变更捕获数据

请回顾第四章,通过 CDC 摄取的数据在摄取后以特定方式存储在数据仓库中。例如,表 6-9 显示了通过 CDC 摄取的名为Orders_cdc的表的内容。它包含源系统中三个订单的历史记录。

表 6-9. Orders_cdc 表

EventType OrderId OrderStatus LastUpdated
插入 1 Backordered 2020-06-01 12:00:00
更新 1 Shipped 2020-06-09 12:00:25
删除 1 Shipped 2020-06-10 9:05:12
插入 2 Backordered 2020-07-01 11:00:00
更新 2 Shipped 2020-07-09 12:15:12
插入 3 Backordered 2020-07-11 13:10:12

您可以使用以下 SQL 语句创建并填充Orders_cdc表:

CREATE TABLE Orders_cdc
(
  EventType varchar(20),
  OrderId int,
  OrderStatus varchar(20),
  LastUpdated timestamp
);

INSERT INTO Orders_cdc
  VALUES('insert',1,'Backordered',
    '2020-06-01 12:00:00');
INSERT INTO Orders_cdc
  VALUES('update',1,'Shipped',
    '2020-06-09 12:00:25');
INSERT INTO Orders_cdc
  VALUES('delete',1,'Shipped',
    '2020-06-10 9:05:12');
INSERT INTO Orders_cdc
  VALUES('insert',2,'Backordered',
    '2020-07-01 11:00:00');
INSERT INTO Orders_cdc
  VALUES('update',2,'Shipped',
    '2020-07-09 12:15:12');
INSERT INTO Orders_cdc
  VALUES('insert',3,'Backordered',
    '2020-07-11 13:10:12');

订单 1 的记录首次创建于下单时,但状态为Backordered。八天后,该记录在发货时在源系统中更新。一天后,由于某种原因,该记录在源系统中被删除。订单 2 经历了类似的旅程,但从未被删除。订单 3 在下单时首次插入,从未更新过。多亏了 CDC,我们不仅知道所有订单的当前状态,还知道它们的完整历史。

如何建模以这种方式存储的数据取决于数据模型旨在回答什么问题。例如,您可能希望报告操作仪表板上所有订单的当前状态。也许仪表板需要显示每个状态中当前订单的数量。一个简单的模型可能看起来像这样:

CREATE TABLE orders_current (
  order_status varchar(20),
  order_count int
);

INSERT INTO orders_current
  (order_status, order_count)
  WITH o_latest AS
  (
    SELECT
       OrderId,
       MAX(LastUpdated) AS max_updated
    FROM Orders_cdc
    GROUP BY orderid
  )
  SELECT o.OrderStatus,
    Count(*) as order_count
  FROM Orders_cdc o
  INNER JOIN o_latest
    ON o_latest.OrderId = o_latest.OrderId
      AND o_latest.max_updated = o.LastUpdated
  GROUP BY o.OrderStatus;

在此示例中,我使用了 CTE 而不是子查询来查找每个OrderIdMAX(LastUpdated)时间戳。然后,我将结果 CTE 与Orders_cdc表连接,以获取每个订单最新记录的OrderStatus

要回答最初的问题,您可以看到两个订单的OrderStatusShipped,还有一个订单仍然是Backordered

SELECT * FROM orders_current;
order_status | order_count
--------------+-------------
Shipped      |           2
Backordered  |           1
(2 rows)

然而,这是否是正确的答案呢?请记住,虽然OrderId 1 的最新状态目前是Shipped,但Order记录已从源数据库中删除。尽管这可能看起来像是一个糟糕的系统设计,但现在假设当客户取消订单时,订单将从源系统中删除。为了考虑到删除的情况,我将对模型刷新进行小的修改以忽略删除操作:

TRUNCATE TABLE orders_current;

INSERT INTO orders_current
  (order_status, order_count)
  WITH o_latest AS
  (
    SELECT
       OrderId,
       MAX(LastUpdated) AS max_updated
    FROM Orders_cdc
    GROUP BY orderid
  )
  SELECT o.OrderStatus,
    Count(*) AS order_count
  FROM Orders_cdc o
  INNER JOIN o_latest
    ON o_latest.OrderId = o_latest.OrderId
      AND o_latest.max_updated = o.LastUpdated
  WHERE o.EventType <> 'delete'
  GROUP BY o.OrderStatus;

如您所见,删除的订单不再被考虑:

SELECT * FROM orders_current;
order_status | order_count
--------------+-------------
Shipped      |           1
Backordered  |           1
(2 rows)

CDC 摄入数据的另一个常见用途是理解变更本身。例如,也许分析师想要知道订单从BackorderedShipped状态平均需要多长时间。我将再次使用 CTE(这次是两个!)来查找每个订单首次BackorderedShipped的日期。然后我将这两个日期相减,以获取每个既是 backordered 又已经 shipped 的订单处于Backordered状态的天数。请注意,此逻辑有意忽略了OrderId 3,该订单当前是 backordered,但尚未发货:

CREATE TABLE orders_time_to_ship (
  OrderId int,
  backordered_days interval
);

INSERT INTO orders_time_to_ship
  (OrderId, backordered_days)
WITH o_backordered AS
(
  SELECT
     OrderId,
     MIN(LastUpdated) AS first_backordered
  FROM Orders_cdc
  WHERE OrderStatus = 'Backordered'
  GROUP BY OrderId
),
o_shipped AS
(
  SELECT
     OrderId,
     MIN(LastUpdated) AS first_shipped
  FROM Orders_cdc
  WHERE OrderStatus = 'Shipped'
  GROUP BY OrderId
)
SELECT b.OrderId,
  first_shipped - first_backordered
    AS backordered_days
FROM o_backordered b
INNER JOIN o_shipped s on s.OrderId = b.OrderId;

您可以看到每个订单的 backorder 时间,以及使用AVG()函数来回答最初的问题:

SELECT * FROM orders_time_to_ship;
orderid | backordered_days
---------+------------------
      1 | 8 days 00:00:25
      2 | 8 days 01:15:12
(2 rows)
SELECT AVG(backordered_days)
FROM orders_time_to_ship;
avg
-------------------
8 days 00:37:48.5
(1 row)

对于具有完整更改历史记录的数据,还有许多其他用例,但就像对已完全加载或仅追加的数据进行建模一样,有一些常见的最佳实践和考虑因素。

像前一节一样,通过利用 CDC 摄入的数据是增量加载而不是完全刷新,可以实现潜在的性能提升。但是,正如在该部分中指出的那样,有时性能增益并不值得采用增量模型刷新而不是全刷新的复杂性。在处理 CDC 数据时,我发现这种情况大多数情况下是正确的。处理更新和删除的额外复杂性通常足以使全刷新成为首选路径。

第七章:管道编排

前几章已经描述了数据管道的构建块,包括数据摄取、数据转换以及机器学习管道中的步骤。本章涵盖了如何“编排”或连接这些块或步骤。

编排确保管道中的步骤按正确顺序运行,并正确管理步骤之间的依赖关系。

当我在第二章介绍管道编排的挑战时,我还介绍了工作流编排平台(也称为工作流管理系统(WMSs)、编排平台编排框架)的概念。本章将重点介绍 Apache Airflow,这是最流行的此类框架之一。尽管本章大部分内容都专注于 Airflow 中的示例,但这些概念也适用于其他框架。实际上,我稍后在本章中也提到了一些 Airflow 的替代方案。

最后,本章的后续部分讨论了管道编排中一些更高级的概念,包括在数据基础架构上协调多个管道。

有向无环图

虽然我在第二章介绍了 DAGs,但值得重复一下它们是什么。本章讨论了它们在 Apache Airflow 中的设计和实现,用于编排数据管道中的任务。

管道步骤(任务)始终是有向的,这意味着它们从一个或多个任务开始,并以特定的任务或任务结束。这是为了保证执行路径。换句话说,它确保任务在其所有依赖任务成功完成之前不会运行。

管道图必须也是无环的,这意味着一个任务不能指向先前已完成的任务。换句话说,它不能循环。如果可以的话,管道将无休止地运行!

您可能还记得来自第二章的 DAG 的以下示例,它在图 7-1 中有所说明。这是在 Apache Airflow 中定义的一个 DAG。

dppr 0203

图 7-1。一个具有四个任务的 DAG。在任务 A 完成后,任务 B 和任务 C 运行。当它们都完成时,任务 D 运行。

Airflow 中的任务可以代表从执行 SQL 语句到执行 Python 脚本的任何内容。正如您将在接下来的章节中看到的,Airflow 允许您定义、调度和执行数据管道中的任务,并确保它们按正确的顺序运行。

Apache Airflow 设置和概述

Airflow 是一个由 Maxime Beauchemin 于 2014 年在 Airbnb 启动的开源项目。它于 2016 年 3 月加入了 Apache 软件基金会的孵化器计划。Airflow 的建立旨在解决数据工程团队面临的常见挑战:如何构建、管理和监控涉及多个具有相互依赖关系的任务的工作流程(特别是数据管道)。

自首次发布以来的六年里,Airflow 已成为数据团队中最受欢迎的工作流管理平台之一。其易于使用的 Web 界面、高级命令行实用程序、内置调度程序以及高度可定制化的特性意味着它几乎适用于任何数据基础设施。尽管是用 Python 构建的,但它可以执行任何语言或平台上运行的任务。事实上,尽管最常用于管理数据管道,但它实际上是一种用于编排任何类型依赖任务的通用平台。

注意

本章中的代码示例和概述参考的是 Airflow 版本 1.x。Airflow 2.0 正在接近,承诺带来一些重大增强,如全新的 Web UI、改进的调度程序、全功能的 REST API 等。尽管本章具体内容涉及 Airflow 1.x,但这些概念在 Airflow 2.0 中仍然适用。此外,此处提供的代码旨在与 Airflow 2.0 兼容,几乎无需修改。

安装和配置

安装 Airflow 非常简单。您需要使用pip,这在“设置 Python 环境”中已介绍过。当您首次安装和启动 Airflow 时,会介绍其一些组件,如 Airflow 数据库、Web 服务器和调度程序。在接下来的章节中,我将定义每个组件及其如何进一步配置。

您可以按照官方Airflow 快速入门指南的安装说明进行操作。这通常不到五分钟!

安装完 Airflow 并且 Web 服务器运行后,您可以在浏览器中访问http://localhost:8080来查看 Airflow Web 界面。如果您想了解更多关于 Airflow 各组件及其配置的信息,本节其余部分将详细介绍每个组件。如果您准备构建您的第一个 Airflow DAG,可以直接跳转到“构建 Airflow DAGs”。

对于更高级的 Airflow 部署,建议查阅官方Airflow 文档

Airflow 数据库

Airflow 使用数据库来存储与每个任务和 DAG 执行历史相关的所有元数据,以及您的 Airflow 配置。默认情况下,Airflow 使用 SQLite 数据库。在安装过程中运行airflow initdb命令时,Airflow 会为您创建一个 SQLite 数据库。对于学习 Airflow 或者小规模项目来说,这是可以接受的。然而,对于更大规模的需求,我建议使用 MySQL 或 Postgres 数据库。幸运的是,Airflow 在幕后使用备受推崇的SqlAlchemy库,并且可以轻松重新配置以使用这些数据库,而不是 SQLite。

要更改 Airflow 使用的数据库,请打开airflow.cfg文件,该文件位于安装期间用于AIRFLOW_HOME的路径中。在安装示例中,那是~/airflow。在文件中,您将看到一个用于sql_alchemy_conn配置的行。它看起来像这样:

# The SqlAlchemy connection string to the metadata database.
# SqlAlchemy supports many different database engine, more information
# their website
sql_alchemy_conn = sqlite:////Users/myuser/airflow/airflow.db

默认情况下,该值设置为本地 SQLite 数据库的连接字符串。在以下示例中,我将创建和配置一个 Postgres 数据库和用户供 Airflow 使用,然后配置 Airflow 以使用新的数据库而不是默认的 SQLite 数据库。

请注意,我假设您已经运行了一个 Postgres 服务器,并且有权限在psql中运行(Postgres 交互式终端)和创建数据库和用户。任何 Postgres 数据库都可以,但必须能够从安装 Airflow 的机器访问它。要了解有关安装和配置 Postgres 服务器的更多信息,请参阅官方网站。您也可以在像 AWS 这样的平台上使用托管的 Postgres 实例。只要安装 Airflow 的机器能够访问它即可。

首先,在命令行上启动psql或以其他方式打开连接到您的 Postgres 服务器的 SQL 编辑器。

现在,创建一个用户供 Airflow 使用。为了简单起见,命名为airflow。此外,为用户设置密码:

CREATE USER airflow;
ALTER USER airflow WITH PASSWORD 'pass1';

接下来,为 Airflow 创建一个数据库。我将其命名为airflowdb

CREATE DATABASE airflowdb;

最后,授予新用户在新数据库上的所有权限。Airflow 需要读取和写入数据库:

GRANT ALL PRIVILEGES
  ON DATABASE airflowdb TO airflow;

现在,您可以返回并修改airflow.cfg文件中的连接字符串。我假设您的 Postgres 服务器正在与 Airflow 运行在同一台机器上,但如果不是,则需要通过将localhost替换为 Postgres 运行的主机的完整路径来修改以下内容。完成后保存airflow.cfg

sql_alchemy_conn = postgresql+psycopg2://airflow:pass1@localhost:5432/airflowdb

由于 Airflow 将通过 Python 连接到 Postgres 数据库,您还需要安装psycopg2库:

$ pip install psycopg2

最后,返回命令行,在 Postgres 中重新初始化 Airflow 数据库:

$ airflow initdb

从现在开始,您可以在 Postgres 服务器的airflowdb数据库中找到所有 Airflow 元数据。那里有大量信息,包括任务历史记录,可以查询。您可以直接从 Postrgres 数据库或 Airflow Web UI 中进行查询,如下一节所述。通过 SQL 查询数据使得分析和报告的机会无限。没有比使用默认情况下 Airflow 收集的数据更好的方法来分析您的流水线的性能!在本书的第十章中,我将讨论使用这些数据和其他数据来衡量和监控数据流水线的性能。

Web 服务器和 UI

在安装后通过运行 airflow webs erver -p 8080 命令启动 Web 服务器后,您可能已经偷偷看过其内容。如果没有,请打开 Web 浏览器并导航至 http://localhost:8080。如果您使用的是全新安装的 Airflow,您将看到类似于 图 7-2 的页面。

dppr 0702

图 7-2. Airflow Web UI。

Web UI 的首页显示了 DAG 列表。正如您所见,Airflow 包含一些示例 DAG。如果您对 Airflow 还不熟悉,这些示例是一个很好的起点。当您创建自己的 DAG 时,它们也会显示在这里。

页面上每个 DAG 都有许多链接和信息:

  • 一个链接,用于打开 DAG 属性,包括源文件路径、标签、描述等信息。

  • 切换按钮,用于启用和暂停 DAG。启用时,第四列中定义的计划决定运行时间。暂停时,计划被忽略,只能通过手动执行运行 DAG。

  • DAG 的名称,点击后会带您到 DAG 详细页面,如 图 7-3 所示。

  • DAG 在未暂停时运行的计划。以 crontab 格式 显示,并在 DAG 源文件中定义。

  • DAG 的所有者。通常情况下是 airflow,但在复杂的部署中,可能有多个所有者可供选择。

  • 最近的任务,这是最新 DAG 运行的摘要。

  • 最后一次 DAG 运行的时间戳。

  • 先前 DAG 运行的摘要。

  • 一组链接,指向各种 DAG 的配置和信息。如果点击 DAG 的名称,也会看到这些链接。

当您点击 DAG 的名称时,会跳转到 DAG 详细页面的树形视图,在 图 7-3 中展示。这是 Airflow 提供的示例 example_python_operator DAG。该 DAG 包含五个任务,全部是 PythonOperators(您将在本节后面学习有关运算符的内容)。在 print_the_context 任务成功完成后,会启动五个任务。任务完成后,DAG 运行完成。

dppr 0703

图 7-3. Airflow DAG 的树形视图。

您也可以点击页面顶部的图形视图按钮,查看 DAG 的图形视图。我发现这个视图非常有用。您可以在 图 7-4 中看到这个特定 DAG 的图形表示。

在包含大量任务的复杂 DAG 中,图形视图可能在屏幕上显示时会有些难以看清。但请注意,您可以使用鼠标放大、缩小和滚动图形。

dppr 0704

图 7-4. Airflow DAG 的图形视图。

屏幕上还有许多其他选项,其中许多是不言自明的。但我想重点介绍另外两个选项:代码和触发 DAG。

当您点击 Code 时,您当然会看到 DAG 背后的代码。您将注意到的第一件事是,DAG 是在 Python 脚本中定义的。在本例中,文件名为example_python_operator.py。稍后,您将了解有关 DAG 源文件结构的更多信息。目前,重要的是知道它保存了 DAG 的配置,包括其调度、每个任务的定义以及每个任务之间的依赖关系。

触发 DAG 按钮允许您按需执行 DAG。尽管 Airflow 是为了按计划运行 DAG 而构建的,在开发期间,在测试期间以及在生产环境中的非计划需求中,这是立即运行 DAG 的最简单方式。

除了管理 DAGs 之外,Web UI 的许多其他功能也会很有用。在顶部导航栏中,如果您点击数据分析,您将看到 Ad Hoc 查询、图表和已知事件的选项。在这里,您可以查询 Airflow 数据库中的信息,如果您不愿直接从其他工具连接到它。

在浏览下,您可以找到 DAG 的运行历史记录和其他日志文件,在管理员中,您可以找到各种配置设置。您可以在官方 Airflow 文档中了解更多关于高级配置选项的信息。

调度器

Airflow 调度器是您在本章前面运行airflow scheduler命令时启动的服务。运行时,调度器不断监视 DAG 和任务,并运行已计划运行或已满足依赖关系的任务(对于 DAG 中的任务)。

调度器使用在airflow.cfg文件的[core]部分中定义的执行器来运行任务。您可以在以下部分了解更多关于执行器的信息。

执行器

执行器是 Airflow 用来运行调度器确定已准备运行的任务的工具。Airflow 支持多种类型的执行器。默认情况下,使用SequentialExecutor。您可以在airflow.cfg文件中更改执行器的类型。在文件的core部分下,您将看到一个executor变量,可以设置为此部分和 Airflow 文档中列出的任何执行器类型。正如您所看到的,当首次安装 Airflow 时设置了SequentialExecutor

[core]
........

# The executor class that airflow should use. Choices include
# SequentialExecutor, LocalExecutor, CeleryExecutor, DaskExecutor, KubernetesExecutor
executor = SequentialExecutor

尽管默认情况下,SequentialExecutor并不适合生产环境,因为它只能同时运行一个任务。对于测试简单的 DAGs 是可以接受的,但仅限于此。然而,它是与 SQLite 数据库兼容的唯一执行器,因此如果您还没有配置 Airflow 与其他数据库,那么SequentialExecutor就是您唯一的选择。

如果你计划在任何规模上使用 Airflow,我建议你使用其他执行器,如CeleryExecutorDaskExecutorKubernetesExecutor。你的选择应该部分取决于你最熟悉的基础设施。例如,要使用CeleryExecutor,你需要设置一个 Celery 代理,使用 RabbitMQ、Amazon SQL 或 Redis。

配置每个执行器所需的基础设施超出了本书的范围,但本节中的示例即使在SequentialExecutor上也可以运行。你可以在它们的文档中了解更多关于 Airflow 执行器的信息。

运算符

请记住,DAG 中的每个节点都是一个任务。在 Airflow 中,每个任务都实现了一个运算符。运算符实际上执行脚本、命令和其他操作。这里有一些常见的运算符:

  • BashOperator

  • PythonOperator

  • SimpleHttpOperator

  • EmailOperator

  • SlackAPIOperator

  • MySqlOperatorPostgresOperator,以及其他特定于数据库的运算符,用于执行 SQL 命令。

  • Sensor

正如你将在接下来的章节中学到的那样,运算符被实例化并分配给 DAG 中的每个任务。

构建 Airflow 的 DAG。

现在你知道了 Airflow 的工作原理,是时候构建一个 DAG 了!虽然 Airflow 附带了一些示例 DAG,但我将按照本书之前的一些示例构建一个 DAG,执行一个示例 ELT 过程的步骤。具体来说,它将从数据库中提取数据,将其加载到数据仓库中,然后将数据转换为数据模型。

一个简单的 DAG

在构建示例 ELT DAG 之前,了解如何在 Airflow 中定义 DAG 是很重要的。DAG 在 Python 脚本中定义,其中结构和任务依赖关系都是用 Python 代码编写的。示例 7-1 是一个简单 DAG 的定义,包含三个任务。它被称为DAG 定义文件。每个任务都定义为一个BashOperator,第一个和第三个任务打印一些文本,第二个任务休眠三秒钟。虽然它没有做任何特别有用的事情,但它是完全功能的,代表了稍后你将编写的 DAG 定义。

示例 7-1. simple_dag.py
from datetime import timedelta
from airflow import DAG
from airflow.operators.bash_operator \
    import BashOperator
from airflow.utils.dates import days_ago

dag = DAG(
    'simple_dag',
    description='A simple DAG',
    schedule_interval=timedelta(days=1),
    start_date = days_ago(1),
)

t1 = BashOperator(
    task_id='print_date',
    bash_command='date',
    dag=dag,
)

t2 = BashOperator(
    task_id='sleep',
    depends_on_past=False,
    bash_command='sleep 3',
    dag=dag,
)

t3 = BashOperator(
    task_id='print_end',
    depends_on_past=False,
    bash_command='echo \'end\'',
    dag=dag,
)

t1 >> t2
t2 >> t3

在你继续并运行 DAG 之前,我想指出 DAG 定义文件的关键特性。首先,像任何 Python 脚本一样,需要导入必要的模块。接下来,定义 DAG 本身并分配一些属性,如名称(simple_dag)、调度时间、开始日期等等。实际上,在这个简单示例中我没有定义的属性还有很多,你可能需要在本章节或官方 Airflow 文档中找到它们。

接下来,我定义了 DAG 中的三个任务。所有任务都是BashOperator类型,这意味着当执行时,它们将运行一个 bash 命令。每个任务还分配了几个属性,包括一个称为task_id的字母数字标识符,以及任务执行时运行的 bash 命令。正如稍后您将看到的那样,每种运算符类型都有其自定义属性,就像BashOperatorbash_command一样。

DAG 定义的最后两行定义了任务之间的依赖关系。阅读方法是,当任务t1完成时,t2运行。当t2完成时,t3运行。当您在 Airflow Web UI 中查看 DAG 时,您会在树形和图形视图中看到这一点。

要运行 DAG,您需要将其定义文件保存在 Airflow 查找 DAG 的位置。您可以在airflow.cfg文件中找到这个位置(或修改它):

dags_folder = /Users/myuser/airflow/dags

将 DAG 定义保存在名为simple_dag.py的文件中,并将其放置在dags_folder位置。如果您已经运行 Airflow Web UI 和调度器,请刷新 Airflow Web UI,您应该在列表中看到名为simple_dag的 DAG。如果没有,请等待几秒钟然后重试,或者停止并重新启动 Web 服务。

接下来,点击 DAG 名称以查看更详细的信息。您将能够查看 DAG 的图形和树形视图,以及您刚刚编写的代码。准备好尝试了吗?在这个屏幕上或回到主页上,将切换按钮翻转,使 DAG 设置为 On,如图 7-5 所示。

dppr 0705

图 7-5。一个启用的 DAG。

请回忆一下,在代码中,DAG 的schedule_interval属性设置为timedelta(days=1)。这意味着 DAG 被设置为每天在 UTC 时间午夜运行一次。你会在 Airflow 首页和 DAG 详细页面上看到这个调度的反映。同时请注意,DAG 的start_date属性设置为days_ago(1)。这意味着 DAG 的第一次运行被设置为当前日期的前一天。当 DAG 被设置为启动时,第一次调度运行是在 UTC 时间前一天的 0:00:00,因此将在执行器有空闲时立即执行。

您可以在 DAG 详细页面上检查 DAG 运行的状态,或者通过导航到顶部菜单中的 Browse → DAG Runs 来查看。从那里,您应该能够看到 DAG 运行的可视状态,以及 DAG 中每个任务的状态。图 7-6 显示了一个simple_dag示例的运行,其中所有任务都成功完成。DAG 的最终状态标记为屏幕左上角的“success”。

如果你想按需运行 DAG,请在 DAG 详细页面点击触发 DAG 按钮。

dppr 0706

图 7-6。一个 Airflow DAG 的图形视图。

一个 ELT 管道的 DAG

现在你知道如何创建一个简单的 DAG 后,你可以构建一个用于数据管道的抽取、加载和转换步骤的功能性 DAG。这个 DAG 包含五个任务。

前两个任务使用BashOperator来执行两个不同的 Python 脚本,每个脚本从 Postgres 数据库表中提取数据,并将结果作为 CSV 文件发送到 S3 存储桶。虽然我不会在这里重新创建脚本的逻辑,但你可以在“从 PostgreSQL 数据库提取数据”中找到它。事实上,如果你想从 MySQL 数据库、REST API 或 MongoDB 数据库中提取数据,你可以使用该章节中的任何提取示例。

每当这些任务完成时,将执行相应的任务,从 S3 存储桶中加载数据到数据仓库中。再次,每个任务都使用BashOperator来执行包含加载 CSV 逻辑的 Python 脚本。你可以在“将数据加载到 Snowflake 数据仓库”或者“将数据加载到 Redshift 数据仓库”中找到相关示例代码,具体取决于你使用的平台。

DAG 中的最后一个任务使用PostgresOperator来执行一个 SQL 脚本(存储在.sql文件中),该脚本用于在数据仓库中创建数据模型。你可能还记得这一逻辑来自第六章。这五个任务组成了一个简单的流水线,遵循了首次在第三章中介绍的 ELT 模式。

图 7-7 显示了 DAG 的图形视图。

dppr 0707

图 7-7。ELT DAG 示例的图形视图。

示例 7-2 展示了 DAG 的定义。花点时间阅读它,即使我也会详细讲解。你可以将其保存到 Airflow 的dags文件夹中,但暂时不要启用它。

示例 7-2。elt_pipeline_sample.py
from datetime import timedelta
from airflow import DAG
from airflow.operators.bash_operator \
  import BashOperator
from airflow.operators.postgres_operator \
  import PostgresOperator
from airflow.utils.dates import days_ago

dag = DAG(
    'elt_pipeline_sample',
    description='A sample ELT pipeline',
    schedule_interval=timedelta(days=1),
    start_date = days_ago(1),
)

extract_orders_task = BashOperator(
    task_id='extract_order_data',
    bash_command='python /p/extract_orders.py',
    dag=dag,
)

extract_customers_task = BashOperator(
    task_id='extract_customer_data',
    bash_command='python /p/extract_customers.py',
    dag=dag,
)

load_orders_task = BashOperator(
    task_id='load_order_data',
    bash_command='python /p/load_orders.py',
    dag=dag,
)

load_customers_task = BashOperator(
    task_id='load_customer_data',
    bash_command='python /p/load_customers.py',
    dag=dag,
)

revenue_model_task = PostgresOperator(
    task_id='build_data_model',
    postgres_conn_id='redshift_dw',
    sql='/sql/order_revenue_model.sql',
    dag=dag,
)

extract_orders_task >> load_orders_task
extract_customers_task >> load_customers_task
load_orders_task >> revenue_model_task
load_customers_task >> revenue_model_task

从示例 7-1 中,你可能还记得导入了一些必要的 Python 包并创建了一个DAG对象。这一次,为了在 DAG 的最后一个任务中使用PostgresOperator,还需要导入一个额外的包。

最后一个任务利用PostgresOperator执行存储在与 Airflow 同一台机器上的数据仓库上的目录中的 SQL 脚本。SQL 脚本的内容看起来类似于第六章中的数据模型转换。例如,考虑到 DAG 正在提取和加载Orders表和Customers表,我将使用来自第六章的以下示例。当然,你可以使用任何 SQL 查询来匹配你正在处理的数据。

CREATE TABLE IF NOT EXISTS order_summary_daily (
order_date date,
order_country varchar(10),
total_revenue numeric,
order_count int
);

INSERT INTO order_summary_daily
  (order_date, order_country,
  total_revenue, order_count)
SELECT
  o.OrderDate AS order_date,
  c.CustomerCountry AS order_country,
  SUM(o.OrderTotal) AS total_revenue,
  COUNT(o.OrderId) AS order_count
FROM Orders o
INNER JOIN Customers c
  ON c.CustomerId = o.CustomerId
GROUP BY
  o.OrderDate, c.CustomerCountry;

在启用 DAG 之前,还有一个步骤。那就是设置用于PostgresOperator的连接。正如在 DAG 定义中所见,有一个名为postgres_conn_id的参数,其值为redshift_dw。你需要在 Airflow 的 Web UI 中定义redshift_dw连接,以便PostgresOperator可以执行该脚本。

要做到这一点,请按照以下步骤操作:

  1. 打开 Airflow web UI,从顶部导航栏选择 Admin → Connections。

  2. 点击创建标签页。

  3. 将 Conn ID 设置为 redshift_dw(或者你在 DAG 定义文件中想要使用的任何 ID)。

  4. 选择 Conn Type 为 Postgres。

  5. 设置数据库的连接信息。

  6. 点击保存。

请注意,Amazon Redshift 兼容 Postgres 连接,这就是我选择该 Conn Type 的原因。你会在 Snowflake 和数十种其他数据库和平台(如 Spark)中找到连接。

现在,你可以准备启用 DAG。你可以返回主页或查看 DAG 详细页面,然后点击切换按钮将 DAG 设为启用状态。因为 DAG 的调度是每天午夜开始的前一天,所以会立即安排一个运行并执行 DAG。你可以在 DAG 详细页面查看 DAG 运行的状态,或者通过顶部菜单中的 Browse → DAG Runs 导航到 DAG 运行页面。如常,你也可以通过 DAG 详细页面上的 Trigger DAG 按钮触发 DAG 的单次运行。

虽然这个例子有些简化,但它整合了 ELT 管道的步骤。在更复杂的管道中,你会发现许多更多的任务。除了更多的数据抽取和加载之外,可能还会有许多数据模型,其中一些依赖于彼此。Airflow 可以轻松确保它们按正确的顺序执行。在大多数 Airflow 的生产部署中,你会发现许多 DAG 用于可能彼此有依赖关系的管道,或者一些外部系统或流程。查看 “高级编排配置” 获取有关管理此类挑战的一些提示。

其他管道任务

除了前一节样本 ELT 管道中的功能任务外,生产质量的管道还需要其他任务,例如在管道完成或失败时向 Slack 频道发送通知,以及在管道的各个点运行数据验证检查等。幸运的是,所有这些任务都可以由 Airflow DAG 处理。

警报和通知

虽然 Airflow web UI 是查看 DAG 运行状态的好地方,但当 DAG 失败(甚至成功)时,最好是收到电子邮件通知。有多种选项可用于发送通知。例如,如果你希望在 DAG 失败时收到电子邮件,可以在定义文件中实例化 DAG 对象时添加以下参数。如果你只想对特定任务收到通知,也可以将这些参数添加到任务而不是 DAG:

'email': ['me@example.com'],
'email_on_failure': True,

在 Airflow 可以发送邮件给你之前,你需要在 airflow.cfg 文件的 [smtp] 部分提供你的 SMTP 服务器的详细信息。

你还可以在 DAG 中的任务中使用 EmailOperator 发送电子邮件:

email_task = EmailOperator(
            task_id='send_email',
            to="me@example.com",
            subject="Airflow Test Email",
            html_content='some test content',
        )

除了EmailOperator之外,还有官方和社区支持的操作器,用于向 Slack、Microsoft Teams 和其他平台发送消息。当然,您也可以始终创建自己的 Python 脚本,将消息发送到您选择的平台,并使用BashOperator执行它。

数据验证检查

第八章详细讨论了数据验证和测试管道,但在您的 Airflow DAG 中添加任务以验证数据是一个良好的实践。正如您将在该章中了解到的那样,数据验证可以通过 SQL 或 Python 脚本实现,或者通过调用其他外部应用程序来实现。到目前为止,您已经知道 Airflow 可以处理它们全部!

高级编排配置

前一节介绍了一个简单的 DAG,它运行了一个完整的端到端数据管道,遵循了 ELT 模式。本节介绍了在构建更复杂的管道或需要协调具有共享依赖或不同调度的多个管道时可能遇到的一些挑战。

耦合与非耦合管道任务

尽管迄今为止的示例可能会让人觉得数据管道中的所有步骤(任务)都能够清晰地链接在一起,但情况并非总是如此。以流数据摄入为例。例如,假设使用 Kafka 将数据流式传输到 S3 存储桶,然后使用 Snowpipe 将数据连续加载到 Snowflake 数据仓库中(请参阅第四章和第五章)。

在这种情况下,数据持续流入数据仓库,但将数据转换的步骤仍然计划按照固定间隔运行,例如每 30 分钟一次。与示例 7-2 中的 DAG 不同,数据摄入的特定运行并不是将数据转换为数据模型任务的直接依赖项。在这种情况下,任务被称为非耦合,而不是 DAG 中的耦合任务。

鉴于这一现实,数据工程师必须在管道的编排方式上进行深思熟虑。虽然没有硬性规定,但在整个管道中进行一致和弹性的决策是管理解耦任务的必要条件。例如,在流数据导入和定期转换步骤的示例中,转换逻辑必须考虑来自两个不同来源(例如OrdersCustomers表)的数据可能处于稍微不同的刷新状态。转换逻辑必须考虑存在仅具有订单记录而没有相应客户记录的情况,例如。

何时拆分 DAG

在设计管道时的一个关键决策点是确定哪些任务应该组成一个 DAG。虽然可能创建一个包含数据基础设施中所有的抽取、加载、转换、验证和警报任务的 DAG,但这将很快变得非常复杂。

决定何时将任务拆分成多个 DAG 以及何时将其保留在单个 DAG 中,有三个因素需要考虑:

当任务需要在不同的计划下运行时,拆分成多个 DAG

如果某些任务只需每天运行一次,而其他任务每 30 分钟运行一次,你可能应该将它们拆分为两个 DAG。否则,你将浪费时间和资源来额外运行某些任务 47 次!在计算成本经常基于实际使用的情况下,这是一个大问题。

当一个流水线是真正独立的时候,保持它们分开

如果管道中的任务仅相互关联,则保持它们在一个单独的 DAG 中。回顾示例 7-2,如果OrdersCustomer表的摄取仅由该 DAG 中的数据模型使用,并且没有其他任务依赖于数据模型,则保持 DAG 独立是有意义的。

当一个 DAG 变得过于复杂时,确定是否可以逻辑上分解它

虽然这有些主观,但如果你发现自己看着一个具有数百个任务和依赖箭头的 DAG 图形视图,那么现在是考虑如何拆分 DAG 的时候了。否则,将来可能会难以维护。

尽管处理可能共享依赖的多个 DAG(例如数据摄取)可能看起来很头疼,但通常是必要的。在接下来的部分,我将讨论如何在 Airflow 中实现跨 DAG 的依赖关系。

使用 Sensor 协调多个 DAG

给定需要在 DAG 之间共享依赖的需求,Airflow 任务可以实现一种称为Sensor的特殊操作器。Airflow 的Sensor设计用于检查某些外部任务或进程的状态,一旦满足检查条件,就会继续执行其 DAG 中下游的依赖任务。

如果需要协调两个不同的 Airflow DAG,可以使用ExternalTaskSensor来检查另一个 DAG 中任务的状态或整个另一个 DAG 的状态。示例 7-3 定义了一个具有两个任务的 DAG。第一个任务使用ExternalTaskSensor来检查本章前面部分中elt_pipeline_sample DAG 的状态。当该 DAG 完成时,Sensor标记为“成功”,然后执行第二个任务(“task1”)。

示例 7-3. sensor_test.py
from datetime import datetime
from airflow import DAG
from airflow.operators.dummy_operator \
  import DummyOperator
from airflow.sensors.external_task_sensor \
  import ExternalTaskSensor
from datetime import timedelta
from airflow.utils.dates import days_ago

dag = DAG(
        'sensor_test',
        description='DAG with a sensor',
        schedule_interval=timedelta(days=1),
        start_date = days_ago(1))

sensor1 = ExternalTaskSensor(
            task_id='dag_sensor',
            external_dag_id = 'elt_pipeline_sample',
            external_task_id = None,
            dag=dag,
            mode = 'reschedule',
            timeout = 2500)

task1 = DummyOperator(
            task_id='dummy_task',
            retries=1,
            dag=dag)

sensor1 >> task1

图 7-8 显示了 DAG 的图形视图。

dppr 0708

图 7-8. 示例 ELT DAG 的图形视图。

启用时,此 DAG 将首先启动dag_sensor任务。请注意其属性:

  • external_dag_id设置为Sensor将监视的 DAG 的 ID。在本例中,它是elt_pipeline_sample DAG。

  • 在这种情况下,external_task_id属性设置为None,这意味着Sensor正在等待整个elt_pipeline_sample DAG 成功完成。如果你将其设置为elt_pipeline_sample DAG 中的特定task_id,那么一旦该task_id成功完成,sensor1将完成并启动dummy_task

  • mode 属性设置为 reschedule。默认情况下,传感器以 poke 模式运行。在该模式下,传感器会阻塞一个工作槽位,同时poke以检查外部任务。根据您使用的执行器类型以及运行的任务数量,这并不理想。在 reschedule 模式下,通过重新调度任务释放工作槽位,从而使工作槽位空闲,直到再次设置为运行。

  • timeout 参数设置为 ExternalTaskSensor 在超时之前继续检查其外部依赖的秒数。在这里设置一个合理的超时时间是一个良好的实践;否则,DAG 将无限期地继续运行。

需要记住的一点是 DAG 按特定的时间表运行,因此 Sensor 需要检查特定的 DAG 运行。默认情况下,ExternalTaskSensor 将检查其所属 DAG 的当前时间表下的 external_dag_id 的运行。由于 elt_pipeline_samplesensor_test DAG 每天午夜运行一次,使用默认设置就可以了。但是,如果两个 DAG 按不同的时间表运行,则最好指定 Sensor 应该检查哪个 elt_pipeline_sample 的运行。您可以使用 execution_deltaexecution_date_fn 参数来实现这一点。execution_date_fn 参数定义了 DAG 运行的特定日期时间,但我发现它比 execution_delta 不太有用。

execution_delta 参数可用于查看 DAG 的特定运行。例如,要查看每 30 分钟调度一次的 DAG 的最近运行,您可以创建一个任务,定义如下:

sen1 = ExternalTaskSensor(
          task_id='dag_sensor',
          external_dag_id = 'elt_pipeline_sample',
          external_task_id = None,
          dag=dag,
          mode = 'reschedule',
          timeout = 2500,
          execution_delta=timedelta(minutes=30))

托管 Airflow 选项

虽然安装一个简单的 Airflow 实例相当简单,但在生产规模上就变得更具挑战性。处理更复杂的执行器以处理任务更大的并行性,保持您的实例最新,并扩展基础资源是不是每位数据工程师都有时间承担的工作。

与许多其他开源工具类似,Airflow 有几种完全托管的解决方案。其中两个最著名的是 Cloud Composer 在 Google Cloud 上和 Astronomer。尽管您将支付月费,远远超过在自己的服务器上运行 Airflow 的成本,但 Airflow 的管理方面将得到照顾。

类似于本书中许多建设与购买的决策,根据您的具体情况,选择自托管 Airflow 还是选择托管解决方案。

  • 您是否有系统运维团队可以帮助您进行自托管?

  • 您的预算是否足以支持托管服务?

  • 您的管道中有多少 DAG 和任务?您的运行规模是否足够高,需要更复杂的 Airflow 执行器?

  • 您的安全和隐私需求是什么?您是否愿意允许外部服务连接到您的内部数据和系统?

其他编排框架

尽管本章重点讨论 Airflow,但这并不是唯一的选择。还有一些其他出色的编排框架,例如LuigiDagster。而针对机器学习管道编排的Kubeflow Pipelines也受到了广泛支持,在机器学习社区中非常流行。

在数据模型转换步骤的编排方面,由 Fishtown Analytics 提供的dbt是一个出色的选择。像 Airflow 一样,它是一个用 Python 构建的开源产品,因此您可以免费在自己的环境中运行,或选择付费的托管版本,称为dbt Cloud。一些组织选择使用 Airflow 或其他通用编排工具进行数据摄取,并运行诸如 Spark 作业之类的任务,但然后使用 dbt 来转换其数据模型。在这种情况下,dbt 作业运行由 Airflow DAG 中的任务触发,dbt 可以自行处理数据模型之间的依赖关系。有关使用 dbt 的示例,可以参考第九章。

第八章:管道中的数据验证

即使在设计最佳的数据管道中,某些事情也可能出错。通过良好的流程、编排和基础设施设计,可以避免或至少减轻许多问题。然而,要确保数据本身的质量和有效性,你需要投入数据验证。最好假设未经测试的数据在分析中使用时并不安全。本章讨论了在 ELT 管道的各个步骤中进行数据验证的原则。

早期验证,频繁验证

尽管有良好的意图,某些数据团队将数据验证留到管道末端,并在转换期间甚至在所有转换完成后实施某种验证。在这种设计中,他们认为数据分析师(通常拥有变换逻辑)最适合理解数据并确定是否存在任何质量问题。

在这种设计中,数据工程师专注于将数据从一个系统移动到另一个系统,编排管道,并维护数据基础设施。尽管这是数据工程师的角色,但缺少一点:通过忽略流经管道中每个步骤的数据内容,他们将信任从中摄取的源系统所有者、自己的摄取过程以及转换数据的分析师。尽管这种责任分离听起来效率很高,但当发现质量问题时,调试过程可能效率低下。

在管道末端发现数据质量问题并追溯到起始点是最糟糕的情况。通过在管道的每个步骤进行验证,你更有可能在当前步骤找到根本原因,而不是之前的步骤。

尽管不能期望数据工程师具备足够的上下文来为每个数据集执行验证,但他们可以通过编写非上下文验证检查并提供基础设施和模板来引导那些接近管道每个步骤的团队成员和利益相关者执行更具体的验证。

源系统数据质量

鉴于将大量源系统摄取到典型数据仓库中,很可能在数据摄取过程中某个时刻会将无效数据引入到仓库中。尽管可能会认为在摄取之前,源系统所有者会发现某种形式的无效数据,但通常情况并非如此,原因有几个:

无效数据可能不会影响源系统本身的功能。

源系统应用的逻辑可能会通过应用层对表中的重复/模糊记录进行去重复,或在应用本身中用默认值填充 NULL 日期值。

源系统中的记录孤立时可能正常运行。

例如,可能会删除Customer记录,但与该客户相关的Order记录可能仍然存在。尽管应用程序可能会忽略这些Order记录,但这种情况肯定会影响数据分析。

在源系统中可能存在尚未发现或修复的错误

在我的职业生涯中,我遇到过多次数据团队识别源系统中关键问题的情况!

注意

无论原因如何,关键是数据工程师不应假设他们摄取的数据没有质量问题,即使加载到仓库中的数据完全匹配其来源。

数据摄取风险

除了源系统中的质量问题外,数据摄取过程本身可能导致数据质量问题。以下是一些常见例子:

摄取中提取或加载步骤中的系统停机或超时

尽管有时这种情况会引发严重错误并停止管道,但在其他情况下,“静默”故障将导致部分提取或加载数据集。

增量摄取中的逻辑错误

从第四章和第五章回想起增量提取的模式。从数据仓库中读取表中最新记录的时间戳,然后提取源系统中时间戳更晚的任何记录,以便加载到数据仓库中。在 SQL 语句中简单地使用“大于或等于”运算符而不是“大于”可能导致重复记录被加载。还有许多其他可能性,比如系统之间的时区不一致。

提取文件中的解析问题

如你在第四章和第五章中所述,从源系统中提取数据,存储在诸如 CSV 这样的平面文件中,然后从该文件加载到数据仓库中是很典型的。当数据从源系统转换为平面文件时,有时会包含特殊字符或其他意外的字符编码。根据数据工程师和数据仓库加载机制处理这些情况的方式,可能会导致记录被丢弃或新加载的记录中的数据格式错误。

注意

与假设源系统将呈现有效数据一样,假设数据摄取“仅仅”提取和加载数据是不合适的。

启用数据分析师验证

在验证已加载到数据仓库中的数据以及转换为数据模型的数据方面,数据分析师通常是最适合拥有验证权利的人。他们是了解原始数据和每个数据模型业务背景的人(见第六章)。然而,数据工程师的任务是为分析师提供他们在数据管道中定义和执行数据验证所需的工具。当然,对于像行数统计和重复记录等较少上下文的验证,数据工程师应该在管道的早期参与验证过程中。

接下来的部分介绍了一个简化的框架,可以供分析师和数据工程师在管道中实现数据验证检查。最后一节提到了一些开源和商业框架,可以用于相同的目的。无论你选择哪种工具,重要的是为工程师和分析师提供一种可靠的方法来编写和执行验证测试,同时尽量减少摩擦。虽然数据团队中的每个人通常都同意有效的数据很重要,但如果实施验证的门槛很高,你会发现它会被新开发和其他优先事项所拖后腿。

一个简单的验证框架

在这一部分中,我定义了一个完全功能的数据验证框架,用 Python 编写,旨在执行基于 SQL 的数据验证检查。像本书中的其他示例一样,它是高度简化的,并且缺少生产环境中所期望的许多功能。换句话说,它并不打算处理所有的数据验证需求。然而,我的目标是介绍这种框架的关键概念,同时分享一些可以扩展和改进以适应你的基础设施的东西。

这个简化版本的框架支持有限的功能,可以检查验证测试中可以检查的结果类型以及如何批量执行测试,但没有更多功能。如果你想将其用作起点,我在本节中还提到了一些可能的扩展来扩展该框架。即使你选择使用现成的框架,我相信理解这种高度简化方法所涉及的概念也是有价值的。

验证器框架代码

这个框架的总体概念是一个 Python 脚本,它执行一对 SQL 脚本,并根据比较运算符进行比较。每个脚本的组合和结果被视为一个验证测试,测试结果根据执行脚本的结果与预期结果的比较而定,可以通过或失败。例如,一个脚本可能会计算给定日期表中的行数,第二个脚本计算前一天的行数,比较运算符>=则检查当前日期的行数是否比前一天多。如果是,则测试通过;否则,测试失败。

注意,SQL 脚本中的一个也可以返回静态值,如整数。正如您在“验证测试示例”中所看到的那样,该方法用于检查表中的重复行。虽然简单,但此框架可以处理广泛的验证逻辑。

使用命令行参数,您可以告诉验证器执行特定的脚本对以及用于比较的运算符。然后,它执行并返回一个通过/失败代码。返回值可以用于触发 Airflow DAG 中的各种操作,如本节稍后所示,或被任何其他执行验证器的过程所消耗。

示例 8-1 显示了验证器的代码。此版本设置为使用psycopg2 Python 库执行针对 Amazon Redshift 数据仓库的测试。它还使用了第四章和第五章中相同的pipeline.conf配置文件来访问仓库的凭据。您可以轻松地修改此脚本以访问 Snowflake 数据仓库,如第 5 章中的示例,或其他您选择的数据仓库。唯一的区别将是您用于连接和执行查询的库。您还需要确保您的 Python 环境设置正确,并激活虚拟环境。有关更多信息,请参阅“设置您的 Python 环境”。

示例 8-1. validator.py
import sys
import psycopg2
import configparser

def connect_to_warehouse():
    # get db connection parameters from the conf file
    parser = configparser.ConfigParser()
    parser.read("pipeline.conf")
    dbname = parser.get("aws_creds", "database")
    user = parser.get("aws_creds", "username")
    password = parser.get("aws_creds", "password")
    host = parser.get("aws_creds", "host")
    port = parser.get("aws_creds", "port")

    rs_conn = psycopg2.connect(
        "dbname=" + dbname
        + " user=" + user
        + " password=" + password
        + " host=" + host
        + " port=" + port)

    return rs_conn

# execute a test made of up two scripts
# and a comparison operator
# Returns true/false for test pass/fail
def execute_test(
        db_conn,
        script_1,
        script_2,
        comp_operator):

    # execute the 1st script and store the result
    cursor = db_conn.cursor()
    sql_file = open(script_1, 'r')
    cursor.execute(sql_file.read())

    record = cursor.fetchone()
    result_1 = record[0]
    db_conn.commit()
    cursor.close()

    # execute the 2nd script and store the result
    cursor = db_conn.cursor()
    sql_file = open(script_2, 'r')
    cursor.execute(sql_file.read())

    record = cursor.fetchone()
    result_2 = record[0]
    db_conn.commit()
    cursor.close()

    print("result 1 = " + str(result_1))
    print("result 2 = " + str(result_2))

    # compare values based on the comp_operator
    if comp_operator == "equals":
        return result_1 == result_2
    elif comp_operator == "greater_equals":
        return result_1 >= result_2
    elif comp_operator == "greater":
        return result_1 > result_2
    elif comp_operator == "less_equals":
        return result_1 <= result_2
    elif comp_operator == "less":
        return result_1 < result_2
    elif comp_operator == "not_equal":
        return result_1 != result_2

    # if we made it here, something went wrong
    return False

if __name__ == "__main__":

    if len(sys.argv) == 2 and sys.argv[1] == "-h":
        print("Usage: python validator.py"
          + "script1.sql script2.sql "
          + "comparison_operator")
        print("Valid comparison_operator values:")
        print("equals")
        print("greater_equals")
        print("greater")
        print("less_equals")
        print("less")
        print("not_equal")

        exit(0)

    if len(sys.argv) != 4:
        print("Usage: python validator.py"
          + "script1.sql script2.sql "
          + "comparison_operator")
        exit(-1)

    script_1 = sys.argv[1]
    script_2 = sys.argv[2]
    comp_operator = sys.argv[3]

    # connect to the data warehouse
    db_conn = connect_to_warehouse()

    # execute the validation test
    test_result = execute_test(
                    db_conn,
                    script_1,
                    script_2,
                    comp_operator)

    print("Result of test: " + str(test_result))

    if test_result == True:
        exit(0)
    else:
        exit(-1)

下面的小节描述了此框架设计用于运行的验证测试的结构以及如何从命令行运行测试以及 Airflow DAG。在下一节中,我将基于常见的测试类型分享一些示例验证测试。

验证测试的结构

正如前面的小节中简要描述的那样,此框架中的验证测试由三个部分组成:

  • 运行脚本并返回单个数值的 SQL 文件

  • 第二个运行脚本并返回单个数值的 SQL 文件

  • 一个“比较运算符”,用于比较从 SQL 脚本返回的两个值

让我们看一个简单的例子,检查两个表的行数是否相同。在示例 8-2 中,SQL 脚本计算名为Orders的表中的行数,而在示例 8-3 中,SQL 脚本从另一个名为Orders_Full的表中获取相同的计数。

示例 8-2. order_count.sql
SELECT COUNT(*)
FROM Orders;
示例 8-3. order_full_count.sql
SELECT COUNT(*)
FROM Orders_Full;

您可以使用以下 SQL 创建和填充本章中使用的OrdersOrders_Full表:

CREATE TABLE Orders (
  OrderId int,
  OrderStatus varchar(30),
  OrderDate timestamp,
  CustomerId int,
  OrderTotal numeric
);

INSERT INTO Orders
  VALUES(1,'Shipped','2020-06-09',100,50.05);
INSERT INTO Orders
  VALUES(2,'Shipped','2020-07-11',101,57.45);
INSERT INTO Orders
  VALUES(3,'Shipped','2020-07-12',102,135.99);
INSERT INTO Orders
  VALUES(4,'Shipped','2020-07-12',100,43.00);

CREATE TABLE Orders_Full (
  OrderId int,
  OrderStatus varchar(30),
  OrderDate timestamp,
  CustomerId int,
  OrderTotal numeric
);

INSERT INTO Orders_Full VALUES(1,'Shipped','2020-06-09',100,50.05);
INSERT INTO Orders_Full VALUES(2,'Shipped','2020-07-11',101,57.45);
INSERT INTO Orders_Full VALUES(3,'Shipped','2020-07-12',102,135.99);
INSERT INTO Orders_Full VALUES(4,'Shipped','2020-07-12',100,43.00);

最后一个验证测试的部分是要用来比较两个值的比较运算符。在来自示例 8-1 的代码示例中,您可以看到可用于比较运算符的选项,但这里它们带有其关联的逻辑符号(括号内)以供参考:

  • equals

  • greater_equals

  • greater

  • less_equals

  • less

  • not_equal

接下来我们将看看如何运行测试并理解结果。

运行验证测试

使用前一小节中验证测试的示例,可以在命令行上执行如下:

$ python validator.py order_count.sql order_full_count.sql equals

如果Orders表和Orders_Full表的行数相同,则输出如下:

result 1 = 15368
result 2 = 15368
Result of test: True

在命令行上看不到的是退出状态码,在本例中为0,但在测试失败时将为-1。但您可以在程序中消耗此值。下一节将展示如何在 Airflow DAG 中执行此操作。您可能还希望考虑在测试失败时执行像发送 Slack 消息或电子邮件之类的操作。稍后在“扩展框架”中将讨论一些选项。

在 Airflow DAG 中的使用

正如您在第七章中学到的,Airflow 任务可以使用BashOperator执行 Python 脚本。考虑从示例 7-2 中的elt_pipeline_sample DAG 开始。在Orders表被摄取之后(提取和加载任务完成之后),我将添加另一个任务来运行我刚分享的验证测试示例,以检查Orders表的行数与名为Orders_Full的虚构表格的行数是否相同。出于本例考虑,假设出于某种原因我们希望确保Orders中的行数与Orders_Full相同,并且如果不是,则任务失败,并停止 DAG 中下游任务的进一步执行。

首先,将以下任务添加到elt_pipeline_sample.py DAG 定义中:

check_order_rowcount_task = BashOperator(
    task_id='check_order_rowcount',
    bash_command='set -e; python validator.py' +
    'order_count.sql order_full_count.sql equals',
    dag=dag,
)

接下来,在同一文件中重新定义 DAG 的依赖顺序为以下代码。这确保在load_orders_task之后,验证任务运行,在验证完成(并通过)以及load_customers_task成功完成后,revenue_model_task任务运行:

extract_orders_task >> load_orders_task
extract_customers_task >> load_customers_task
load_orders_task >> check_order_rowcount_task
check_order_rowcount_task >> revenue_model_task
load_customers_task >> revenue_model_task

图 8-1 显示了 DAG 的更新图形视图。

dppr 0801

图 8-1. 带有验证测试的示例 ELT DAG 的图形视图。

当执行check_order_rowcount_task时,根据任务定义运行以下 Bash 命令:

set -e; python validator.py order_count.sql order_full_count.sql equals

您会注意到使用了先前在本节中讨论过的命令行参数执行验证器。新的部分是在其余命令之前加上了set -e;。这告诉 Bash 在错误时停止脚本的执行,错误由非零退出状态码定义。如您所记得的,如果验证测试失败,它将返回退出状态-1。如果发生这种情况,Airflow 任务将失败,并且不会执行下游任务(本例中的revenue_model_task)。

当验证测试失败时,并不总是需要停止 DAG 的进一步执行。在这种情况下,不应将 Bash 命令集中的set -e部分包含在 Airflow 任务中,或者修改验证器以不同方式处理警告和严重错误。接下来,我将讨论何时这样做,何时只需发送某种通知。

何时停止管道,何时警告并继续

有时候,比如在前面的例子中,当数据验证测试失败时,停止管道是必要的。在那个例子中,如果Orders表中的记录计数不正确,可能是由于在最后一个任务中刷新数据模型,业务用户将看到不正确的销售数据。如果避免这种情况很重要,那么停止 DAG 以便解决问题是正确的方法。完成这些操作后,数据模型仍然包含前一个成功运行 DAG 的数据。总的来说,旧数据比不正确的数据更好!

然而,还有其他时候,验证测试失败并不那么关键,更多的是提供信息。例如,也许表中的订单数量自昨天的上一个运行增加了 3%,而过去 30 天的平均每日增长率是 1%。你可以通过下一节中我展示的基本统计测试来捕捉这样的增长。这是否值得停止呢?答案取决于你的情况和对风险的接受程度,但你可以依靠多个测试来得出答案。

例如,如果你还运行了一个测试来检查Orders表中是否有重复行,并且测试通过了,那么你就知道问题不是重复的。也许公司因为促销活动而销售额惊人增长。你也可以调整你的测试以考虑季节性因素。也许现在是假期季节,昨天是黑色星期五。与其将记录的增长与过去 30 天相比较,不如与去年同期相比较,无论业务年度增长是否增加都可以考虑。

最终,是选择抛出错误并停止管道,还是发送警报到 Slack 频道的决定,应该基于业务的背景和数据的用例。然而,这也指出了数据工程师和分析师都有必要贡献验证测试到管道的需求。虽然数据工程师可能会检查行计数差异,但他们可能没有业务背景去考虑创建一个测试来检查Orders表中行计数的季节性增长因素。

如果你只想警告而不是停止管道,你需要在前面例子中的 DAG 或验证框架本身中进行一些修改。Airflow 在错误处理方面有许多选项,你可以在官方 Airflow 文档中了解。在接下来关于验证框架的一些可能扩展的部分中,我建议一些处理框架中较不关键失败的方法。任何一个选项都可以;你可以选择在哪里设置逻辑。

扩展框架

正如我在本章前面提到的,从示例 8-1 中的样本数据验证框架中,缺少了许多你可能希望考虑用于生产部署的功能。如果你决定将这个框架作为起点,而不是考虑开源或商业选项,你可能会考虑一些改进措施。

在验证框架中的常见需求是,在测试失败时向 Slack 频道或电子邮件发送通知。我将提供一个示例,演示如何为 Slack 频道执行此操作,但在 Python 中发送电子邮件和其他消息服务的通知的示例也有很多。

首先,你需要为要发送的 Slack 频道创建一个incoming webhook。入站 Webhook 是一个唯一的 URL,你可以向其发送数据,以便它显示为该频道中的消息。你可以按照Slack 文档中的说明创建它。

一旦你有了 Webhook,你可以将以下函数添加到validator.py中,如示例 8-4 所示。你可以向其传递有关验证测试的信息。发送到 Webhook 的信息随后会在 Slack 频道中发布。

示例 8-4. 发送 Slack 消息的函数
# test_result should be True/False
def send_slack_notification(
  webhook_url,
  script_1,
  script_2,
  comp_operator,
  test_result):
    try:
        if test_result == True:
            message = ("Validation Test Passed!: "
            + script_1 + " / "
            + script_2 + " / "
            + comp_operator)
        else:
            message = ("Validation Test FAILED!: "
            + script_1 + " / "
            + script_2 + " / "
            + comp_operator)

        slack_data = {'text': message}
        response = requests.post(webhook_url,
            data=json.dumps(slack_data),
            headers={
                'Content-Type': 'application/json'
            })

        if response.status_code != 200:
            print(response)
            return False
    except Exception as e:
        print("error sending slack notification")
        print(str(e))
        return False

现在,在validation.py退出之前,你只需要调用该函数。示例 8-5 展示了更新脚本的最后几行。

示例 8-5. 在测试失败时发送 Slack 消息
if test_result == True:
        exit(0)
    else:
        send_slack_notification(
          webhook_url,
          script_1,
          script_2,
          comp_operator,
          test_result)
        exit(-1)

当然,Slack 消息的格式还有改进的空间,这个函数发送的消息,现在已经足够完成工作。请注意,我在send_slack_notification函数中包含了test_result参数。它被设置为处理通过测试和未通过测试的通知。尽管在示例中我没有这样使用它,但你可能希望这样做。

如前一小节所述,有时候一个 Slack 消息就足够了,测试失败的结果不应导致流水线停止。尽管你可以利用 DAG 配置来处理这种情况,但也可以通过添加另一个命令行参数来定义严重性,从而改进验证框架。

示例 8-6 展示了带有严重性处理的validator.py中更新后的__main__块。当脚本以halt严重性级别执行时,失败的测试会导致退出码为-1。当严重性级别设置为warn时,失败的测试结果会导致退出码为 0,就像测试通过时一样。在两种情况下,失败消息都会导致在你指定的频道发送 Slack 消息。

示例 8-6. 添加多种严重性级别的测试失败处理
if __name__ == "__main__":

    if len(sys.argv) == 2 and sys.argv[1] == "-h":
        print("Usage: python validator.py"
            + "script1.sql script2.sql "
            + "comparison_operator")
        print("Valid comparison_operator values:")
        print("equals")
        print("greater_equals")
        print("greater")
        print("less_equals")
        print("less")
        print("not_equal")

        exit(0)

    if len(sys.argv) != 5:
        print("Usage: python validator.py"
            + "script1.sql script2.sql "
            + "comparison_operator")
        exit(-1)

    script_1 = sys.argv[1]
    script_2 = sys.argv[2]
    comp_operator = sys.argv[3]
    sev_level = sys.argv[4]

    # connect to the data warehouse
    db_conn = connect_to_warehouse()

    # execute the validation test
    test_result = execute_test(
                  db_conn,
                  script_1,
                  script_2,
                  comp_operator)

    print("Result of test: " + str(test_result))

    if test_result == True:
        exit(0)
    else:
        send_slack_notification(
          webhook_url,
          script_1,
          script_2,
          comp_operator,
          test_result)
        if sev_level == "halt":
            exit(-1)
        else:
            exit(0)

有无数其他方法可以扩展这个框架,其中两种如下。我相信你也会想到其他一些方法!

应用程序中的异常处理

虽然我在本书中为了节省空间而没有详细介绍,但在生产中,必须捕获和处理异常,如无效的命令行参数和 SQL 错误。

使用 validator.py 的单个执行能运行多个测试的能力

考虑将您的测试存储在配置文件中,并按表、DAG 或其他适合您的开发模式的方式进行分组。然后,您可以通过单个命令执行与管道中特定点匹配的所有测试,而不是为您定义的每个测试执行一个命令。

验证测试示例

前面的部分定义了一个简单的验证框架及其工作原理。作为提醒,验证测试包括以下内容:

  • 运行脚本并得到一个单一的数字值的 SQL 文件

  • 运行脚本并得到一个单一的数字值的第二个 SQL 文件

  • 用于比较从 SQL 脚本返回的两个值的“比较运算符”

假设您已将示例 8-4、8-5 和 8-6 的增强功能添加到 Example 8-1 中的validator.py代码中,您可以在命令行上执行测试如下:

python validator.py order_count.sql order_full_count.sql equals warn

在本节中,我将定义一些我认为在验证管道中的数据时很有用的样本测试。这些并不是你需要运行的所有测试,但它们确实涵盖了一些常见点,可以帮助你开始并激发更广泛的测试。每个子节包括组成测试的两个 SQL 文件的源代码,以及执行测试所需的命令行命令和参数。

在摄取后重复记录

检查重复记录是一个简单而常见的测试。唯一需要考虑的是,你需要在检查的表中定义什么是“重复”。是基于单个 ID 值?还是基于 ID 以及第二列?在这个例子中,我将检查确保Orders表中没有两条具有相同OrderId的记录。要基于其他列检查重复项,只需将这些列添加到第一个查询的SELECTGROUP BY中即可。

注意第二个查询返回了一个固定值 0。这是因为我预期没有重复项,并希望将重复项的计数与零进行比较。如果它们匹配,则测试通过。

示例 8-7. order_dup.sql
WITH order_dups AS
(
  SELECT OrderId, Count(*)
  FROM Orders
  GROUP BY OrderId
  HAVING COUNT(*) > 1
)
SELECT COUNT(*)
FROM order_dups;
示例 8-8. order_dup_zero.sql
SELECT 0;

要运行测试,请使用以下命令:

python validator.py order_dup.sql order_dup_zero.sql equals warn

摄取后行数意外变化

当您期望最近摄取的记录数量相对稳定时,您可以使用统计检查来查看最新的摄取是否加载比历史记录建议的更多或更少的记录。

在这个例子中,我假设数据每天摄取,并且会查看最近加载(昨天)Orders表中记录的数量是否在我可以接受的范围内。只要间隔是固定的,你可以对每小时、每周或任何其他间隔执行相同操作。

我将使用标准差计算并查看昨天的行数是否在Orders表的整个历史记录的 90%置信水平内。换句话说,基于历史,值(行数)是否在 90%置信区间内的任一方向(每个方向最多可以偏离 5%)?

在统计学中,这被视为双尾检验,因为我们在正态分布曲线的两侧查找。你可以使用 z-score 计算器确定在置信水平为 90%的双尾检验中使用的分数,以确定 z-score 为 1.645. 换句话说,我们正在寻找根据设置的阈值在任一方向上的差异,无论是太高还是太低。

我将在测试中使用该 z-score 来查看昨天订单记录的计数是否通过测试。在验证测试中,我将返回昨天行数的 z-score 的绝对值,然后将其与第二个 SQL 脚本中的 z-score 1.645 进行比较。

因为你需要在Orders表中有大量样本数据,我提供了验证测试中第一个 SQL 脚本的两个版本。第一个(示例 8-9) 是用于遍历Orders表、获取每天行数,并计算前一天 z-score 的“真实”代码。

然而,你可能想要使用一些样本数据来进行这种测试的实验。我提供了一个备用版本,以填充名为orders_by_day的表,然后执行示例 8-9 的后半部分来计算样本集的最后一天(2020-10-05)的 z-score。示例 8-11 显示了备用版本。

示例 8-9. order_yesterday_zscore.sql
WITH orders_by_day AS (
  SELECT
    CAST(OrderDate AS DATE) AS order_date,
    COUNT(*) AS order_count
  FROM Orders
  GROUP BY CAST(OrderDate AS DATE)
),
order_count_zscore AS (
  SELECT
    order_date,
    order_count,
    (order_count - avg(order_count) over ())
     / (stddev(order_count) over ()) as z_score
  FROM orders_by_day
)
SELECT ABS(z_score) AS twosided_score
FROM order_count_zscore
WHERE
  order_date =
    CAST(current_timestamp AS DATE)
    - interval '1 day';

示例 8-10 简单返回要检查的值。

示例 8-10. zscore_90_twosided.sql
SELECT 1.645;

若要运行测试,请使用以下方法:

python validator.py order_yesterday_zscore.sql zscore_90_twosided.sql greater_equals warn
注意

如果Orders表包含大量数据,创建orders_by_day数据集作为转换任务中的表(就像第六章中的数据模型示例一样),而不是作为验证脚本中的 CTE,是值得的。因为按天计算的订单数量在过去不应该发生变化,你可以创建一个增量数据模型,并在每个后续的天数中追加行,以表明Orders表中新数据的到达。

这是另一版本,带有一个硬编码的日期进行检查以及运行它所需的样本数据。通过这个版本,你可以调整order_count的值并运行测试,以获得不同的 z-score 在期望范围内和之外:

CREATE TABLE orders_by_day
(
  order_date date,
  order_count int
);

INSERT INTO orders_by_day
  VALUES ('2020-09-24', 11);
INSERT INTO orders_by_day
  VALUES ('2020-09-25', 9);
INSERT INTO orders_by_day
  VALUES ('2020-09-26', 14);
INSERT INTO orders_by_day
  VALUES ('2020-09-27', 21);
INSERT INTO orders_by_day
  VALUES ('2020-09-28', 15);
INSERT INTO orders_by_day
  VALUES ('2020-09-29', 9);
INSERT INTO orders_by_day
  VALUES ('2020-09-30', 20);
INSERT INTO orders_by_day
  VALUES ('2020-10-01', 18);
INSERT INTO orders_by_day
  VALUES ('2020-10-02', 14);
INSERT INTO orders_by_day
  VALUES ('2020-10-03', 26);
INSERT INTO orders_by_day
  VALUES ('2020-10-04', 11);
示例 8-11. order_sample_zscore.sql
WITH order_count_zscore AS (
  SELECT
    order_date,
    order_count,
    (order_count - avg(order_count) over ())
     / (stddev(order_count) over ()) as z_score
  FROM orders_by_day
)
SELECT ABS(z_score) AS twosided_score
FROM order_count_zscore
WHERE
  order_date =
    CAST('2020-10-05' AS DATE)
    - interval '1 day';

若要运行测试,请使用以下方法:

python validator.py order_sample_zscore.sql zscore_90_twosided.sql greater_equals warn

指标值波动

正如本章前面所述,在管道的每个步骤中验证数据至关重要。前两个示例在摄入后验证了数据的有效性。此示例检查在数据在管道的转换步骤中建模后是否出现问题。

在来自第六章的数据建模示例中,多个源表被连接在一起,并实现了确定如何聚合值的逻辑。有很多问题可能会发生,包括导致行重复或删除的无效连接逻辑。即使源数据在管道的早期阶段已通过验证,也始终要对在管道末端构建的数据模型进行验证是个好习惯。

您可以检查三件事情:

  • 确保度量在某些下限和上限之间

  • 检查数据模型中行数的增长(或减少)

  • 检查特定指标的值是否出现意外波动

到现在为止,您可能已经对如何实施这些测试有了一个很好的想法,但我将提供最后一个示例来检查指标值的波动。逻辑与我上一节分享的用于检查给定源表行数变化的逻辑几乎相同。不过,这一次,我不是检查行数值,而是查看特定日期订单总收入是否超出历史规范。

就像前一节查找行数变化的示例一样,我提供了在原始数据上如何做到这一点(示例 8-12)以及在样本聚合数据上的一个“真实”示例(示例 8-14)。要运行示例 8-12,您需要在Orders表中有相当多的数据。这段代码对于真正的实现是有意义的。但是,您可能会发现示例 8-14 更容易用于学习目的。

示例 8-12. revenue_yesterday_zscore.sql
WITH revenue_by_day AS (
  SELECT
    CAST(OrderDate AS DATE) AS order_date,
    SUM(ordertotal) AS total_revenue
  FROM Orders
  GROUP BY CAST(OrderDate AS DATE)
),
daily_revenue_zscore AS (
  SELECT
    order_date,
    total_revenue,
    (total_revenue - avg(total_revenue) over ())
     / (stddev(total_revenue) over ()) as z_score
  FROM revenue_by_day
)
SELECT ABS(z_score) AS twosided_score
FROM daily_revenue_zscore
WHERE
  order_date =
    CAST(current_timestamp AS DATE)
    - interval '1 day';

示例 8-13 简单地返回要检查的值。

示例 8-13. zscore_90_twosided.sql
SELECT 1.645;

使用这个来运行测试:

python validator.py revenue_yesterday_zscore.sql zscore_90_twosided.sql greater_equals warn

这是示例 8-14 的示例数据,正如前面提到的,这是示例 8-12 的简化版本,但用于您自己的实验:

CREATE TABLE revenue_by_day
(
  order_date date,
  total_revenue numeric
);

INSERT INTO revenue_by_day
  VALUES ('2020-09-24', 203.3);
INSERT INTO revenue_by_day
  VALUES ('2020-09-25', 190.99);
INSERT INTO revenue_by_day
  VALUES ('2020-09-26', 156.32);
INSERT INTO revenue_by_day
  VALUES ('2020-09-27', 210.0);
INSERT INTO revenue_by_day
  VALUES ('2020-09-28', 151.3);
INSERT INTO revenue_by_day
  VALUES ('2020-09-29', 568.0);
INSERT INTO revenue_by_day
  VALUES ('2020-09-30', 211.69);
INSERT INTO revenue_by_day
  VALUES ('2020-10-01', 98.99);
INSERT INTO revenue_by_day
  VALUES ('2020-10-02', 145.0);
INSERT INTO revenue_by_day
  VALUES ('2020-10-03', 159.3);
INSERT INTO revenue_by_day
  VALUES ('2020-10-04', 110.23);
示例 8-14. revenue_sample_zscore.sql
WITH daily_revenue_zscore AS (
  SELECT
    order_date,
    total_revenue,
    (total_revenue - avg(total_revenue) over ())
     / (stddev(total_revenue) over ()) as z_score
  FROM revenue_by_day
)
SELECT ABS(z_score) AS twosided_score
FROM daily_revenue_zscore
WHERE
  order_date =
    CAST('2020-10-05' AS DATE)
    - interval '1 day';

要运行测试,请使用这个:

python validator.py revenue_sample_zscore.sql zscore_90_twosided.sql greater_equals warn

当然,您会希望考虑调整此测试以适应您的业务案例。

每天查看订单收入是否太“嘈杂”?您的订单量是否足够低,需要查看每周或每月的聚合数据?如果是这样,您可以修改示例 8-12 以按周或月进行聚合,而不是按天。示例 8-15 展示了相同检查的月度版本。它比较了上个月与之前 11 个月的差异。

请注意,此示例检查从当前日期到上个月的总收入。这是在“关闭”一个月时运行的验证类型,通常是在下个月的第一天。例如,这是您可能在 10 月 1 日运行的验证,以确保根据过去的历史,9 月份的收入是否在您预期的范围内。

示例 8-15. revenue_lastmonth_zscore.sql
WITH revenue_by_day AS (
  SELECT
    date_part('month', order_date) AS order_month,
    SUM(ordertotal) AS total_revenue
  FROM Orders
  WHERE
    order_date > date_trunc('month',current_timestamp - interval '12 months')
    AND
    order_date < date_trunc('month', current_timestamp)
  GROUP BY date_part('month', order_date)
),
daily_revenue_zscore AS (
  SELECT
    order_month,
    total_revenue,
    (total_revenue - avg(total_revenue) over ())
     / (stddev(total_revenue) over ()) as z_score
  FROM revenue_by_day
)
SELECT ABS(z_score) AS twosided_score
FROM daily_revenue_zscore
WHERE order_month = date_part('month',date_trunc('month',current_timestamp - interval '1 months'));

还有许多其他类型的验证测试变体。你需要根据自己的数据分析和调整日期粒度的级别、你想要比较的日期周期,甚至是 z 分数。

商业和开源数据验证框架

在本节中,我使用了一个基于 Python 的示例验证框架。正如之前提到的,尽管它很简单,但可以轻松扩展为一个功能齐全、可投入生产的应用程序,用于各种数据验证需求。

说到数据验证,就像数据摄入、数据建模和数据编排工具一样,当涉及到数据验证时,你需要做出一个自建还是购买的决策。实际上,之前的自建与购买决策通常会影响数据团队在管道不同阶段选择用于数据验证的工具。

例如,一些数据摄入工具包括功能来检查行数变化、列中的意外值等。一些数据转换框架,如dbt,包括数据验证和测试功能。如果你已经投资了这类工具,看看有哪些可用选项。

最后,有一些开源框架可用于数据验证。这类框架的数量庞大,我建议找一个适合你生态系统的框架。例如,如果你正在构建一个机器学习管道并使用 TensorFlow,你可以考虑使用TensorFlow 数据验证。对于更通用的验证,Yahoo 的验证器是一个开源选择。

第九章:维护管道的最佳实践

到目前为止,这本书主要关注于构建数据管道。本章讨论如何在遇到增加复杂性并处理管道依赖系统中不可避免的变更时,保持这些管道的维护。

处理源系统的变更

对于数据工程师来说,最常见的维护挑战之一是处理数据来源系统不是静态的这一事实。开发人员总是在他们的软件中进行更改,无论是添加功能,重构代码库,还是修复错误。当这些更改引入要摄取的数据的模式或含义修改时,管道就有可能面临失败或不准确的风险。

正如本书中多次讨论的那样,现代数据基础设施的现实是数据从多种来源摄入。因此,在处理源系统中的模式和业务逻辑变更时很难找到一种适合所有情况的解决方案。尽管如此,我建议投资一些最佳实践。

引入抽象化

在可能的情况下,最好在源系统和摄取过程之间引入抽象层。同样重要的是,源系统的所有者要么维护抽象方法,要么了解这种抽象方法。

例如,不要直接从 Postgres 数据库摄取数据,考虑与数据库所有者合作构建一个 REST API,从数据库中拉取数据并可以查询以进行数据提取。即使 API 只是一个透传,但它存在于源系统所有者维护的代码库中意味着系统所有者知道正在提取哪些数据,不必担心其 Postgres 应用数据库的内部结构变更。如果他们选择修改数据库表的结构,他们需要对 API 进行修改,但不必考虑其他可能依赖它的代码。

此外,如果对源系统的更改导致支持的 API 端点中的字段被删除,则可以做出有意识的决定。也许该字段会随时间逐渐淘汰,或者在历史数据中支持但在未来为 NULL。无论哪种方式,存在一个明确的抽象层时,都意识到需要处理这种变更。

REST API 并不是唯一的抽象选项,有时并不是最佳选择。通过 Kafka 主题发布数据是一种维持约定模式的绝佳方式,同时完全将发布事件的源系统和订阅它的系统(摄取)的细节分开。

维护数据契约

如果必须直接从源系统的数据库或通过一些未明确为您的提取而设计的方法摄取数据,则创建和维护数据合同是管理模式和逻辑变更的较少技术性解决方案。

数据合同可以以文本文档的形式编写,但最好以标准化的配置文件形式编写,例如在示例 9-1 中。在此示例中,来自 Postgres 数据库中表的摄入数据合同以 JSON 形式存储。

示例 9-1. orders_contract.json
{
  ingestion_jobid: "orders_postgres",
  source_host: "my_host.com",
  source_db: "ecommerce",
  source_table: "orders",
  ingestion_type: "full",
  ingestion_frequency_minutes: "60",
  source_owner: "dev-team@mycompany.com",
  ingestion_owner: "data-eng@mycompany.com"
};

一旦创建了数据合同,以下是您可以使用它们来提前应对任何可能影响管道完整性的源系统变更的几种方式:

  • 创建一个 Git 钩子,在提交 PR 或提交代码到分支时查找表中的任何更改(模式或逻辑),这些表在数据合同中列为source_table。自动通知贡献者该表用于数据摄入,以及联系方式(ingestion_owner)以协调变更。

  • 如果数据合同本身位于 Git 存储库中(应该是!),请添加一个 Git 钩子来检查合同的变更。例如,如果增加摄入运行的频率,则不仅应更新数据合同,还应与源系统所有者协商,以确保对生产系统没有负面影响。

  • 在公司集中文档站点上发布所有数据合同的可读形式,并使其可搜索。

  • 编写并安排一个脚本,以通知源系统和摄入所有者任何在过去六个月内未更新的数据合同(或其他频率),并要求他们进行审核和更新(如果需要)。

无论是自动化还是手动操作,目标都是在数据摄入或摄入方法(例如从增量到完整加载)发生变化之前,及时标记并与管道或源系统的任何问题进行通信。

读取时模式的限制

处理源数据模式变更的一种方法是从写入时设计转向读取时设计。

本书中贯穿始终采用的是写入时模式;特别是在第四章和第五章。从源头提取数据时,定义结构(模式),然后将数据写入数据湖或 S3 存储桶。然后,在运行摄入中的加载步骤时,数据处于可预测的形式,并且可以加载到定义的表结构中。

读取时模式是一种模式,其中数据写入数据湖、S3 存储桶或其他存储系统,没有严格的模式。例如,定义系统中放置订单的事件可能被定义为 JSON 对象,但随着时间的推移,该对象的属性可能会随着新属性的添加或现有属性的移除而发生变化。在这种情况下,直到读取数据时才能知道数据的模式,这就是为什么称其为读取时模式。

尽管对于将数据写入存储非常有效,但这种模式会增加加载步骤的复杂性,并在管道中产生一些重大影响。从技术角度来看,从 S3 存储桶中读取以这种方式存储的数据非常容易。Amazon Athena 和其他产品使得查询原始数据就像编写 SQL 查询一样简单。然而,维护数据的定义并不是一件小事。

首先,您需要使用与在加载步骤中读取模式灵活数据的工具集成的数据目录。数据目录存储数据湖和仓库中数据的元数据。它可以存储数据集的结构和定义。对于按需模式读取,定义和存储数据目录中数据的结构对于实用和人类参考都至关重要。AWS Glue 数据目录Apache Atlas是流行的数据目录,但还有许多其他选择。

首先,您需要考虑加载步骤的逻辑变得更加复杂。您需要考虑如何在检测到摄取过程中的新字段时动态添加新列到仓库中的表中。在管道的转换步骤中,您如何通知数据分析师或者更改他们源表格的人建模的数据?

如果选择按需模式读取方法,您需要认真对待数据治理,这不仅包括对数据进行分类,还包括定义组织中数据使用的标准和流程。数据治理是一个广泛的主题,无论您如何摄取数据,都是一个重要的主题。但是,如果选择按需模式读取方法,在技术层面上是一个不可忽视的话题。

扩展复杂性

当源系统和下游数据模型有限时,构建数据管道本身就具有挑战性。即使在相对较小的组织中,一旦这些数字变大,扩展管道以处理增加的复杂性也会面临一些挑战。本节包括在管道各个阶段应对这些挑战的一些提示和最佳实践。

标准化数据摄取

当涉及到复杂性时,您从中摄取的系统数量通常不是问题的主要因素,而是每个系统并不完全相同。这一事实通常会导致两个管道维护挑战:

  • 摄取作业必须编写以处理各种源系统类型(例如 Postgres,Kafka 等)。您需要从的源系统类型越多,您的代码库就越大,需要维护的东西也就越多。

  • 无法轻松地对同一源系统类型的摄取作业进行标准化。例如,即使您只从 REST API 中摄取数据,如果这些 API 没有标准化的分页方式、增量访问数据等特性,数据工程师可能会构建“一次性”摄取作业,这些作业不能重用代码并共享可以集中维护的逻辑。

根据你的组织情况,你可能无法控制你所摄取的系统。也许你必须主要从第三方平台摄取数据,或者内部系统由组织层次结构下不同部门的工程团队构建。这不是技术问题,但每个问题仍然应被考虑并作为数据流水线战略的一部分解决。幸运的是,在你的控制范围内也有一些技术方法来减轻对你的流水线的影响。

首先是非技术因素。如果你正在摄取的系统是内部构建的,但标准化程度不高,那么提高对数据组织流水线影响的认识可能会获得系统所有者的支持。

特别是在较大的公司中,构建每个系统的软件工程师可能并不意识到他们构建的系统与组织内其他部门的系统并不完全相同。幸运的是,软件工程师通常能够理解标准化带来的效率和可维护性优势。与工程组织建立合作伙伴关系需要耐心和恰到好处的方法,但这是数据团队中被低估的非技术技能之一。

如果你发现自己需要从大量第三方数据源摄取数据,那么你的组织很可能在许多情况下选择购买而不是自建。建立/购买决策是复杂的,组织通常在评估不同供应商和内部解决方案提案时权衡多种因素。在过程中往往忽略或推迟的一个因素是对报告和分析的影响。在这种情况下,数据团队面临的挑战是从设计不良的产品中摄取数据。尽最大努力早期参与评估过程,并确保你的团队在最终决策中有一席之地。就像提高内部系统标准化意识一样,与供应商合作确定分析需求的重要性通常不被考虑,除非数据团队确保他们的声音被听到。

在你的控制范围内,还有一些技术方法可以减少你的数据摄取作业的复杂性:

标准化你能够的所有代码,并且重复使用。

这是软件工程的一般最佳实践,但有时会在创建数据摄取作业时被忽视。

力求通过配置驱动的数据摄取

你是否需要从多个 Postgres 数据库和表摄取数据?不要为每个摄取编写不同的作业,而是编写一个单一的作业,通过配置文件(或数据库表中的记录!)迭代定义你想要摄取的表和架构。

考虑你自己的抽象层。

如果你无法让源系统所有者在它们的系统和你的数据摄取之间建立一些标准化的抽象层,可以考虑自行完成或与它们合作,并承担大部分开发工作。例如,如果你必须摄取来自 Postgres 或 MySQL 数据库的数据,可以征得源团队的许可,实施使用 Debezium 进行流式 CDC(参见第四章),而不是再写一个摄取任务。

数据模型逻辑的重用

复杂性也可能在管道的后续阶段和特别是在管道转换阶段的数据建模过程中出现(参见第六章)。随着分析师构建更多数据模型,他们往往会做以下两件事之一:

  • 在构建每个模型的 SQL 中重复逻辑。

  • 派生各个模型,创建模型之间的多个依赖关系。

正如在数据摄取中(以及一般的软件工程中)代码重用是理想的一样,在数据建模中也是理想的。它确保存在单一的真实来源,并在存在错误或业务逻辑更改的情况下减少需要更改的代码量。这种权衡是管道中更复杂的依赖图。

图 9-1 展示了一个 DAG(参见第七章),其中只有一个数据摄取和四个通过并行运行脚本构建的数据模型。它们可以这样执行,因为它们彼此之间没有依赖关系。

dppr 0901

图 9-1. 四个独立数据模型。

如果它们确实是不相关的数据模型,那没问题。但是,如果它们都共享一些逻辑,最好重构模型和 DAG,使其看起来像图 9-2。

dppr 0902

图 9-2. 具有逻辑重用和依赖关系的数据模型。

示例 9-2 展示了逻辑重用的简单示例,表示在图 9-2 中执行的build_model_1任务中执行的脚本。该脚本按日生成订单计数,并将其存储在名为orders_by_day的数据模型中。

您可以使用从第六章的Orders表格,可以使用以下 SQL 重新创建和填充:

CREATE TABLE Orders (
  OrderId int,
  OrderStatus varchar(30),
  OrderDate timestamp,
  CustomerId int,
  OrderTotal numeric
);

INSERT INTO Orders
  VALUES(1,'Shipped','2020-06-09',100,50.05);
INSERT INTO Orders
  VALUES(2,'Shipped','2020-07-11',101,57.45);
INSERT INTO Orders
  VALUES(3,'Shipped','2020-07-12',102,135.99);
INSERT INTO Orders
  VALUES(4,'Shipped','2020-07-12',100,43.00);
示例 9-2. model_1.sql
CREATE TABLE IF NOT EXISTS orders_by_day AS
SELECT
    CAST(OrderDate AS DATE) AS order_date,
    COUNT(*) AS order_count
FROM Orders
GROUP BY CAST(OrderDate AS DATE);

DAG 中的后续模型在需要每日订单计数时可以引用此表,而不是每次重新计算。示例 9-3 表示在 图 9-2 中的 build_model_2 任务中执行的脚本。与每天按订单计算订单数量相比,使用 orders_by_day 模型更为重要,尤其是在具有额外逻辑的更复杂计算或带有 WHERE 子句或连接的查询中,更应一次编写逻辑并重复使用。这样做可以确保单一真相源,确保仅需维护单个模型,并且作为奖励,仅需要您的数据仓库运行任何复杂逻辑一次并将结果存储以供以后参考。在某些情况下,这种节省时间在管道运行时的显著性是显著的。

示例 9-3. model_2.sql
SELECT
  obd.order_date,
  ot.order_count
FROM orders_by_day obd
LEFT JOIN other_table ot
  ON ot.some_date = obd.order_date;

虽然一些精明的数据分析师从一开始就设计他们的数据模型和随后的 DAG,但更常见的是在管道中出现问题后才找到重构的机会。例如,如果在模型逻辑中发现了错误并需要修复多个模型,则很可能有机会将逻辑应用于单个模型并从中派生其他模型。

尽管最终结果是更复杂的依赖关系集合,如果处理得当,正如您将在下一节中看到的那样,您会发现管道中数据建模部分的逻辑更可靠且不太可能导致多个真相版本的出现。

确保依赖完整性

如前一节所述,尽管重用数据模型逻辑带来了诸多好处,但也存在一个权衡:需要跟踪每个模型依赖的内容,并确保这些依赖在 DAG 中正确定义以进行编排。

在前一节中的 图 9-2(以及示例 9-2 和 9-3 中的查询)中,model_2 依赖于 model_1,而 model_3model_4 都依赖于 model_2。这些依赖关系在 DAG 中得到了正确定义,但随着团队构建更多模型,跟踪依赖关系变得相当繁琐且容易出错。

随着管道变得越来越复杂,是时候考虑通过程序化方法定义和验证数据模型之间的依赖关系了。有多种方法,我将讨论其中两种。

首先,您可以在开发过程中构建一些逻辑,以识别 SQL 脚本中的依赖关系,并确保任何脚本依赖的表在 DAG 中的上游被执行。这并不简单,可以通过从 SQL 脚本中解析表名来完成,或者更常见的是,在数据分析师提交新模型或对现有模型进行修改时,在配置文件中手动提供依赖列表。在这两种情况下,您需要做一些工作,并为开发过程增加一些摩擦。

另一种方法是使用数据模型开发框架,比如dbt,它除了其他好处外,还具有分析师在模型定义的 SQL 中定义模型之间引用的机制。

例如,我将重写示例 9-3 中的model_2.sql,并在 dbt 中使用ref()函数来引用连接中的model_1.sql。示例 9-4 展示了结果。

示例 9-4. model_2_dbt.sql
SELECT
  obd.order_date,
  ot.order_count
FROM {{ref('model_1')}} obd
LEFT JOIN other_table ot
  ON ot.some_date = obd.order_date;

通过更新的 SQL,dbt 知道model_2依赖于model_1,并确保按正确顺序执行。事实上,dbt 动态构建 DAG,而不是强迫您在 Airflow 等工具中这样做。当数据模型在执行之前由 dbt 编译时,对model_1的引用将用表名(orders_by_day)填充。如果图 9-2 中的 DAG 中的所有四个模型改为在 dbt 中编写,它们可以在命令行上用单个命令编译和执行:

$ dbt run

当执行dbt run时,表示每个模型的 SQL 脚本将根据每个表从彼此引用的方式按正确的顺序运行。正如您在第七章中学到的,通过 Airflow 运行命令行任务是简单的。如果您仍然希望在数据模型开发中同时使用 Airflow 作为编排器和 dbt,那也没问题。图 9-3 显示了一个更新的 DAG,其中两个摄取步骤的运行方式与以前相同。当它们完成时,一个单独的 Airflow 任务执行dbt run命令,该命令按正确顺序执行所有四个数据模型的 SQL。

dppr 0903

图 9-3. 从 Airflow 中执行的 dbt 数据模型。

虽然在这个例子中,我运行了 dbt 项目中的所有模型,但你也可以通过向dbt run传递参数来指定要运行的模型子集。

无论您选择使用自定义代码识别和验证模型依赖关系,还是利用 dbt 等产品,在处理大规模依赖关系时都是保持数据管道的关键。最好不要依赖手动检查和人工操作!

第十章:衡量和监控流水线性能

即使是设计最为良好的数据流水线也不应被视为“设定并忘记”。测量和监控流水线性能的实践至关重要。在流水线的可靠性方面,您有责任对团队和利益相关者设定并履行期望。

本章概述了一些技巧和最佳实践,这些技巧和实践通常由数据团队交付给其他人,但令人惊讶的是,他们并不总是在自己身上进行这些投资:收集数据并衡量其工作性能。

关键流水线指标

在确定需要在整个流水线中捕获哪些数据之前,首先必须决定要跟踪哪些指标。

选择指标应该从确定对您和利益相关者重要的事物开始。一些示例包括以下内容:

  • 运行了多少验证测试(请参阅第八章),以及总测试中通过的百分比是多少

  • 特定 DAG 成功运行的频率有多高

  • 流水线在几周、几个月和几年内的总运行时间

收集计算这些指标所需的数据并不难,这在你看过接下来的章节后就会明白。可以直接从本书中早先构建的基础设施中捕获这些数据;尤其是,请参阅 Airflow(第七章)和数据验证框架(第八章)。

准备数据仓库

在监控和报告流水线性能之前,您当然需要捕获这些测量所需的数据。幸运的是,作为数据专业人士,您拥有在您面前的工具来执行此操作!您的数据仓库是存储来自数据流水线每个步骤的日志数据的最佳场所。

在本节中,我定义了用于存储来自 Airflow 和数据验证框架中定义的数据的表结构(请参阅第八章)。这些数据稍后将用于开发衡量流水线性能的关键指标。

我想指出,您可能希望跟踪和报告的其他数据点众多。我喜欢这两个示例,因为它们涵盖了基础知识,并应激发其他与您的数据基础架构相关的跟踪和测量。

数据基础架构模式

首先,您需要一张表来存储来自 Airflow 的 DAG 运行历史记录。回想一下第七章中提到,Airflow 用于执行数据流水线中的每个步骤。它还保留了每个 DAG 运行的历史记录。在提取这些数据之前,您需要一个表来加载这些数据。以下是名为dag_run_history的表的定义,应该在数据摄取过程中用于加载数据的模式中创建:

CREATE TABLE dag_run_history (
  id int,
  dag_id varchar(250),
  execution_date timestamp with time zone,
  state varchar(250),
  run_id varchar(250),
  external_trigger boolean,
  end_date timestamp with time zone,
  start_date timestamp with time zone
);

除了报告 DAG 的性能外,还需要提供关于数据有效性的见解。在第八章中,我定义了一个简单的基于 Python 的数据验证框架。在本章中,我将扩展它,以便将每个验证测试的结果记录到数据仓库中。名为validation_run_history的以下表将成为验证测试结果的目标。建议将其创建在您的数据仓库中与加载的数据相同的模式中:

CREATE TABLE validation_run_history (
  script_1 varchar(255),
  script_2 varchar(255),
  comp_operator varchar(10),
  test_result varchar(20),
  test_run_at timestamp
);

本章的其余部分实现了填充和利用先前两个表中加载数据的逻辑。

记录和摄入性能数据

现在是时候填充您在前一节中在数据仓库中创建的两个表了。第一个将通过构建一个数据摄入作业来填充,就像您在第四章和第五章中学到的那样。第二个将需要对首次在第八章中介绍的数据验证应用程序进行增强。

从 Airflow 摄入 DAG 运行历史记录

要填充您在前一节中在数据仓库中创建的dag_run_history表,您需要从您在“Apache Airflow Setup and Overview”中配置的 Airflow 应用程序数据库中提取数据。

在该部分中,我选择使用 Postgres 数据库供 Airflow 使用,因此以下提取代码遵循“从 PostgreSQL 数据库提取数据”中定义的模型。请注意,由于 Airflow 数据库中的dag_run表的自动递增id列,我选择增量加载数据是很容易的(定义在示例 10-1 中)。其提取结果是一个名为dag_run_extract.csv的 CSV 文件,上传到您在第四章中设置的 S3 存储桶。

在执行代码之前,您需要向来自第四章的pipeline.conf文件添加一个新部分。如下所示,它必须包含您在第七章中设置的 Airflow 数据库的连接详细信息:

[airflowdb_config]
host = localhost
port = 5432
username = airflow
password = pass1
database = airflowdb
示例 10-1. airflow_extract.py
import csv
import boto3
import configparser
import psycopg2

# get db Redshift connection info
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
dbname = parser.get("aws_creds", "database")
user = parser.get("aws_creds", "username")
password = parser.get("aws_creds", "password")
host = parser.get("aws_creds", "host")
port = parser.get("aws_creds", "port")

# connect to the redshift cluster
rs_conn = psycopg2.connect(
            "dbname=" + dbname
            + " user=" + user
            + " password=" + password
            + " host=" + host
            + " port=" + port)

rs_sql = """SELECT COALESCE(MAX(id),-1)
 FROM dag_run_history;"""

rs_cursor = rs_conn.cursor()
rs_cursor.execute(rs_sql)
result = rs_cursor.fetchone()

# there's only one row and column returned
last_id = result[0]
rs_cursor.close()
rs_conn.commit()

# connect to the airflow db
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
dbname = parser.get("airflowdb_config", "database")
user = parser.get("airflowdb_config", "username")
password = parser.get("airflowdb_config", "password")
host = parser.get("airflowdb_config", "host")
port =  parser.get("airflowdb_config", "port")
conn = psycopg2.connect(
        "dbname=" + dbname
        + " user=" + user
        + " password=" + password
        + " host=" + host
        + " port=" + port)

# get any new DAG runs. ignore running DAGs
m_query = """SELECT
 id,
 dag_id,
 execution_date,
 state,
 run_id,
 external_trigger,
 end_date,
 start_date
 FROM dag_run
 WHERE id > %s
 AND state <> \'running\';
 """

m_cursor = conn.cursor()
m_cursor.execute(m_query, (last_id,))
results = m_cursor.fetchall()

local_filename = "dag_run_extract.csv"
with open(local_filename, 'w') as fp:
    csv_w = csv.writer(fp, delimiter='|')
    csv_w.writerows(results)

fp.close()
m_cursor.close()
conn.close()

# load the aws_boto_credentials values
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
access_key = parser.get("aws_boto_credentials",
                "access_key")
secret_key = parser.get("aws_boto_credentials",
                "secret_key")
bucket_name = parser.get("aws_boto_credentials",
                "bucket_name")

# upload the local CSV to the S3 bucket
s3 = boto3.client(
        's3',
        aws_access_key_id=access_key,
        aws_secret_access_key=secret_key)
s3_file = local_filename
s3.upload_file(local_filename, bucket_name, s3_file)

提取完成后,您可以按照第五章中详细描述的方法将 CSV 文件的内容加载到数据仓库中。如果您有一个 Redshift 数据仓库,示例 10-2 定义了如何执行此操作。

示例 10-2. airflow_load.py
import boto3
import configparser
import pyscopg2

# get db Redshift connection info
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
dbname = parser.get("aws_creds", "database")
user = parser.get("aws_creds", "username")
password = parser.get("aws_creds", "password")
host = parser.get("aws_creds", "host")
port = parser.get("aws_creds", "port")

# connect to the redshift cluster
rs_conn = psycopg2.connect(
            "dbname=" + dbname
            + " user=" + user
            + " password=" + password
            + " host=" + host
            + " port=" + port)

# load the account_id and iam_role from the conf files
parser = configparser.ConfigParser()
parser.read("pipeline.conf")
account_id = parser.get(
                "aws_boto_credentials",
                "account_id")
iam_role = parser.get("aws_creds", "iam_role")

# run the COPY command to ingest into Redshift
file_path = "s3://bucket-name/dag_run_extract.csv"

sql = """COPY dag_run_history
 (id,dag_id,execution_date,
 state,run_id,external_trigger,
 end_date,start_date)"""
sql = sql + " from %s "
sql = sql + " iam_role 'arn:aws:iam::%s:role/%s';"

# create a cursor object and execute the COPY command
cur = rs_conn.cursor()
cur.execute(sql,(file_path, account_id, iam_role))

# close the cursor and commit the transaction
cur.close()
rs_conn.commit()

# close the connection
rs_conn.close()

您可能希望手动运行一次摄入过程,但稍后可以通过 Airflow DAG 按计划运行,正如本章后面的部分所述。

向数据验证器添加日志记录

为了记录首次在第八章中介绍的验证测试的结果,我将在validator.py脚本中添加一个名为log_result的函数。因为脚本已经连接到数据仓库以运行验证测试,所以我重用连接并简单地INSERT一个记录与测试结果:

def log_result(
      db_conn,
      script_1,
      script_2,
      comp_operator,
      result):

    m_query = """INSERT INTO
 validation_run_history(
 script_1,
 script_2,
 comp_operator,
 test_result,
 test_run_at)
 VALUES(%s, %s, %s, %s,
 current_timestamp);"""

    m_cursor = db_conn.cursor()
    m_cursor.execute(
      m_query,
      (script_1, script_2, comp_operator, result))
    db_conn.commit()

    m_cursor.close()
    db_conn.close()

    return

作为最后的修改,您需要在测试运行后调用新函数。在添加日志记录代码后,示例 10-3 完整定义了更新的验证器。通过此添加,每次运行验证测试时,结果都会记录在validation_run_history表中。

我建议运行几个验证测试以生成接下来示例所需的测试数据。有关运行验证测试的更多信息,请参阅第八章。

示例 10-3. validator_logging.py
import sys
import psycopg2
import configparser

def connect_to_warehouse():
    # get db connection parameters from the conf file
    parser = configparser.ConfigParser()
    parser.read("pipeline.conf")
    dbname = parser.get("aws_creds", "database")
    user = parser.get("aws_creds", "username")
    password = parser.get("aws_creds", "password")
    host = parser.get("aws_creds", "host")
    port = parser.get("aws_creds", "port")

    # connect to the Redshift cluster
    rs_conn = psycopg2.connect(
                "dbname=" + dbname
                + " user=" + user
                + " password=" + password
                + " host=" + host
                + " port=" + port)

    return rs_conn

# execute a test made of up two scripts
# and a comparison operator
# Returns true/false for test pass/fail
def execute_test(
        db_conn,
        script_1,
        script_2,
        comp_operator):

    # execute the 1st script and store the result
    cursor = db_conn.cursor()
    sql_file = open(script_1, 'r')
    cursor.execute(sql_file.read())

    record = cursor.fetchone()
    result_1 = record[0]
    db_conn.commit()
    cursor.close()

    # execute the 2nd script and store the result
    cursor = db_conn.cursor()
    sql_file = open(script_2, 'r')
    cursor.execute(sql_file.read())

    record = cursor.fetchone()
    result_2 = record[0]
    db_conn.commit()
    cursor.close()

    print("result 1 = " + str(result_1))
    print("result 2 = " + str(result_2))

    # compare values based on the comp_operator
    if comp_operator == "equals":
        return result_1 == result_2
    elif comp_operator == "greater_equals":
        return result_1 >= result_2
    elif comp_operator == "greater":
        return result_1 > result_2
    elif comp_operator == "less_equals":
        return result_1 <= result_2
    elif comp_operator == "less":
        return result_1 < result_2
    elif comp_operator == "not_equal":
        return result_1 != result_2

    # if we made it here, something went wrong
    return False

def log_result(
        db_conn,
        script_1,
        script_2,
        comp_operator,
        result):
    m_query = """INSERT INTO
 validation_run_history(
 script_1,
 script_2,
 comp_operator,
 test_result,
 test_run_at)
 VALUES(%s, %s, %s, %s,
 current_timestamp);"""

    m_cursor = db_conn.cursor()
    m_cursor.execute(
                m_query,
                (script_1,
                    script_2,
                    comp_operator,
                    result)
            )
    db_conn.commit()

    m_cursor.close()
    db_conn.close()

    return

if __name__ == "__main__":
    if len(sys.argv) == 2 and sys.argv[1] == "-h":
        print("Usage: python validator.py"
            + "script1.sql script2.sql "
            + "comparison_operator")
        print("Valid comparison_operator values:")
        print("equals")
        print("greater_equals")
        print("greater")
        print("less_equals")
        print("less")
        print("not_equal")

        exit(0)

    if len(sys.argv) != 5:
        print("Usage: python validator.py"
            + "script1.sql script2.sql "
            + "comparison_operator")
        exit(-1)

    script_1 = sys.argv[1]
    script_2 = sys.argv[2]
    comp_operator = sys.argv[3]
    sev_level = sys.argv[4]

    # connect to the data warehouse
    db_conn = connect_to_warehouse()

    # execute the validation test
    test_result = execute_test(
                    db_conn,
                    script_1,
                    script_2,
                    comp_operator)

    # log the test in the data warehouse
    log_result(
        db_conn,
        script_1,
        script_2,
        comp_operator,
        test_result)

    print("Result of test: " + str(test_result))

    if test_result == True:
        exit(0)
    else:
        if sev_level == "halt":
            exit(-1)
        else:
            exit(0)

有关运行验证测试的更多信息,请参阅第八章。

转换性能数据

现在,您已经捕获了管道中的关键事件并将它们存储在数据仓库中,您可以利用它们来报告管道的性能。最好的方法是构建一个简单的数据管道!

参考本书中引入的 ELT 模式(第三章)以及整本书中使用的模式。构建用于报告每个管道性能的管道的工作几乎完成。在前一节中已处理提取和加载(EL)步骤。您所剩下的就是转换(T)步骤。对于此管道,这意味着将来自 Airflow DAG 运行的数据以及您选择记录的其他操作转换为您打算测量和对自己负责的性能指标。

在以下小节中,我定义了用于创建本章前面讨论过的一些关键指标数据模型的转换。

DAG 成功率

正如您在第六章中记得的那样,您必须考虑您希望建模的数据的粒度。在这种情况下,我想测量每个 DAG 按天的成功率。这种粒度水平使我能够每天、每周、每月或每年测量单个或多个 DAG 的成功率。无论 DAG 每天运行一次还是更多次,此模型都将支持成功率。示例 10-4 定义了用于构建该模型的 SQL。请注意,这是一个完全刷新的模型以简化操作。

示例 10-4. dag_history_daily.sql
CREATE TABLE IF NOT EXISTS dag_history_daily (
  execution_date DATE,
  dag_id VARCHAR(250),
  dag_state VARCHAR(250),
  runtime_seconds DECIMAL(12,4),
  dag_run_count int
);

TRUNCATE TABLE dag_history_daily;

INSERT INTO dag_history_daily
  (execution_date, dag_id, dag_state,
  runtime_seconds, dag_run_count)
SELECT
  CAST(execution_date as DATE),
  dag_id,
  state,
  SUM(EXTRACT(EPOCH FROM (end_date - start_date))),
  COUNT(*) AS dag_run_count
FROM dag_run_history
GROUP BY
  CAST(execution_date as DATE),
  dag_id,
  state;

您可以从dag_history_daily表中,测量单个或所有 DAG 在给定日期范围内的成功率。以下是基于在第七章中定义的一些 DAG 运行的示例,但您将看到基于您自己的 Airflow DAG 运行历史数据。确保至少运行一次 Airflow 数据的摄取(在本章前面定义)以填充dag_history_daily

这是一个查询,用于按 DAG 返回成功率。当然,您可以按给定的 DAG 或日期范围进行筛选。请注意,您必须将dag_run_count强制转换为DECIMAL以计算分数成功率:

SELECT
  dag_id,
  SUM(CASE WHEN dag_state = 'success' THEN 1
      ELSE 0 END)
      / CAST(SUM(dag_run_count) AS DECIMAL(6,2))
  AS success_rate
FROM dag_history_daily
GROUP BY dag_id;

查询的输出将如下所示:

dag_id          |      success_rate
-------------------------+------------------------
tutorial                | 0.83333333333333333333
elt_pipeline_sample     | 0.25000000000000000000
simple_dag              | 0.31250000000000000000
(3 rows)

DAG 运行时间随时间的变化

经常使用时间来测量 DAG 的运行时间,以便跟踪随着时间推移完成时间较长的 DAG,从而创建数据仓库中数据过时的风险。我将使用我在上一小节中创建的dag_history_daily表来计算每个 DAG 按日平均运行时间。

请注意,在以下查询中,我仅包括成功的 DAG 运行,但在某些情况下,您可能希望报告运行时间长且失败的 DAG 运行(也许是由于超时!)。还要记住,由于给定 DAG 的多次运行可能在单个日内发生,因此我必须在查询中对这些 DAG 的运行时间进行平均。

最后,因为dag_history_daily表按日期和dag_state进行了细分,我实际上不需要对runtime_secondsdag_run_count求和,但作为最佳实践,我会这样做。为什么呢?如果我或另一位分析师决定更改逻辑以包括失败的 DAG 运行等操作,那么SUM()函数就会是必需的,但很容易被忽视。

这里是从第七章中的elt_pipeline_sample DAG 的查询:

SELECT
  dag_id,
  execution_date,
  SUM(runtime_seconds)
    / SUM(CAST(dag_run_count as DECIMAL(6,2)))
  AS avg_runtime
FROM dag_history_daily
WHERE
  dag_id = 'elt_pipeline_sample'
GROUP BY
  dag_id,
  execution_date
ORDER BY
dag_id,
execution_date;

查询的输出将类似于以下内容:

dag_id        | execution_date |     avg_runtime
---------------------+----------------+----------
elt_pipeline_sample | 2020-09-16     |  63.773900
elt_pipeline_sample | 2020-09-17     | 105.902900
elt_pipeline_sample | 2020-09-18     | 135.392000
elt_pipeline_sample | 2020-09-19     | 101.111700
(4 rows)

验证测试量和成功率

由于您在本章前面为数据验证器添加的额外日志,现在可以测量验证测试的成功率以及运行的测试总量。

示例 10-5 定义了一个名为validator_summary_daily的新数据模型,用于按日粒度计算和存储每个验证器测试的结果。

示例 10-5. validator_summary_daily.sql
CREATE TABLE IF NOT EXISTS validator_summary_daily (
  test_date DATE,
  script_1 varchar(255),
  script_2 varchar(255),
  comp_operator varchar(10),
  test_composite_name varchar(650),
  test_result varchar(20),
  test_count int
);

TRUNCATE TABLE validator_summary_daily;

INSERT INTO validator_summary_daily
  (test_date, script_1, script_2, comp_operator,
  test_composite_name, test_result, test_count)
SELECT
  CAST(test_run_at AS DATE) AS test_date,
  script_1,
  script_2,
  comp_operator,
  (script_1
    || ' '
    || script_2
    || ' '
    || comp_operator) AS test_composite_name,
  test_result,
  COUNT(*) AS test_count
FROM validation_run_history
GROUP BY
  CAST(test_run_at AS DATE),
  script_1,
  script_2,
  comp_operator,
  (script_1 || ' ' || script_2 || ' ' || comp_operator),
  test_result;

尽管创建validator_summary_daily的逻辑相当简单,但还是值得注意test_composite_name列。在没有为每个验证测试指定唯一名称(一个值得考虑的增强功能)的情况下,test_composite_name是测试的两个脚本和运算符的组合。它充当了一个可以用来分组验证测试运行的复合键。例如,以下是计算每个测试通过时间百分比的 SQL。当然,您可以按天、周、月或其他任何时间范围查看这些内容:

SELECT
  test_composite_name,
  SUM(
    CASE WHEN test_result = 'true' THEN 1
    ELSE 0 END)
    / CAST(SUM(test_count) AS DECIMAL(6,2))
  AS success_rate
FROM validator_summary_daily
GROUP BY
  test_composite_name;

输出将类似于以下内容:

test_composite_name    |      success_rate
--------------------------+----------------------
sql1.sql sql2.sql equals    | 0.33333333333333333
sql3.sql sql4.sql not_equal | 0.75000000000000000

(2 rows)

至于测试运行的数量,您可能希望按日期、测试或两者查看。如前所述,保持此值的上下文很重要。随着管道数量和复杂性的增加,您可以使用此措施确保在整个管道中测试数据的有效性。以下 SQL 通过日期生成测试计数和成功率。这是一个数据集,您可以在双 Y 轴线图或类似可视化上绘制:

SELECT
  test_date,
  SUM(
    CASE WHEN test_result = 'true' THEN 1
    ELSE 0 END)
    / CAST(SUM(test_count) AS DECIMAL(6,2))
  AS success_rate,
  SUM(test_count) AS total_tests
FROM validator_summary_daily
GROUP BY
  test_date
ORDER BY
  test_date;

结果将类似于以下内容:

test_date  |      success_rate      | total_tests
------------+------------------------+-----------
2020-11-03 | 0.33333333333333333333 |         3
2020-11-04 | 1.00000000000000000000 |         6
2020-11-05 | 0.50000000000000000000 |         8

(3 row)

管理性能管道

有了前几节的代码,你可以创建一个新的 Airflow DAG 来调度和编排一个管道,以摄取和转换管道性能数据。这可能感觉有点递归,但你可以利用你已有的基础设施来执行这种类型的操作。请记住,这种向后看的报告侧重于洞察力,而不是像运行时间监控或管道警报那样的使命关键任务。你永远不想使用相同的基础设施来执行这些任务!

性能 DAG

基于第 7 章中的示例,编排所有在本章中定义的步骤的 DAG 将会很熟悉。根据示例 10-3,验证测试的结果已经记录在数据仓库中。这意味着在这个管道中只需要进行少数几个步骤:

  1. 从 Airflow 数据库提取数据(参见示例 10-1)。

  2. 从 Airflow 提取的数据加载到数据仓库中(参见示例 10-2)。

  3. 转换 Airflow 历史(参见示例 10-4)。

  4. 转换数据验证历史(参见示例 10-5)。

示例 10-6 是 Airflow DAG 的源代码,而图 10-1 显示了该 DAG 的图形形式。

dppr 1001

图 10-1. 流水线性能 DAG 的图形视图。
示例 10-6. pipeline_performance.py
from datetime import timedelta
from airflow import DAG
from airflow.operators.bash_operator \
    import BashOperator
from airflow.operators.postgres_operator \
    import PostgresOperator
from airflow.utils.dates import days_ago

dag = DAG(
    'pipeline_performance',
    description='Performance measurement pipeline',
    schedule_interval=timedelta(days=1),
    start_date = days_ago(1),
)

extract_airflow_task = BashOperator(
    task_id='extract_airflow',
    bash_command='python /p/airflow_extract.py',
    dag=dag,
)

load_airlflow_task = BashOperator(
    task_id='load_airflow',
    bash_command='python /p/airflow_load.py',
    dag=dag,
)

dag_history_model_task = PostgresOperator(
    task_id='dag_history_model',
    postgres_conn_id='redshift_dw',
    sql='/sql/dag_history_daily.sql',
    dag=dag,
)

validation_history_model_task = PostgresOperator(
    task_id='validation_history_model',
    postgres_conn_id='redshift_dw',
    sql='/sql/validator_summary_daily.sql',
    dag=dag,
)

extract_airflow_task >> load_airlflow_task
load_airlflow_task >> dag_history_model_task
load_airlflow_task >> validation_history_model_task

性能透明度

有了一个工作中的管道来衡量你的生产管道和数据验证测试的性能,还有一件事要记住:与你的数据团队和利益相关者分享由此产生的见解的重要性。管道性能的透明度对于建立与利益相关者的信任,以及在你的团队中创建归属感和自豪感至关重要。

这里有一些利用本章节生成的数据和见解的技巧:

利用可视化工具

使你创建的数据模型的度量指标能够在与你的利益相关者使用的相同可视化工具中访问。可能是 Tableau、Looker 或类似产品。无论是什么,确保它是你的团队和利益相关者每天都会去的地方。

定期分享总结的度量指标

至少每月(如果不是每周)通过电子邮件、Slack 或团队和利益相关者关注的其他地方分享总结的度量指标。

观察趋势,而不仅仅是当前值

在你分享的仪表板和总结中,不仅仅分享每个指标的最新值。还包括随时间变化的情况,并确保负面趋势像正面趋势一样频繁地指出。

反应趋势

分享度量指标的趋势不仅仅是为了展示。这是一个反应和改进的机会。验证测试的失败率比前一个月高吗?深入挖掘原因,做出改变,并观察未来的趋势来衡量你的工作的影响。

posted @ 2025-11-24 09:11  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报