成功的工程项目始于明确的愿景和目标感(我们在做什么,为什么做),并且有一个坚实的设计和架构(我们如何实现这个愿景)。将周密的计划与合适的构建模块(工具、资源和工程能力)结合,能够确保最终结果不仅反映出任务的目标,还能在规模上表现良好。Delta Lake 提供了关键的构建模块,使我们能够设计、构建、测试、部署和维护企业级数据湖仓(Lakehouse)。
本章的目标不仅仅是提供一些思路、模式和最佳实践,而是为你提供一本实用的指南。我们提供了适当的信息、推理和思维模型,帮助你将这里学到的经验汇集成清晰的蓝图,以架构你自己的数据湖仓。无论你是刚接触湖仓概念,对增量数据质量的金字塔(Medallion)架构不熟悉,还是第一次尝试处理流数据,我们都会一起走这段旅程。
我们将学习:
- 湖仓架构是什么
- 使用 Delta Lake 作为实现湖仓架构的基础
- 金字塔架构(Medallion Architecture)
- 流式金字塔架构
湖仓架构
如果成功的工程项目始于明确的愿景和目标,而我们的最终目标是为我们的数据湖仓奠定基础,那么我们首先需要定义湖仓是什么。
什么是湖仓(Lakehouse)?
湖仓是一种开放的数据管理架构,它结合了数据湖的灵活性、成本效益和可扩展性,以及传统数据仓库的数据管理、模式强制和 ACID 事务。
这个定义需要进一步拆解——特别是,它假设读者已经对数据仓库和数据湖有所了解,并理解在选择技术时需要权衡的利弊。接下来的部分将讨论每种选择的优缺点,并描述湖仓是如何发展而来的。
数据仓库和数据湖的历史以及它们共享的众多使用案例,对于曾经在数据交付和消费领域工作的人员来说应当是司空见惯的。但对于刚开始接触数据领域的你,或者正在从数据仓库迁移过来,或仅在数据湖中工作过的人来说,这一部分也同样适用。
为了理解湖仓架构的演变过程,我们需要回答以下问题:
- 如果湖仓是一种结合了数据湖和数据仓库优点的混合架构,那么它是不是应该比其各部分的总和更优秀?
- 为什么今天数据湖所带来的灵活性、成本效益和无限扩展性对我们所有人都很重要?
- 为什么仅仅依赖数据湖的优势不够,必须与数据仓库提供的模式强制、ACID 事务和良好的数据管理相结合,才能发挥出数据湖的真正优势?
向数据仓库学习
数据仓库的出现是为了解决大型企业中的数据孤岛问题,并简化商业智能(BI)和分析决策的过程。虽然数据仓库作为一种集中式解决方案,解决了特定数据领域中的结构化数据问题,但数据仓库架构中的物理限制意味着其成本会随着数据的规模和大小成比例增加。这些物理限制源于数据以本地存储(非分布式)方式存在,属于垂直扩展架构。
虽然成本是大规模数据仓库的限制因素(由于垂直扩展),但与经营多个独立数据孤岛相比,数据仓库的运营成本可能仍然是值得的。数据仓库的架构设计考虑了安全的数据管理、访问策略以及规则和标准的执行,首先保证数据一致性。这对于数据的正确性至关重要,数据的正确性现在已成为数据质量的重要组成部分。在支持类型安全的结构化数据和模式强制的支持下,数据仓库通常用于提供基础的商业智能和运营数据系统,这些系统必须提供一致的表格和清晰的数据定义。
在数据管理方面,数据仓库通过基于用户和角色的权限(称为授予)来支持访问控制,从而建立了一个安全的规则系统,用于管理哪些用户可以执行数据的读取(select)、写入(insert)、更新和删除操作。
除了成本之外,数据仓库架构无法满足当今需求的原因之一是缺乏支持各种工作负载的灵活性,包括数据科学和机器学习。
如今,传统数据仓库缺乏对常见机器学习和数据科学工作流的支持,这些工作流需要自定义的数据类型和格式,支持非结构化数据(如图像)、半结构化数据(如 CSV、JSON)和完全结构化数据(如 Parquet/ORC),以及能够高效跳过文件、修剪列和其他数据减少技术,从而轻松将整个表读入内存的能力。而不是依赖于训练和测试模型所需的原始数据,团队必须主动查询数据仓库,以生成正确的输入数据集,这可能会很棘手,尤其是当使用迭代算法时,如果跳过显式的缓存点,就需要多次往返操作。
向数据湖学习
数据湖的出现是为了将原始(未处理)数据以各种格式(CSV、JSON、ORC、文本、二进制)存储在分布式文件系统中,当时最流行的选择是 Hadoop 分布式文件系统(HDFS)。通过利用普通硬件,数据湖可以用于运行分布式处理作业(如 MapReduce),或者作为一个数据暂存区域,将数据加载到数据仓库中。如今,许多工作负载仍然遵循类似的模式,利用基于云的对象存储或其他托管的弹性存储和计算来驱动数据湖。那么,数据湖是如何与湖仓的故事相契合的呢?
数据湖为存储原始数据流(作为文件)提供了解决方案,这些数据可以直接用于数据科学和机器学习的应用场景,支持数据仓库中无法使用的数据格式。通过双层数据架构(Dual-Tier Data Architecture),这些数据流在转化后用于保持数据仓库的数据同步,这将在下一节中详细介绍。
数据湖的优势与其低成本密切相关,相比数据仓库,它的成本相对较低,并且它对文件格式的灵活性有广泛的支持。
文件格式的灵活性是把双刃剑。今天存储的数据格式可以在未来轻松变更,因为数据湖是无模式的,任何内容都可以存储在它的文件系统中。
从积极的角度看,存储和计算的分离意味着成本保持较低,直到数据被调用进行处理时,所需的开销才会增加。不幸的是,由于数据湖缺乏模式管理,当较旧的数据集从存储中提取时,问题就会出现。数据损坏就是数据湖被称为“数据沼泽”的一个主要原因。
与数据仓库进一步区分的是,数据湖不支持事务或操作级别的隔离,因此,它不支持多个数据生产者或消费者同时共享同一套资源。在一致性方面,几乎不可能在活动的读取者和写入者之间实现一致状态,或者支持多种访问模式,这在今天的批处理和流处理作业在同一物理表上运行时尤为常见。
从我们对数据湖没有规则的理解来看,最终会导致数据不稳定、数据无法使用,最坏的情况是完全“污染”或“有毒”的数据湖,基于此,出现了一个激进的想法:如果能够将两者的优点结合起来,能否实现最佳的结果?
双层数据架构(Dual-Tier Data Architecture)
双层架构是数据湖和数据仓库之间关系的自然发展。在你脑海中想象一下类似 Airflow 这样的编排平台:Airflow 的流行,正是因为它能够解决数据湖与数据仓库之间的一致性问题。那如果我们有一种方法来同时管理这两个问题呢?
与其让数据从操作数据系统(数据孤岛)直接跳跃到数据仓库(共享数据)或数据湖,不如利用双层架构依赖于提取、转换、加载(ETL)作业来管理一致性,使用数据湖中的数据来填充数据仓库。这正是图 9-1 中所示的内容。
该图展示了以下流程:
- 从孤立的数据源提取操作数据并写入到着陆区(/raw/*)。
- 从 /raw 读取数据,进行清理和转换,并将更改写入到 /cleansed。
- 从 /cleansed 读取数据(可以与其他数据进行额外的连接和规范化),然后将结果写入数据仓库。
只要工作流完成,数据湖中的数据将始终与数据仓库保持同步。这个模式还支持卸载或重新加载表格,从而节省数据仓库的成本。回头看,这个设计是合理的。
为了支持对数据的直接读取,数据湖被用来支持机器学习应用场景,而数据仓库则用于支持业务和分析处理。然而,增加的复杂性无意中加重了数据工程师的负担,他们需要管理多个“真相源”,并且维护多份相同数据(数据湖中可能存储了一份或多份,数据仓库中也有一份)的成本也大大提高,同时还得费心弄清楚哪些数据过时了,数据在哪里,为什么过时。
如果你曾玩过“两个真相和一个谎言”的游戏,你会发现这就像是架构上的等价物,不过不同的是,这可不是一个有趣的游戏,代价要高得多——毕竟,这可是我们宝贵的操作数据。拥有两个真相源,意味着系统之间可能(并且很可能)会不同步,各自讲述自己的“真相”。这也意味着每个真相源都是在撒谎,只不过它们并未意识到。
所以问题依然悬而未决:如果能够实现两者的优势,并高效地将数据湖与数据仓库结合起来,那该如何实现呢?嗯,这就是数据湖仓(Lakehouse)的诞生之地。
湖仓架构
湖仓(Lakehouse)是一种混合数据架构,它结合了数据仓库和数据湖的最佳特性。图 9-2 提供了一个简单的概念流程,展示了三个数据架构——数据仓库、数据湖和数据湖仓——各自能够支持的用例。
这种新架构通过将开放标准与统一的系统设计相结合实现——在用于数据湖的低成本存储上直接实现类似于数据仓库的数据结构和数据管理功能。
事实上,湖仓架构智能地提供了以下功能:
- 事务支持
- 架构强制和治理/审计日志与数据完整性
- 通过 SQL 和开放接口(如 JDBC)支持商业智能(BI)
- 存储和计算的分离
- 开放标准、开放 API 和开放数据格式
- 端到端流处理
- 支持多样化的工作负载,从传统的 SQL 到深度学习
通过融合两者的最佳特性,我们获得了一个单一的系统,数据团队可以利用它更快地推进工作,因为他们可以直接使用数据来完成明确的任务,而无需访问多个系统(这总是增加复杂性)。数据仓库和数据湖之间的边界消解,使得使用单一的数据表来源变得更加容易。与双层架构相比,这是一个巨大的优势。这也避免了弄清楚哪个系统(仓库或湖)中的数据是正确的,谁不同步,以及所有需要耗费成本才能给出明确答案的工作。该架构的优势还确保团队在数据科学、机器学习和商业分析项目中能够使用最完整、最新的数据。
Delta Lake基础
我们刚刚了解了湖仓架构的成功结合,这种架构在数据仓库的设计限制之外,同时享受数据湖的高可用性、几乎无边界的可扩展性以及存储和计算分离的成本效益。
本节将讨论我们在使用Delta Lake时可以获得的内置功能,以及为什么它是驱动湖仓架构的正确工具。
开放标准下的开源工具与开放生态系统
使用Delta Lake架构构建湖仓时,意味着采用开放标准,并承诺支持一个以开放协议、常识和标准惯例为中心的开放生态系统。
开放文件格式
Apache Parquet 是我们Delta表中数据的物理文件格式。Parquet在大数据社区中得到广泛支持,已经证明了它在速度和可扩展性方面的价值,但随着数据随着时间的推移自然演变,维护Parquet会变得困难。Parquet本身并不提供模式验证或演化,也不支持列重映射。
Delta带来的最大不同之处在于一致性和列级别的保证,使底层的Parquet能够在经过模式转换和微小变化的情况下继续使用,而这些变化在标准Parquet中可能会导致数据损坏。
Parquet是面向列的分析数据的标准文件格式。因此,Delta协议是开放的,任何社区成员都可以使用它来构建新的工具和连接器(我们在第4章中已经探讨过),并且可以在许多主要云服务供应商(如Amazon、Microsoft)、Starburst和Databricks提供的服务中原生使用。
自描述表元数据
每个Delta表的元数据与物理表数据一起存储。这一设计消除了需要像Hive Metastore那样维护单独的元存储来描述给定表的需求。这一设计决策使得静态表能够更高效地使用标准文件系统工具进行复制和移动,同时也支持仅包含元数据的表副本,可以使用SHALLOW CLONE命令进行探索。
开放的表规范
最后,不必担心供应商锁定问题;整个Delta Lake项目通过Linux基金会免费提供给整个开源社区,并且有着良好的社区支持。
Delta统一格式(UniForm)
UniForm是Delta Lake 3.0中引入的一个功能。它使得即使应用程序需要Iceberg或Hudi格式,也能读取Delta表中所需的格式。通过承诺实现互操作性,我们可以继续在不断扩展的数据生态系统中轻松使用Delta表。
UniForm会自动生成所需的Apache Iceberg或Apache Hudi的元数据,这样我们就不需要事先决定使用哪种湖仓格式,也不必在格式之间进行手动转换,这样的转换容易出错。借助UniForm,Delta成为跨生态系统的通用格式,为湖仓提供互操作性。
警告
启用Delta UniForm Iceberg功能需要Delta表特性IcebergCompatV2,这是一个写入协议特性。只有支持此表特性的客户端才能写入启用此特性的表。必须使用Delta Lake 3.1或更高版本才能写入启用此功能的Delta表。
启用Delta UniForm Iceberg时,需要将delta-iceberg提供给Spark shell:
–packages io.delta:io.delta:delta-iceberg_2.12:<version>
启用Delta UniForm Hudi时,需要将delta-hudi提供给Spark shell:
–packages io.delta:io.delta:delta-hudi_2.12:<version>
您可以通过Delta表属性启用Iceberg或Hudi支持:
% 'delta.universalFormat.enabledFormats' = 'iceberg, hudi'
您可以通过以下方式创建支持Iceberg和Hudi的表:
% CREATE TABLE T(c1 INT) USING DELTA TBLPROPERTIES( 'delta.universalFormat.enabledFormats' = 'iceberg, hudi');
或者在表创建后添加对Iceberg的支持:
ALTER TABLE T SET TBLPROPERTIES( 'delta.columnMapping.mode' = 'name', 'delta.enableIcebergCompatV2' = 'true', 'delta.universalFormat.enabledFormats' = 'iceberg');
UniForm通过在每次成功的Delta事务后异步生成Iceberg或Hudi表的元数据来工作。
事务支持
事务支持在数据准确性和顺序插入顺序至关重要时是非常关键的。可以说,几乎所有的生产场景都需要这一特性。我们应该始终关注达到一个最小的高标准。虽然事务意味着会有额外的检查和控制,但如果多个写入方同时对表进行修改,那么就始终存在冲突的可能性。理解分布式Delta事务协议的行为,使我们能够准确知道哪个写入应该优先,并且能够确保数据插入顺序的准确性。
可序列化写入
Delta为事务提供了ACID(原子性、一致性、隔离性、持久性)保证,同时使用一种称为写入序列化的技术,支持多个并发写入者。当新行只是简单地追加到表时(如INSERT操作),在提交操作之前不需要读取表的元数据。然而,如果表的修改更加复杂——例如,行被删除或更新——则在提交写入操作之前,必须先读取表的元数据。这一过程确保在提交任何更改之前,所做的更改不会发生冲突,否则可能会破坏Delta表上正确的顺序插入和操作顺序。为了避免损坏,冲突会导致特定的异常,由并发修改的类型触发。
读取的快照隔离
读取给定Delta表的进程会与多个并发写入者的复杂性隔离开来,保证以精确的顺序读取Delta表的一致性快照。
支持增量处理
每个表包含表的原子版本的单一顺序历史记录,对于每个表的版本,状态都保存在一个快照中。这意味着,读取Delta表特定版本(时间点)数据的进程(任务)可以直观地读取本地表快照与当前(最新)表版本之间的具体更改。
增量处理减少了维护游标(最后偏移、ID)或更复杂状态的操作负担。考虑例子9-1。我们可能在职业生涯中见过类似的任务,或者可以推测任务是从一个起始时间戳和一组记录开始,进行读取、写入或删除,并且也处理最后成功批次的最后记录。传统批处理任务的状态管理可能比较棘手,具体取决于任务的复杂性,因为我们必须手动维护检查点。例子9-1展示了必须追踪的三个变量:startTime、recordsPerBatch和lastRecordId。在此示例中,startTime变量旨在帮助结合lastRecordId创建基于时间的游标。
例子9-1:提供状态给无状态批处理任务
% ./run-some-batch-job.py \
--startTime x \
--recordsPerBatch 10000 \
--lastRecordId z
使用Delta Lake时,我们可以忽略startTime和lastRecordId,只需使用事务日志的startingVersion。这为我们提供了一个特定的读取起始点。例子9-2展示了修改后的任务。
例子9-2:为无状态批处理任务提供Delta的startingVersion
% ./run-some-batch-job.py --startingVersion 10 --recordsPerBatch 10000
虽然在这个例子中可能没有明确的“啊哈!”时刻,但Delta增量处理的力量在于,每次运行后,事务日志会告诉我们表中发生的所有变化。
支持时间旅行
事务的最大收益之一,除了能够根据错误的插入回滚和重置表外,还能利用这一功能(时间旅行)进行新操作,如查看给定表在特定时间点的状态,以便比较变更。这是一个很少有数据工程师意识到自己需要的视角,也是一项能显著减少平均解决时间(MTTR)并最小化数据停机时间的能力,因为每个表都有历史记录,而这些历史记录与Git历史或Git blame非常相似。
模式强制和治理
在以下上下文中,治理指的是管理给定表定义(数据定义语言,或DDL)结构的规则;这些规则管理构成表的列、列类型和描述性元数据。模式强制则涉及尝试向表中写入无效内容的后果。
Delta Lake 使用写时模式(schema-on-write)来实现经典数据库所要求的高一致性,并支持数据库管理系统(DBMS)中人们依赖的治理机制。为了清晰起见,接下来我们将讨论写时模式和读时模式之间的区别。
写时模式(Schema-on-write)
由于 Delta Lake 支持写时模式和声明式模式演化,正确性责任落在给定 Delta Lake 表的数据生产者身上。然而,这并不意味着只要你是数据的生产者,就可以为所欲为。记住,数据湖之所以成为“数据沼泽”,是因为缺乏治理。在 Delta Lake 中,初次成功提交的事务会自动为识别表的列和类型奠定基础。在治理角色下,我们必须遵守事务日志中写入的规则。这可能听起来有些吓人,但请放心,这样做是为了改善数据生态系统。通过清晰的模式强制规则和处理模式演化的适当程序,最终,控制表结构修改的规则能有效保护表的消费者免受潜在问题的影响。
一致数据和质量期望
在现实世界中,设立不变条件可以减少关于谁在何时何地做错了什么的讨论。在 Delta Lake 中,这意味着我们很少使用 mergeSchema 选项,并且如果有人希望使用 overwriteSchema,我们会非常关注。当你使用 Delta Lake 与一些既定的工作方式时,Delta 日志将成为你的裁决来源,实际上可以消除不必要的会议,因为你可以通过查看表的历史记录,基本上自动地定位根本原因。例如,我们可以使用 DeltaTable 类的 history 函数查看最后10个事务,如下所示:DeltaTable.forName(spark, …).history(10)。结果将提供我们对表所做更改的确切顺序,是进行根本原因分析的宝贵资源。
读时模式(Schema-on-read)
数据湖使用读时模式(schema-on-read),因为数据湖没有一致的治理或元数据,这本质上是一个被美化过的分布式文件系统。虽然读时模式灵活,但它的灵活性也是为什么数据湖被称为“荒野西部”——无治理、混乱且往往存在问题的原因。
这意味着,当数据存储在某个位置(目录根)并且采用某种文件格式(如 JSON、CSV、二进制、Parquet、文本或其他格式)时,文件可以写入特定位置并无限增长,随着数据集的时间推移,问题的潜力也会随着数据集的老化而增加。
作为数据湖中特定位置数据的消费者,你可能能够提取并解析数据,如果你幸运的话——它甚至可能有一些文档,如果你非常幸运——并且如果你有足够的时间和计算能力,你大概能够完成你的工作。然而,没有适当的治理和类型安全,数据湖可以迅速增长到多个 TB(如果你爱烧钱,也可以达到 PB 级别),或基本上成为低存储成本的“数据垃圾”。虽然这是一个极端的说法,但在许多数据组织中,这也是现实。
存储与计算的分离
Delta Lake 提供了存储和计算之间的明确分离。数据湖架构的最大好处之一是无限制存储和文件系统的可扩展性。湖仓架构采用了数据湖的优势,因为生成和消费大量数据是现代数据分析和机器学习的基本组成部分。
理论上,只要你在模式强制、符合性和演化方面有严格的治理——这来自于写时模式的不变条件——结合对底层文件格式(如 Parquet)的规范化支持,你就可以获得接近无限的可扩展性(在合理范围内)用于存储在数据湖仓中的数据,采用的是可互操作和极具便携性的文件格式。便携性方面甚至可以进一步分解。你可以将 Delta Lake 表(即打包整个湖仓并转移)从一个云迁移到另一个云,同时保持所有表的完整性——包括事务日志。
逻辑操作与物理反应的分离
值得指出的是,在 Delta Lake 中,逻辑操作与在底层物理存储层产生的物理反应之间有着更大的分离。以我们在第五章中清理表的例子为例:调用 DELETE FROM 对给定表进行删除时,实际上是与物理文件受影响(真正删除)之间的分离。这是因为时间旅行功能(回溯/撤销)使我们能够删除意外删除的数据——这种删除本可能破坏数据的完整性,并且没有恢复的机会。每个人的职业生涯中都曾发生过数据的意外删除,只是并非每个人都承认!这就是为什么 VACUUM 和 REORG 操作如此重要的原因。为了真正删除文件,必须执行一个带有物理反应的操作。
支持事务流处理
我们在第七章中介绍了 Delta 的流处理能力。在 Delta 中,能够轻松地在批处理和流处理之间切换,无论是特定操作(如入站读取或出站写入),最初听起来可能会像魔法一样。许多流处理管道因源表上的分布式文件突然消失而遭遇意外终结,这些变化可能是由外部因素(如覆盖作业替换丢失的数据)引起的,但 Delta 提供了对多版本并发控制(MVCC)的完整支持,这意味着一个流应用程序读取表时,不会因为并发写入操作而中断。
Delta Lake 支持完整的端到端流处理,而不会牺牲速度和质量。所有事物都有权衡,在现实世界中,最好权衡延迟成本与速度需求,并就业务或数据团队愿意做出的权衡达成共识,以实现正确的平衡。虽然我们不能保证一切总是顺利,但使用 Delta Lake 时提供的保护机制能够帮助平稳应对各种波动。
分析与机器学习工作负载的统一访问
总的来说,Delta 为各种数据相关的解决方案提供了一种平衡的方法。数据分析师和BI工程师可以轻松地使用简单的SQL查询,而同时也支持数据科学和机器学习工作负载中所需的高效、直接的物理文件访问;后者为数据科学和机器学习工作负载提供了正确的操作模型,在这种模型中,要求对所有列数据进行直接访问,包括在任务范围内运行迭代算法(就地运行)的能力。
Delta Sharing 协议
在数据建模之后,安全且可靠地在内部和外部利益相关者之间共享数据是最具挑战性的问题之一。常见的做法是将数据从数据湖导出——例如,从一个 S3 存储桶导出到另一个存储桶。基本上使用文件传输协议(FTP)进行数据传输的原因,归结于缺乏身份和访问管理(IAM)以及可互操作数据格式的标准。Delta Sharing 协议解决了这个问题。
图 9-3 显示了 Delta Sharing 协议。物理 Delta 表作为唯一的真实数据源存在,Delta Sharing 服务器的引入补充了缺失的访问控制和治理机制,确保提供安全可靠的数据交换。
使用 Delta Sharing 协议可以为内部或外部利益相关者提供对 Delta 表的安全直接访问。这消除了在导出数据时产生的运营成本,同时节省了时间、金钱和工程团队的精力,并提供了一个共享的、平台无关的真实数据源。我们将在第 14 章深入探讨 Delta Sharing 协议,作为本书的结尾。
Delta 协议提供的一般功能支持了数据湖仓所需的基础能力。现在,我们将转换视角,更具体地探讨如何在湖仓中构建数据质量架构,采用一种目标驱动的分层数据架构,即“奖章架构”(medallion architecture)。
奖章架构
在数据流动过程中,数据往往是杂乱无章的,因为它以各种形态和大小出现,并且具有不同的准确性和完整性。接受数据并不总是符合最终用户的期望、现有的数据合同和已建立的数据质量检查,甚至不会准时到达——或者根本不出现——是解决这些数据质量问题的关键。这些挑战对数据工程团队造成了很大的压力,要求他们在不断变化的主观和客观需求中持续交付,而从这种集体的努力中诞生了奖章架构。
奖章架构是一种数据设计模式,用于在湖仓中逻辑地组织数据。这是通过使用一系列独立的数据层来实现的,目的是为数据集的逐步优化提供框架。图 9-4 显示了架构的高层视图,其中数据从批处理或流式源头流动,跨越不同的数据血统——从初始数据摄取点(青铜层)开始,经过多个处理和增强阶段。
数据在流动过程中常常是杂乱无章的,因为它以各种形态和大小出现,并且准确性和完整性各不相同。接受数据并不总是符合最终用户的期望、现有的数据合同和已建立的数据质量检查,甚至不会准时到达——或者根本不出现——是解决这些数据质量问题的关键。这些挑战对数据工程团队造成了很大的压力,要求他们在不断变化的主观和客观需求中持续交付,而从这种集体的努力中诞生了奖章架构。
奖章架构是一种数据设计模式,用于在湖仓中逻辑地组织数据。这是通过使用一系列独立的数据层来实现的,目的是为数据集的逐步优化提供框架。图 9-4 显示了架构的高层视图,其中数据从批处理或流式源头流动,跨越不同的数据血统——从初始数据摄取点(青铜层)开始,经过多个处理和增强阶段。
探索青铜层
青铜层代表了湖仓中数据血统的初始点。这里的常见做法是对数据应用最小化的转换(如果有的话)。这些是不能忽视的转换,比如将源格式转换为兼容Delta Lake写入的类型。采用最小化转换的方法意味着我们保留了未来重新处理这些原始数据的选项,以支持更多的用例或修改的需求。
青铜层用于最小化增强
青铜层通常用于将源数据转换为写入Delta Lake所需的格式。当你采用最小化增强的方法时,还可以探索简化甚至自动化这个初始数据摄取步骤。使用与DataFrame API兼容的开放数据协议——例如,使用类型安全的二进制可序列化交换格式,如Apache Avro或Google Protocol Buffers——意味着你可以花更多时间解决更重要的问题,而不是摄取数据。对于少量的表,可能会认为可以忽略自动化,但随着表数量的增加,忽略自动化显然对工程师的心理健康不好。
最小化转换和增强
由于我们摄取的数据尽可能接近“原始”,因此需要记住保持一个有限的模式,并尽量减少对数据的转换。让我们用一个具体的例子来说明:假设我们从Kafka这样的流式源读取数据,我们希望捕获每条记录的主题名称、二进制键、值以及时间戳,并将它们写入Delta Lake表。这些属性都存在于Kafka的DataFrame结构中(如果我们使用Spark的KafkaSource API),并且可以使用kafka-delta-ingest库提取(第4章中首次探讨)。
示例 9-3(ch09/notebooks/medallion_bronze.ipynb)是一个简洁的示例,展示了最小化转换和增强。
示例 9-3:这是一个简单的青铜层管道,读取Kafka数据,应用最小化转换,然后将数据写入Delta
% reader_opts: Dict[str, str] = …
writer_opts: Dict[str, str] = …
bronze_layer_stream = (
spark.readStream
.options(**reader_opts)
.format("kafka").load()
.select(col("key"),col("value"),col("topic"),col("timestamp"))
.withColumn("event_date", to_date(col("timestamp")))
.writeStream
.format('delta')
.options(**writer_opts)
.partitionBy("event_date")
)
streaming_query = bronze_layer.toTable(...)
示例 9-3 中应用的极简方法只保留了尽可能接近原始数据的必要信息。这种技术将提取和转换数据的责任留给了银层。尽管我们增加了一些额外的工作,这种最简方法使得未来能够重新处理(重新读取)Kafka中的原始数据,而不必担心数据过期(这可能导致数据丢失)。大多数Kafka中的数据保留时间为24小时到7天之间。
如果我们从外部数据库读取数据,例如Postgres,最小模式仅仅是表的DDL。鉴于数据库的写时模式(schema-on-write)特性,我们已经明确了行为和预期,因此与示例 9-3 中的示例相比,银层所需的工作量可以简化。
作为经验法则,如果数据源具有类型安全的模式(如Avro、Protobuf)或实现了写时模式(schema-on-write),那么青铜层的工作量通常会大大减少。这并不意味着我们可以盲目地直接写入银层,因为青铜层是第一个拦截不符合规范或损坏的数据,防止它们继续向金层推进。在处理非类型安全数据的情况下——例如CSV或JSON数据——青铜层至关重要,它能够有效清除损坏或有问题的数据。
使用Spark的宽容模式保护青铜层
示例 9-4展示了一种名为宽容通过(permissive passthrough)的技术。该选项允许我们使用预定义(一致)的模式来添加一个门控机制,以拦截损坏的数据,同时保留不符合规范的行以供调试。
示例 9-4:使用宽容通过防止坏数据
% from pyspark.sql.types import StructType, StructField, StringType
known_schema: StructType = (
StructType.fromJson(...)
.add(StructField('_corrupt', StringType(), True, {
'comment': 'invalid rows go into _corrupt rather than simply being dropped'
}))
happy_df = (
spark.read.options(**{
"inferSchema": "false",
"columnNameOfCorruptRecord": "_corrupt",
"mode": "PERMISSIVE",
})
.schema(known_schema)
.json(...)
首先,我们使用StructType.fromJson方法加载已知模式。我们也可以使用StructType().add(...)模式手动构建模式。
然后,我们将_corrupt字段添加到模式中。这将为我们的坏数据提供一个容器。可以认为,_corrupt列要么为null,要么包含值。然后可以使用过滤器where(col("_corrupt").isNotNull())或者相反,来分离好数据和坏数据。
接着应用读取选项:“inferSchema”:“false”,“mode”:“PERMISSIVE”,“columnNameOfCorruptRecord”:“_corrupt”。通过关闭模式推断,我们可以选择只通过明确提供更新的模式来进行模式变化。这意味着不会出现运行时的惊讶。模式推断是一种强大的技术,它扫描(采样)大量的半结构化数据行(如CSV或JSON),以生成它认为是稳定的StructType(模式)。模式推断的问题在于,它不了解数据的历史结构,并且仅限于根据初始批次提供的内容生成假设。
示例 9-4 中的技术同样适用于流式转换,只需使用from_json原生函数,它位于sql.functions包中(pyspark.sql.functions,spark.sql.functions)。这意味着我们可以先在批处理中进行测试,然后再开启流式数据管道,从而准确了解我们的数据摄取管道在半结构化数据的不一致世界中的具体行为。
尽管青铜层可能在范围和责任上感觉有限,但它在调试和恢复中扮演着极其重要的角色,并且是未来新思路的来源。由于青铜层表的原始性质,也不建议广泛广播这些表的可用性。没有什么比因误用原始表而导致的事件处理更糟糕了。
探索银层
在勋章架构中,铜层代表数据血统的初始点,而银层则代表原始数据被过滤、清理、处理,并通过与一个或多个其他表连接进行增强的阶段。如果铜层是数据的“婴儿期”,那么银层就是数据的“青少年期”,正如我们在青少年时期的成长经历一样,数据的成长过程也充满了起伏。
用于清理和过滤数据
根据最初进入铜层的数据来源,我们可能会面临一个充满挑战的过程。正如没有两个完全相同的人一样,数据源的一致性和基准质量可能会有很大的差异。这时,初步的清理和过滤就显得尤为重要。
我们清理数据,目的是将其规范化,呈现一个可靠的源数据,供下游使用。我们的下游用户可能是我们自己、组织内的其他团队,甚至是外部利益相关者。在极端情况下,我们可能需要从流式数据源(如 Kafka)中提取并解码二进制数据,将其从 Avro 或 Protobuf 转换后,进行更多的转换。我们的管道输出可能会生成嵌套或扁平化的行。
在这一阶段,通常也需要过滤或丢弃一些列。在示例9-4中,我们看到了包括 _corrupt 列的模式。这个信息在银层或金层中并不适用于消费,它仅用于支持铜层中的数据保存技术,并作为向工程师报告问题的一种方式。
工程师通常会提供类似 _corrupt 或 _debug 的 * 列,这些列包含简单的信息或更具体的结构体或映射。这个技术也可以用于携带可观察性元数据或报告所需的附加上下文。
示例 9-5 继续了示例 9-4,展示了如何从铜层的 Delta 表继续读取,并进行过滤、丢弃和转换,以便将数据写入清理后的银层表。
示例 9-5:过滤、丢弃和转换——写入银层所需的所有操作
% medallion_stream = (
delta_source.readStream.format("delta")
.options(**reader_options)
.load()
.transform(transform_from_json)
.transform(transform_for_silver)
.writeStream.format("delta")
.options(**writer_options))
.option('mergeSchema': 'false'))
streaming_query = (
medallion_stream
.toTable(f"{managed_silver_table}"))
示例9-5中的管道从铜层的 Delta 表(来自示例9-3)读取数据,解码接收到的二进制数据(来自 value 列),同时启用 permissive 模式,这是我们在示例9-4中探索过的:
def transform_from_json(input_df: DataFrame) -> DataFrame:
return input_df.withColumn("ecomm",
from_json(
col("value").cast(StringType()),
known_schema,
options={
'mode': 'PERMISSIVE',
'columnNameOfCorruptRecord': '_corrupt'
}
))
接下来,我们需要进行第二次转换,以便为写入银层做准备。这是一个小的次要转换,移除任何有问题的行,并对数据和时间戳进行别名声明,这些时间戳可能与事件时间戳和日期不同:
def transform_for_silver(input_df: DataFrame) -> DataFrame:
return (
input_df.select(
col("event_date").alias("ingest_date"),
col("timestamp").alias("ingest_timestamp"),
col("ecomm.*")
)
.where(col("_corrupt").isNull())
.drop("_corrupt"))
在完成这些转换后,我们将数据写入银层 Delta 表中。我们还显式设置了 mergeSchema: false。虽然这是默认行为,但它仍然是一个重要的提醒,因为它向其他工程师明确了预期行为,并确保没有意外的列从铜层误导入到银层。我们在第5章中讨论了使用 ALTER TABLE 进行自动模式演化的替代方法。
无论我们清理和过滤铜层数据的原因是什么,最终我们的努力将为利益相关者提供更加一致和可靠的数据,支持他们各种不同的用例。我们可以认为银层是勋章架构中的第一个稳定层。
建立数据增强的层
并没有规定银表必须从铜表读取。事实上,银层通常会用来从一个或多个银表甚至金表中进行连接。例如,如果清理和过滤一个铜表的数据可以支持多个额外的用例,那么我们可以通过重用我们内部团队和外部合作伙伴的工作成果,节省时间并降低复杂性。从概念上来说,图9-5展示了表的血统,从左到右开始,首先是两个铜表(左侧),接着是一系列的连接和转换(银层),最后生成金表(右侧)。
随着表和视图数量的增加,以及数据产品及其所有者的逐步增长,能够查看铜层、银层和金层之间的血统关系,有助于提供更多的背景信息。我们将在第13章中更详细地讨论血统问题。
启用数据质量检查和平衡
Delta 提供了基于列的约束功能,以增强简单模式强制执行无法提供的功能(回顾一下,模式强制执行和演化已在第5章中讨论过)。
通过列级约束,我们可以通过应用 CHECK 形式的谓词,在表级别直接强制执行更复杂的规则:
ALTER TABLE <tablename>
ADD CONSTRAINT <name>
CHECK <sql-predicate>
这样做的好处是,我们可以确保表中的数据始终符合约束标准。坏处是,如果任何一行不符合约束条件,将抛出 DeltaInvariantViolationException,从而中断作业。
数据质量框架可以通过将规则与底层物理表定义分离,来简化表约束。一些流行的开源框架包括 Great Expectations、Spark Expectations 和 Delta Live Tables (DLT) 期望(后者是 Databricks 提供的付费服务)。数据质量是 DataOps 的一个重要部分,它可以帮助在数据离开勋章架构中的特定层之前,阻止不良数据的流入。
记住:作为数据工程师,我们需要像主人一样行动,并为我们的数据利益相关者提供优质的客户服务。我们越早在精炼过程中过滤和设定良好的质量门控,下游的数据消费者就会越满意。
探索金层
金层是勋章架构中最成熟的数据层。金层中的数据经过了多次转化,并且被特别策划过,具有在数据世界中的特定位置。这是因为金层中的数据是为了解决明确的预定目标而特别构建的。如果铜层代表的是婴儿阶段的数据,银层是青少年阶段的数据,那么金层就代表了数据的三四十岁——或者说是数据在其成长过程中已经确立了具体的身份。
建立高信任度和高一致性
虽然将数据比作不同生命周期阶段的人这一类比可能不完全准确,但作为一种思维模型,它是有效的。金层中的数据很少会发生剧烈变化,就像随着年龄的增长,我们的个性、需求和愿望改变的速度也会变得较慢。示例 9-6展示了如何从银层(示例 9-5)的转化数据中生成 TopN 报告。
示例 9-6. 为业务层消费创建有意的表
% pyspark
silver_table = spark.read.format("delta")...
top5 = (
silver_table
.groupBy("ingest_date", "category_id")
.agg(
count(col("product_id")).alias("impressions"),
min(col("price")).alias("min_price"),
avg(col("price")).alias("avg_price"),
max(col("price")).alias("max_price")
)
.orderBy(desc("impressions"))
.limit(5))
(top5
.write.format("delta")
.mode("overwrite")
.options(**view_options)
.saveAsTable(f"gold.{topN_products_daily}"))
示例 9-6展示了如何进行每日聚合。报告数据通常存储在金层中。这是我们(以及业务方)最关心的数据。我们的工作是确保提供有目的构建的表(或视图),以确保业务关键数据的可用性、可靠性和准确性。
对于基础表——实际上,对于任何业务关键数据——突如其来的变化都是令人不安的,可能导致报告失败,并且会影响机器学习模型的运行推理。这不仅仅是钱的问题,可能关系到公司是否能在竞争激烈的行业中保持客户和声誉。
金层可以使用物理表或虚拟表(视图)来实现。这为我们提供了优化我们策划表的方式,最终结果要么是完整的物理表(当没有使用视图时),要么是简单的元数据,提供在与虚拟表交互时所需的任何过滤器、列别名或连接条件。性能要求最终将决定是使用表还是视图,但视图已经足够支持许多金层用例的需求。
现在我们已经探讨了勋章架构,接下来我们将深入研究如何通过模式来减少从数据摄取到数据成为下游利益相关者可以消费的时间和精力要求。
流式勋章架构
在前面的内容中,我们学习了勋章架构是一种数据设计模式,能够帮助我们解决常见的飞行中数据问题,这些问题包括:
- 缺乏重播或恢复能力(可以通过铜层解决)
- 列级别的期望被破坏(可以通过Delta协议解决,关闭mergeSchema并忽略overwriteSchema,除非必要时作为最后手段)
- 列特定的数据质量和正确性问题(可以通过约束或使用像spark-expectations或Delta Live Tables带有@dlt.expect的工具库来解决)
虽然我们已经探讨了通过勋章架构精炼数据以去除不完美、遵循明确定义的模式并提供数据检查和平衡,但我们没有涉及如何提供一个无缝的流转过程,使数据从铜层到银层再到金层的转化顺畅进行。
时间常常成为阻碍,太少的时间意味着没有足够的信息做出明智的决策,而太多的时间则容易导致自满,有时甚至有点懒散。因此,时间是一个类似金发姑娘问题的难题,尤其是当我们关注于减少数据在湖仓中的端到端延迟时。接下来的部分,我们将探讨减少勋章架构中各层延迟的常见模式,重点是端到端流式处理。
正如本书中多次提到的,Delta协议支持批量或流式访问表。我们可以部署我们的管道,采取特定步骤,确保输出的数据集符合质量标准,并使我们能够信任上游数据源,从而大大减少端到端延迟,从数据摄取(铜层)到银层,最终到达金层中的业务或数据产品所有者。
通过精心设计我们的管道,在数据质量问题变得更加普遍之前进行拦截和修正,我们可以利用从示例 9-3到示例 9-6中获得的经验,构建一个端到端的流式工作流。
图 9-6展示了流式工作流的一个例子。数据从我们的Kafka主题到达,如示例 9-3所示。然后,数据集被附加到我们的铜Delta表(ecomm_raw),这使我们能够在银层应用中捕捉增量变化。提供转化的示例见示例 9-5。最后,我们要么创建并替换临时视图(或在Databricks中创建物化视图),要么创建另一个金层应用,负责定期从ecomm_silver摄取数据,以生成专门构建的表或视图。通过扩展示例 9-6中看到的模式,我们可以将流式管道拼接在一起,从直接上游增量摄取数据,允许我们追踪所有转化的血缘关系,直到最初的起点(Kafka)。
有许多方法可以通过定时作业或完整的框架(如 Apache Airflow、Databricks Workflows 或 Delta Live Tables)来编排端到端的工作流。最终的结果是,我们可以从边缘到最重要、最关键的黄金表格中减少延迟。
对于 Delta Lake 和 Spark Structured Streaming 如果您正在从批处理优先架构迁移到流式优先架构,最简单的方法是在逐步推进过程中依赖触发器。例如,df.writeStream...trigger(availableNow=True).toTable(...),这样您可以继续以批处理的方式操作,同时使数据应用程序能够轻松转换为始终运行的流式应用程序。使用结构化流式处理的一个好处是,所有复杂的状态管理都通过应用程序检查点来处理;另一个额外的好处是,availableNow 触发器会尊重 DataStreamReader 上的任何速率限制选项,例如 maxFilesPerTrigger。
总结
这一章介绍了现代湖仓架构的基本原则,并展示了Delta Lake如何为这一任务提供基础支持。
湖仓架构建立在开放标准之上,支持开放协议和格式,具备ACID事务支持、表级时间旅行以及与UniForm的简化互操作性,还提供开箱即用的数据共享协议,简化了内外部利益相关者之间的数据交换。我们概述了Delta协议,并通过学习提供我们接入规则的不可变性以及表级保证,了解了如何通过“写时模式”和“模式强制”保护下游数据消费者免于意外泄漏腐败或低质量的数据。
接着,我们探讨了如何利用勋章架构提供一个标准框架来确保数据质量,并详细了解了每个层级在常见的铜、银、金模型中的应用。
质量门控模式使我们能够构建一致的数据策略,并基于从铜层(原始数据)到银层(清洗和标准化数据),再到金层(精心策划和面向目标的数据)的增量质量模型提供保证和期望。数据在湖仓内部和这些门控层之间的流动,不仅增强了湖仓内的数据信任度,还通过启用湖仓中的端到端流式处理,帮助我们减少了端到端延迟。