原文:
annas-archive.org/md5/c47e39c1bc577f11068c3d7425d9a76d译者:飞龙
第四章:现代数据架构
在本章中,我们将探讨为构建可扩展和灵活的数据平台而出现的现代数据架构。具体来说,我们将介绍 Lambda 架构模式,并讨论它如何结合批量数据分析实现实时数据处理。你将了解 Lambda 架构的关键组件,包括用于历史数据的批处理层、用于实时数据的速度处理层,以及用于统一查询的服务层。我们还将讨论如何利用 Apache Spark、Apache Kafka 和 Apache Airflow 等技术,在大规模环境中实现这些层。
本章结束时,你将理解构建现代数据湖的核心设计原则和技术选择。你将能够解释 Lambda 架构相比传统数据仓库设计的优势。最重要的是,你将拥有开始构建自己现代数据平台的概念基础。
所涵盖的概念将帮助你以低延迟处理流数据,同时在历史数据上执行复杂的分析工作负载。你将获得利用开源大数据技术构建可扩展、灵活的数据管道的实践经验。无论你需要实时分析、机器学习模型训练,还是临时分析,现代数据架构模式都能帮助你支持多样化的数据需求。
本章提供了从传统数据仓库过渡到下一代数据湖的蓝图。通过这些课程,你将掌握构建现代数据平台所需的关键架构原则、组件和技术,以便在 Kubernetes 上实现。
在本章中,我们将涵盖以下主要内容:
-
数据架构
-
大数据数据湖设计
-
实现湖屋架构
数据架构
现代数据架构在过去十年中经历了显著的发展,使得组织能够利用大数据的力量并推动先进的数据分析。两种关键的架构模式是 Lambda 架构和 Kappa 架构。在本节中,我们将探讨这两种架构,并了解它们如何为构建我们的大数据环境提供有用的框架。
Lambda 架构
Lambda 架构是一种大数据处理架构模式,平衡了批处理和实时处理方法。其名称源自 Lambda 计算模型。Lambda 架构在 2010 年代初期变得流行,成为一种以经济高效和灵活的方式处理大规模数据的方法。
Lambda 架构的核心组件包括以下内容:
-
批处理层:负责管理主数据集。该层在固定间隔内批量摄取和处理数据,通常为每 24 小时一次。数据处理完成后,批处理视图被视为不可变的,并被存储。
-
速度层:负责处理尚未由批处理层处理的最新数据。此层在数据到达时实时处理数据,以提供低延迟视图。
-
服务层:负责通过合并批处理层和速度层的视图来响应查询。
这些组件在图 4.1的架构图中有所展示:
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_04_01.jpg
图 4.1 – Lambda 架构设计
Lambda 架构的主要优点是它提供了一种混合方法,将大量历史数据视图(批处理层)与最新数据的视图(速度层)结合在一起。这使得分析师可以以统一的方式查询最新和历史数据,从而获得快速的洞察。
批处理层针对吞吐量和效率进行了优化,而速度层则针对低延迟进行了优化。通过分离各自的职责,该架构避免了每次查询都需要运行大规模、长期运行的批处理任务。相反,查询可以利用预计算的批处理视图,并通过速度层的最新数据进行增强。
在构建于云基础设施上的现代数据湖中,Lambda 架构提供了一个灵活的蓝图。云存储层作为基础数据湖,用于存储数据。批处理层利用分布式数据处理引擎,如 Apache Spark,生成批处理视图。速度层流式处理并处理最新数据,服务层运行高效的查询引擎,如 Trino,用于分析数据。
Kappa 架构
Kappa 架构作为一种替代方法,最近由 Lambda 架构的主要创始人提出。Kappa 架构的主要区别在于,它旨在通过消除单独的批处理层和速度层来简化 Lambda 模型。
相反,Kappa 架构通过单一的流处理路径来处理所有数据。其关键组件包括以下内容:
-
流处理层:负责将所有数据作为流进行摄取和处理。此层处理历史数据(通过重放日志/文件)以及新来的数据。
-
服务层:负责通过访问流处理层生成的视图来响应查询。
我们可以在图 4.2中看到一个可视化表示:
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_04_02.jpg
图 4.2 – Kappa 架构设计
Kappa 架构的核心是一个不可变的、仅附加的日志,供所有数据使用工具(如 Kafka 和事件溯源范式)使用。流数据直接被摄取到日志中,而不是通过独立的管道。该日志确保数据有序、无法篡改,并支持自动重放——这些是流处理和批处理的关键支持功能。
Kappa 架构的优势在于其设计的简单性。通过单一的处理路径,不需要管理独立的批处理和实时系统。所有数据都通过流处理进行处理,这也使得历史数据的重新处理和分析变得更加灵活。
其权衡之处在于,流处理引擎可能无法提供与最先进的批处理引擎相同的规模和吞吐量(尽管现代流处理器已不断发展,以处理非常大的工作负载)。另外,尽管 Kappa 设计可能更简单,但其架构本身可能比 Lambda 更难实现和维护。
对于数据湖,Kappa 架构与大量原始数据的特性非常契合。云存储层充当原始数据的骨干。然后,像 Apache Kafka 和 Apache Flink 这样的流处理器接收、处理并生成分析准备好的数据视图。服务层利用 Elasticsearch 和 MongoDB 等技术来推动分析和仪表板的展示。
比较 Lambda 和 Kappa
Lambda 和 Kappa 架构采取不同的方法,但在准备、处理和分析大数据集方面解决了相似的问题。其主要区别列于表 4.1:
| Lambda | Kappa | |
|---|---|---|
| 复杂度 | 管理独立的批处理和实时系统 | 通过流合并处理 |
| 重新处理 | 重新处理历史批次 | 依赖于流重放和算法 |
| 延迟 | 在速度层中对最近数据有较低的延迟 | 所有数据的延迟相同 |
| 吞吐量 | 利用优化吞吐量的批处理引擎 | 将所有数据作为流处理 |
表 4.1 – Lambda 和 Kappa 架构的主要区别
在实践中,现代数据架构通常将这些方法结合使用。例如,Lambda 的批处理层可能每周或每月运行一次,而实时流则填补空白。Kappa 可能会在流中利用小批次来优化吞吐量。平衡延迟、吞吐量和重新处理的核心思想是相同的。
对于数据湖,Lambda 提供了一个经过验证的蓝图,而 Kappa 提供了一个强大的替代方案。虽然有人可能认为 Kappa 提供了更简化的操作,但它难以实现,并且随着规模的扩大,其成本可能快速增长。Lambda 的另一个优势是它完全可以适应需求。如果没有必要进行数据流处理(或者没有经济可行性),我们可以仅实现批处理层。
数据湖构建者应该理解每种架构的关键原则,以便根据他们的业务、分析和运营需求设计最佳架构。通过利用云基础设施的规模和灵活性,现代数据湖可以实施这些模式,以处理当今的数据量并推动高级分析。
在下一节,我们将深入探讨 Lambda 架构方法以及它如何应用于创建高性能、可扩展的数据湖。
大数据的数据湖设计
在本节中,我们将对比数据湖与传统数据仓库,并涵盖核心设计模式。这将为后面“如何做”的工具和实施部分奠定基础。让我们从现代数据架构的基线——数据仓库开始。
数据仓库
数据仓库几十年来一直是商业智能和分析的支柱。数据仓库是一个集成多个来源的数据存储库,经过组织和优化,旨在用于报告和分析。
传统数据仓库架构的关键方面如下:
-
结构化数据:数据仓库通常只存储结构化数据,例如来自数据库和 CRM 系统的交易数据。不包括来自文档、图片、社交媒体等的非结构化数据。
-
写时模式:数据结构和模式在数据仓库设计时就已定义。这意味着添加新的数据源和改变业务需求可能会很困难。
-
批处理:数据提取、转换和加载(ETL)是按计划批量从源系统提取的,通常是每日或每周。这会引入访问最新数据时的延迟。
-
与源系统分离:数据仓库作为一个独立的数据存储,用于优化分析,与源事务系统分开。
在大数据时代,数据量、种类和速度的增长暴露了传统数据仓库架构的一些局限性。
它无法以具有成本效益的方式存储和处理来自新来源(如网站、移动应用、物联网设备和社交媒体)的庞大非结构化和半结构化数据。此外,它缺乏灵活性——添加新的数据源需要更改模式和 ETL,这使得适应变得缓慢且昂贵。最后,批处理无法快速提供足够的洞察力,以满足实时个性化和欺诈检测等新兴需求。
这引发了数据湖架构的产生,作为应对,我们将在接下来的部分详细讨论。
大数据和数据湖的崛起
为应对上述挑战,新的数据湖方法使得能够大规模处理任何类型的数据存储,使用诸如 Hadoop HDFS 或云对象存储等负担得起的分布式存储。数据湖以读取时模式运行,而不是预先定义的模式。数据以原生格式存储,只有在读取时才会解释模式。它包括通过诸如 Apache Kafka 等工具捕获、存储和访问实时流数据。此外,还有一个庞大的开源生态系统,支持可扩展的处理,包括 MapReduce、Spark 和其他工具。
数据湖是一个集中式的数据存储库。它旨在以原始格式存储数据,这样可以灵活地按需分析不同类型的数据(表格、图像、文本、视频等),而无需预先确定数据的使用方式。由于它是基于对象存储实现的,因此可以存储来自不同来源、在不同时间间隔下的数据(有些数据可能每天更新,有些每小时更新,甚至有些是接近实时的数据)。数据湖还将存储和处理技术分开(与数据仓库不同,数据仓库将存储和处理整合在一个独特的结构中)。通常,数据处理涉及分布式计算引擎(如 Spark),用于处理 TB 级别的数据。
数据湖提供了一种以成本效益的方式存储组织面临的庞大而多样化数据量并进行分析的方式。然而,它们也面临一些挑战:
-
如果没有治理,数据湖有可能变成无法访问的数据沼泽。数据需要进行分类并提供上下文,以便发现。
-
准备原始数据进行分析仍然涉及在分散的孤立工具中进行复杂的数据处理。
-
大多数分析仍然需要先对数据进行建模、清洗和转换——就像数据仓库一样。这种做法导致了重复劳动。
-
数据湖中使用的基于对象存储的系统无法执行行级修改。每当表格中的一行需要修改时,整个文件都必须重写,这会大大影响处理性能。
-
在数据湖中,没有高效的模式控制。虽然按需读取模式使得新的数据源更容易加入,但无法保证因为数据摄取失败,表格的结构不会发生变化。
近年来,为了克服这些新挑战,业界进行了大量努力,将两者的优势结合在一起,这就是现在所称的数据湖仓(data lakehouse)。让我们深入了解这一概念。
数据湖仓的兴起
在 2010 年代,随着 Delta Lake、Apache Hudi 和 Apache Iceberg 等新兴开源技术的出现,“湖仓”(lakehouse)这一术语开始受到关注。湖仓架构旨在结合数据仓库和数据湖的最佳特性:
-
支持像数据湖一样在任何规模上处理多种结构化和非结构化数据
-
提供像数据仓库一样对原始数据和精炼数据进行高效的 SQL 分析
-
对大规模数据集的ACID(原子性、一致性、隔离性和持久性)事务
数据湖仓允许以开放格式同时存储、更新和查询数据,同时确保数据在大规模环境下的正确性和可靠性。它支持以下功能:
-
模式强制执行、演化和管理
-
行级更新插入(更新+插入)和删除以实现高效的可变性
-
按时间点一致性视图跨历史数据
使用湖屋架构,整个分析生命周期——从原始数据到清洗和建模后的数据,再到策划的数据产品——都可以在一个地方直接访问,适用于批处理和实时用例。这提升了敏捷性,减少了重复劳动,并通过生命周期使数据的重用和再利用更加容易。
接下来,我们将探讨数据在这一架构概念中的结构化方式。
湖屋存储层
与数据湖架构类似,湖屋(Lakehouse)也建立在云对象存储之上,通常分为三个主要层次:铜层、银层和金层。这种方法被称为“奖章”设计。
铜层是原始数据摄取层。它包含来自各种来源的原始数据,存储方式与接收到时完全相同。数据格式可以是结构化的、半结构化的或非结构化的。例如,日志文件、CSV 文件、JSON 文档、图片、音频文件等。
该层的目的是以最完整和最原始的格式存储数据,作为分析用途的真相版本。在这一层不会进行任何转换或聚合。它作为构建更高层次的策划和聚合数据集的源数据。
银层包含精心策划、精炼和标准化的数据集,这些数据集经过丰富、清洗、集成,并符合业务标准。数据具有一致的模式,能够进行查询以支持分析。
该层的目的是准备高质量的、可分析的数据集,这些数据集可以为下游分析和机器学习模型提供支持。这涉及到数据整理、标准化、去重、联合不同数据源等。
该结构可以是表格、视图或优化查询的文件。例如,Parquet 文件、Delta Lake 表、物化视图等。元数据被添加以启用数据发现。
金层包含聚合后的数据模型、指标、关键绩效指标(KPI)和其他衍生数据集,支持商业智能和分析仪表板。
该层的目的是为业务用户提供现成的策划数据模型,用于报告和可视化。这涉及到预计算指标、聚合、业务逻辑等,以优化分析工作负载。
该结构通过列存储、索引、分区等优化分析。示例包括聚合、数据立方体、仪表板和机器学习模型。元数据将其与上游数据连接。
有时,在铜层之前,通常会有一个额外的层——着陆区。在这种情况下,着陆区接收原始数据,所有清洗和结构化工作都在铜层完成。
在接下来的章节中,我们将看到如何使用现代数据工程工具将数据湖屋设计付诸实践。
实施湖屋架构
图 4.3 显示了在 Lambda 设计中实现数据湖仓架构的可能方式。该图显示了常见的数据湖仓层以及在 Kubernetes 上实现这些层所使用的技术。左侧的第一组代表了与此架构一起工作的可能数据源。此方法的一个关键优势是它能够摄取和存储来自多种来源和格式的数据。如图所示,数据湖可以连接并整合来自数据库的结构化数据,以及来自 API 响应、图像、视频、XML 和文本文件等的非结构化数据。这种按需模式(schema-on-read)允许原始数据快速加载,而无需提前建模,从而使架构具有高度的可扩展性。当需要分析时,数据湖仓层可以使用按查询模式(schema-on-query)在一个地方查询所有这些数据集。这使得从不同来源整合数据以获得新见解变得更加简便。加载与分析的分离还使得随着对数据的新理解的出现,能够进行迭代分析。总体而言,现代数据湖仓旨在快速接纳多结构和多源数据,同时使用户能够灵活地分析这些数据。
首先,我们将仔细查看图 4.3顶部显示的批量层。
批量摄取
设计的第一层是批量摄取过程。对于所有的非结构化数据,定制的 Python 处理过程是首选。可以开发自定义代码来从 API 端点查询数据,读取 XML 结构,处理文本和图像。对于数据库中的结构化数据,我们有两种数据摄取选择。首先,Kafka 和 Kafka Connect 提供了一种简单配置数据迁移任务并连接到大量数据库的方法。Apache Kafka 是一个分布式流处理平台,允许发布和订阅记录流。在其核心,Kafka 是一个基于发布-订阅模型构建的持久化消息代理。Kafka Connect 是 Kafka 附带的工具,提供了一种通用的方式将数据进出 Kafka。它提供了可重用的连接器,帮助将 Kafka 主题连接到外部系统,如数据库、键值存储、搜索索引和文件系统等。Kafka Connect 为许多常见的数据源和接收器提供了连接器插件,如 JDBC、MongoDB、Elasticsearch 等。这些连接器将外部系统中的数据移动到 Kafka 主题中,反之亦然。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_04_03.jpg
图 4.3 – Kubernetes 中的数据湖仓
这些连接器是可重用且可配置的。例如,JDBC 连接器可以配置为捕获来自 PostgreSQL 数据库的变更并将其写入 Kafka 主题。Kafka Connect 负责处理数据格式转换、分布式协调、容错等,并支持通过跟踪源连接器(例如数据库 变更数据捕获(CDC)连接器)中的数据变更,将变更流管道化到 Kafka 主题中,从而简化了数据的进出 Kafka 的过程。虽然 Kafka 是一个广为人知的流数据工具,但与 Kafka Connect 一起使用已证明在数据库的批量数据迁移方面非常高效。
有时,当管理 Kafka 集群进行数据迁移不可行时(我们稍后会讨论其中的一些情况),可以通过 Apache Spark 从结构化数据源摄取数据。Apache Spark 提供了一种多功能的工具,可以从各种结构化数据源摄取数据到基于云对象存储的数据湖中,例如 Amazon S3 或 Azure Data Lake Storage。Spark 的 DataFrame API 允许从关系数据库、NoSQL 数据存储以及其他结构化数据源查询数据。尽管很方便,但从 JDBC 数据源读取数据在 Spark 中可能效率低下。Spark 会将表作为单个分区读取,因此所有处理将在单个任务中进行。对于大型表格,这可能会减慢数据摄取和后续查询的速度(更多细节请参见 第五章)。为了优化,我们需要手动对源数据库的读取进行分区。使用 Spark 进行数据摄取的主要缺点是需要自己处理这些分区和优化问题。其他工具可以通过为你管理并行摄取任务来提供帮助,但 Spark 提供了连接和处理许多数据源的灵活性,开箱即用。
现在,让我们来看一下存储层。
存储
接下来,在图表的中间部分,我们有 存储 层。这是我不建议迁移到 Kubernetes 的唯一一层。基于云的对象存储服务现在有许多功能,能够优化可扩展性和可靠性,使其操作简单且具有很好的检索性能。尽管有一些很棒的工具可以在 Kubernetes 中构建数据湖存储层(例如 min.io/),但这样做不值得,因为你必须自己处理可扩展性和可靠性。对于本书的目的,我们将在 Kubernetes 中处理所有数据湖仓层,除了存储层。
批处理
现在,我们将讨论批处理处理层。Apache Spark 已成为大数据生态系统中大规模批处理数据处理的事实标准。与传统的 MapReduce 作业不同,传统作业将中间数据写入磁盘,而 Spark 在内存中处理数据,这使得其在迭代算法和交互式数据分析上更快。Spark 使用集群管理器来协调作业在多个工作节点上的执行。这使得它能够通过将数据分布到集群中并并行处理,来高效处理非常大的数据集。Spark 能够高效地处理存储在分布式文件系统(如 HDFS)和云对象存储中的 TB 级数据。
Spark 的一个关键优势是它为 SQL 和复杂分析提供的统一 API。数据工程师和科学家可以使用 Python DataFrame API 来处理和分析批量数据集。然后,可以通过 Spark SQL 查询相同的 DataFrame,提供熟悉性和交互性。这使得 Spark 对于各种用户来说非常易于操作。通过利用内存处理并提供易于使用的 API,Apache Spark 成为可扩展批量数据分析的首选解决方案。拥有大量日志文件、传感器数据或其他记录的公司可以依赖 Spark 高效地并行处理这些庞大的数据集。这也巩固了它在现代数据架构中的基础技术地位。
接下来,我们将讨论编排层。
编排
在存储层和批处理层之上,在图 4.3中,我们找到了一个编排层。当我们构建更复杂的数据管道,将多个处理步骤连接在一起时,我们需要一种可靠的方式来管理这些管道的执行。这就是编排框架的作用。在这里,我们选择使用 Airflow。Airflow 是一个开源的工作流编排平台,最初由 Airbnb 开发,用于编写、调度和监控数据管道。此后,它已成为数据管道中最流行的编排工具之一。
使用 Airflow 对于批处理数据管道的重要原因如下:
-
调度:Airflow 允许你定期调度批处理作业(每小时、每天、每周等)。这样就不需要手动启动作业,确保它们可靠地运行。
-
依赖管理:作业通常需要按顺序运行,或者等待其他作业完成。Airflow 提供了一种简单的方法来设置这些依赖关系,在有向无环图(DAG)中进行管理。
-
监控:Airflow 具有内置的仪表板,用于监控作业的状态。你可以看到哪些作业已成功、失败、正在运行等状态。它还会保留日志和历史记录,以供后续调试。
-
灵活性:可以通过修改 DAG 来添加新的数据源、转换和输出,而不会影响其他不相关的作业。Airflow 的 DAG 提供了高度的可配置性。
-
抽象:Airflow DAG 允许管道开发人员专注于业务逻辑,而非应用程序编排。底层的 Airflow 平台处理工作流调度、状态监控等事务。
现在,我们将进入服务层的介绍。
批处理服务
对于 Kubernetes 中的 批处理服务 层,我们选择了 Trino。Trino(前身为 PrestoSQL)是一个开源的分布式 SQL 查询引擎,旨在对多种数据源执行交互式分析查询。Trino 可以处理高达 PB 级别的数据查询。使用 Trino,你可以并行查询多个数据源。当 SQL 查询提交给 Trino 时,它会被解析和规划,生成分布式执行计划。该执行计划随后会提交给工作节点,工作节点并行处理查询并将结果返回给协调节点。它支持 ANSI SQL(最常见的 SQL 标准之一),并且可以连接多种数据源,包括所有主要的云端对象存储服务。通过利用 Trino,数据团队可以直接在云数据湖中实现自助 SQL 分析,避免了仅为分析而进行的数据移动,且仍能提供交互式的响应时间。
接下来,我们将看一下为数据可视化选择的工具。
数据可视化
对于数据可视化和分析,我们选择了使用 Apache Superset。尽管市场上有许多优秀的工具,我们发现 Superset 易于部署、易于运行、易于使用,并且极易集成。Superset 是一个开源的数据探索和可视化应用,能够让用户轻松构建交互式仪表板、图表和图形。Superset 最早于 2015 年在 Airbnb 内部作为分析师和数据科学家的工具开发。随着 Airbnb 对其使用和贡献的增加,它决定在 2016 年将 Superset 开源,并以 Apache 许可证发布,将其捐赠给 Apache 软件基金会。从那时起,Superset 被许多其他公司采用,并拥有一个活跃的开源社区为其发展做出贡献。它具有直观的图形界面,可以通过丰富的仪表板、图表和图形可视化和探索数据,支持多种复杂的可视化类型。它拥有一个 SQL Lab 编辑器,可以让你编写 SQL 查询,连接不同的数据库并可视化结果。它提供了安全访问和角色管理,允许对数据访问和修改进行细粒度的控制。它可以连接多种数据源,包括关系数据库、数据仓库和 SQL 引擎,如 Trino。Superset 可以通过提供的 Helm charts 方便地在 Kubernetes 上部署。Helm chart 会配置所有必需的 Kubernetes 对象——如部署、服务、入口等——以运行 Superset。
由于具备丰富的可视化功能、处理多样数据源的灵活性以及 Kubernetes 部署支持,Apache Superset 是现代数据栈中在 Kubernetes 上的一个重要补充。
现在,让我们继续查看图表下方的部分,图 4.3,即实时层。
实时摄取
在批量数据摄取中,数据按照定期的时间表以较大的块或批次加载。例如,批量作业可能每小时、每天或每周运行一次,以从源系统加载新数据。另一方面,在实时数据摄取中,数据随着其生成持续不断地流入系统。这使得数据能够真实地、接近实时地流入数据湖。实时数据摄取是事件驱动的——当事件发生时,它们会生成数据并流入系统。这可能包括用户点击、物联网传感器读数、金融交易等内容。系统会对每个到达的事件做出反应并进行处理。Apache Kafka 是最流行的开源工具之一,它提供了一个可扩展、容错的平台来处理实时数据流。它可以与 Kafka Connect 配合使用,用于从数据库和其他结构化数据源流式传输数据,或者与用 Python 等语言开发的定制数据生产者一起使用。
实时摄取到 Kafka 的数据通常也会“同步”到存储层,以便进行后续的历史分析和备份。我们不建议仅仅使用 Kafka 作为实时数据存储。相反,我们遵循最佳实践,在定义的时间段后从 Kafka 中清除数据,以节省存储空间。默认的时间段为七天,但我们可以根据需要进行配置。然而,实时数据处理并不是基于存储层进行的,而是通过直接从 Kafka 中读取数据来完成的。这就是接下来要讨论的内容。
实时处理
有许多优秀的实时数据处理工具:Apache Flink、Apache Storm 和 KSQLDB(它是 Kafka 家族的一部分)。然而,我们选择使用 Spark,因为它具有出色的性能和易用性。
Spark Structured Streaming 是一个 Spark 模块,我们可以用它来处理流式数据。其核心思想是,Structured Streaming 从概念上将实时数据流转换为一个表,数据会持续不断地附加到这个表中。内部机制是将实时数据流拆分成小批量的数据,然后通过 Spark SQL 处理这些数据,就像它们是表一样。
在实时流数据被拆分成几毫秒的小批次后,每个小批次被视为一个表,并附加到逻辑表中。然后,在每个批次到达时,Spark SQL 查询会在这些批次上执行,以生成最终的结果流。这种小批次架构提供了可扩展性,因为它可以利用 Spark 的分布式计算模型在数据批次之间并行处理。可以通过增加更多机器来扩展以处理更大的数据量。小批次方法还提供了容错保障。结构化流使用检查点机制,在计算状态被定期快照。如果发生故障,流处理可以从最后一个检查点重新启动,而不是重新计算所有数据。
通常,Spark Structured Streaming 查询直接从 Kafka 主题读取数据(使用必要的外部库),内部处理并进行必要的计算后,将数据写入实时数据服务引擎。实时服务层是我们接下来的话题。
实时数据服务
为了实时提供数据,我们需要能够快速查询数据并以低延迟返回数据的技术。MongoDB 和 Elasticsearch 是两种最常用的技术。
MongoDB 是一个流行的开源文档型 NoSQL 数据库。与传统的关系型数据库使用表和行不同,MongoDB 以灵活的 JSON 类文档存储数据,这些文档的结构可以不同。MongoDB 设计用于可扩展性、高可用性和高性能。它使用高效的存储格式、索引优化以及其他技术,以提供低延迟的读写操作。文档模型和分布式能力使 MongoDB 能够非常高效地处理实时数据的写入和读取。查询、数据聚合和分析可以在实时数据累积过程中进行大规模操作。
Elasticsearch 是一个开源的搜索和分析引擎,基于 Apache Lucene 构建。它提供了一个分布式、多租户支持的全文搜索引擎,具备 HTTP Web 接口和无模式的 JSON 文档。一些 Elasticsearch 的关键功能和应用场景包括:
-
实时分析与洞察:Elasticsearch 允许你实时分析和探索非结构化数据。当数据被接收时,Elasticsearch 会立即对其进行索引并使其可搜索。这使得数据流的实时监控和分析成为可能。
-
日志分析:Elasticsearch 通常用于从各种源(如应用程序日志、网络日志、Web 服务器日志等)实时接收、分析、可视化和监控日志数据。这使得实时监控和故障排除成为可能。
-
应用监控和性能分析:通过摄取和索引应用程序指标,Elasticsearch 可用于实时监控和分析应用程序性能。可以分析诸如请求速率、响应时间、错误率等指标。
-
实时网站分析:Elasticsearch 可以实时摄取和处理来自网站流量的分析数据,以启用自动建议、用户行为实时追踪等功能。
-
物联网(IoT)和传感器数据:对于时间序列的物联网和传感器数据,Elasticsearch 提供诸如数据聚合、异常检测等功能,能够实现物联网平台的实时监控和分析。
由于其低延迟和数据查询速度,Elasticsearch 是一个非常适合实时数据消费的工具。此外,Elastic 系列产品中还有 Kibana,它可以实现实时数据可视化,接下来我们将进一步探讨它。
实时数据可视化
Kibana 是一个开源的数据可视化和探索工具,专门设计用于与 Elasticsearch 配合使用。Kibana 提供易于使用的仪表板和可视化功能,允许你探索和分析已在 Elasticsearch 集群中索引的数据。Kibana 直接连接到 Elasticsearch 集群,并索引关于集群的元数据,利用这些元数据呈现关于数据的可视化和仪表板。它提供预构建的和可定制的仪表板,允许通过直方图、折线图、饼图、热力图等可视化方式进行数据探索。这些可视化方式使得理解趋势和模式变得容易。通过 Kibana,用户可以创建并分享自己的仪表板和可视化,以满足特定的数据分析需求。它拥有专门的工具来处理时间序列日志数据,非常适合用于监控 IT 基础设施、应用程序、物联网设备等,并且能够快速进行强大的临时数据过滤,深入挖掘具体细节。
Kibana 适用于实时数据的一个重要原因是 Elasticsearch 是为日志分析和全文搜索而设计的,这两者都需要快速和接近实时的数据摄取和分析。当数据流入 Elasticsearch 时,Kibana 的可视化会实时更新,以反映数据的当前状态。这使得根据实时数据流监控系统、检测异常、设置警报等成为可能。Elasticsearch 的可扩展性与 Kibana 的交互式仪表板相结合,为大规模系统中的实时数据可视化和探索提供了一个极为强大的解决方案。
总结
在本章中,我们介绍了现代数据架构的演变和关键设计模式,例如 Lambda 架构,它支持构建可扩展和灵活的数据平台。我们学习了 Lambda 方法如何结合批处理和实时数据处理,提供历史数据分析的同时,还能支持低延迟的应用程序。
我们讨论了从传统数据仓库到下一代数据湖和湖仓的过渡。现在你已经理解了这些基于云对象存储的现代数据平台如何提供架构灵活性、按需扩展的成本效益,并统一批处理和流式数据。
我们还深入研究了构成现代数据技术栈的组件和技术。这包括数据摄取工具,如 Kafka 和 Spark,分布式处理引擎,如用于流式处理的 Spark Structured Streaming 和用于批数据的 Spark SQL,调度器,如 Apache Airflow,存储在云对象存储中的数据,以及使用 Trino、Elasticsearch 和可视化工具(如 Superset 和 Kibana)的服务层。
无论你的使用场景是需要对历史数据进行 ETL 和分析,还是对实时数据应用程序进行处理,这个现代数据技术栈都提供了一个蓝图。这里的课程为你提供了必要的基础,帮助你摄取、处理、存储、分析并服务数据,从而支持高级分析并推动数据驱动的决策。
在下一章中,我们将深入探讨 Apache Spark,了解它的工作原理、内部架构,以及执行数据处理的基本命令。
第五章:使用 Apache Spark 进行大数据处理
如前一章所示,Apache Spark 已迅速成为用于大数据工作负载的最广泛使用的分布式数据处理引擎之一。在本章中,我们将介绍使用 Spark 进行大规模数据处理的基础知识。
我们将首先讨论如何为开发和测试设置本地 Spark 环境。您将学习如何启动交互式 PySpark Shell,并使用 Spark 内置的 DataFrames API 来探索和处理示例数据集。通过编码示例,您将获得有关 PySpark 数据转换的实际经验,例如过滤、聚合和连接操作。
接下来,我们将探索 Spark SQL,它允许您通过 SQL 查询 Spark 中的结构化数据。您将学习 Spark SQL 如何与其他 Spark 组件集成,以及如何使用它来分析 DataFrame。我们还将讨论优化 Spark 工作负载的最佳实践。虽然本章不会深入探讨如何调优集群资源和参数,但您将了解一些配置,这些配置可以显著提高 Spark 作业的性能。
到本章结束时,您将理解 Spark 架构,并了解如何设置本地 PySpark 环境,加载数据到 Spark DataFrame,使用 PySpark 转换和分析数据,通过 Spark SQL 查询数据,并应用一些性能优化。掌握了这些 Spark 基础技能,您将为使用 Spark 的统一引擎处理大数据分析挑战做好准备。
在本章中,我们将涵盖以下主要内容:
-
入门 Spark
-
DataFrame API 和 Spark SQL API
-
使用真实数据
到本章结束时,您将获得使用 PySpark(Spark 的 Python API)加载、转换和分析大数据集的实践经验。
技术要求
-
要在本地运行 Spark,您需要 Java 8 或更高版本,并配置
JAVA_HOME环境变量。为此,请按照www.java.com/en/download/help/download_options.html中的说明操作。 -
为了更好地可视化 Spark 过程,我们将通过 JupyterLab 进行交互式使用。您还应该确保您的 Python 发行版中已启用此功能。要安装 Jupyter,请按照此处的说明进行操作:
jupyter.org/install。 -
本章的所有代码都可以在本书 GitHub 仓库的
Chapter05文件夹中找到,地址为:github.com/PacktPublishing/Bigdata-on-Kubernetes。
入门 Spark
在本节中,我们将学习如何在本地计算机上启动并运行 Spark。我们还将概览 Spark 的架构和一些核心概念。这将为本章后续的实际数据处理部分打下基础。
本地安装 Spark
现在安装 Spark 和执行 pip3 install 命令一样简单:
-
安装 Java 8 后,运行以下命令:
pip3 install pyspark -
这将安装 PySpark 及其依赖项,例如 Spark 本身。你可以通过在终端运行以下命令来测试安装是否成功:
spark-submit --version
你应该会在终端看到一个简单的输出,显示 Spark 的 logo 和版本信息。
Spark 架构
Spark 采用分布式/集群架构,正如你在下面的图示中看到的:
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_05_01.jpg
图 5.1 – Spark 集群架构
协调 Spark 应用程序的核心部分叫做 SparkSession 对象,它直接与 Spark 上下文集成。Spark 上下文连接到一个集群管理器,集群管理器可以在计算集群中配置资源。当本地运行时,一个嵌入式集群管理器会与驱动程序程序在同一个Java 虚拟机(JVM)内运行。但在生产环境中,Spark 应配置为使用如 Yarn 或 Mesos 这样的独立集群资源管理器。我们稍后将看到 Spark 如何使用 Kubernetes 作为集群管理器结构。
集群管理器负责分配计算资源并隔离集群中的计算。当驱动程序请求资源时,集群管理器会启动 Spark 执行器来执行所需的计算任务。
Spark 执行器
Spark 执行器是由集群管理器在集群中的工作节点上启动的进程。它们执行计算任务并为 Spark 应用存储数据。每个应用都有自己的执行器,这些执行器在整个应用程序运行期间保持运行,并在多个线程中执行任务。Spark 执行名为任务的代码片段以执行分布式数据处理。
执行组件
Spark 作业触发 Spark 程序的执行。它会被划分为一组个更小的任务集,这些任务集之间相互依赖,称为阶段。
阶段由可以并行执行的任务组成。这些任务在执行器内部通过多个线程执行。可以在执行器内并发运行的任务数量基于集群中预先分配的槽位(核心)数进行配置。
这一层次结构包括作业、阶段、任务、槽位和执行器,旨在促进 Spark 程序在集群中的分布式执行。我们将在本章稍后深入探讨与此结构相关的一些优化。现在,让我们通过运行一个简单的交互式 Spark 程序来可视化 Spark 的执行组件。
启动 Spark 程序
在接下来的步骤中,我们将使用一个名为Jupyter的交互式 Python 编程环境。如果你尚未在本地安装 Jupyter,请确保它已安装。
你可以通过在终端中键入以下命令来启动 Jupyter 环境:
jupyter lab
你会看到 Jupyter 进程的输出,并且一个新的浏览器窗口应该会打开。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_05_02.jpg
图 5.2 – Jupyter 界面
Jupyter 会使事情变得更加简单,因为我们将运行一个交互式的 Spark 会话,并且能够通过其 UI 监控 Spark:
-
首先,点击 Python 3 按钮,位于 Notebook 部分(图 5.2)。这将启动一个新的 Jupyter 笔记本。
-
接下来,我们将使用一些 Python 代码从网络下载
titanic数据集(可通过raw.githubusercontent.com/neylsoncrepalde/titanic_data_with_semicolon/main/titanic.csv获取)。在第一个代码块中,输入以下内容:requests Python library if it is not available. Press *Shift* + *Enter* to run the code block. -
接下来,我们将导入必要的库:
import os import requests -
然后,我们将创建一个字典,文件名作为键,URL 作为值:
urls_dict = { "titanic.csv": "https://raw.githubusercontent.com/neylsoncrepalde/titanic_data_with_semicolon/main/titanic.csv", } -
现在,我们将创建一个简单的 Python 函数来下载这个数据集并将其保存在本地:
def get_titanic_data(urls): for title, url in urls.items(): response = requests.get(url, stream=True) with open(f"data/titanic/{title}", mode="wb") as file: file.write(response.content) return True -
接下来,我们将创建一个名为
data的文件夹,以及一个名为titanic的子文件夹来存储数据集。exist_ok参数允许代码继续运行,如果这些文件夹已经存在则不会抛出错误。然后,我们运行我们的函数:os.makedirs('data/titanic', exist_ok=True) get_titanic_data(urls_dict)
现在,titanic 数据集已经可以用于分析。
本章中呈现的所有代码可以在本书 GitHub 仓库的 第五章 文件夹中找到 (github.com/PacktPublishing/Bigdata-on-Kubernetes/tree/main/Chapter%205)。
接下来,我们可以开始配置 Spark 程序来分析这些数据:
-
为了做到这一点,我们必须首先导入
SparkSession类和functions模块。这个模块将是我们使用 Spark 进行大部分数据处理所必需的:from pyspark.sql import SparkSession from pyspark.sql import functions as f -
在运行完导入后,创建一个 Spark 会话:
spark = SparkSession.builder.appName("TitanicData").getOrCreate() -
这个 Spark 会话使 Spark UI 可用。我们可以通过在浏览器中输入
localhost:4040来查看它。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_05_03.jpg
图 5.3 – Spark UI
如你所见,目前还没有数据可用。在我们运行 Spark 程序中的某些操作后,作业将开始显示在这个监控页面上。
现在,让我们回到 Jupyter 中的代码。
-
要读取下载的数据集,运行以下代码:
titanic = ( spark .read .options(header=True, inferSchema=True, delimiter=";") .csv('data/titanic/titanic.csv') )这段代码的选项说明文件的第一行包含列名(
header = True),我们希望 Spark 自动检测表格模式并相应地读取它(inferSchema = True),并设置文件分隔符或定界符为;。 -
要显示数据集的前几行,运行以下代码:
titanic.show() -
现在,如果我们回到 Spark UI,我们已经可以看到完成的作业。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_05_04.jpg
图 5.4 – 带有作业的 Spark UI
我们可以检查 Spark UI 中其他选项卡,查看各个阶段和任务,并在 SQL / DataFrame 选项卡中可视化发送到 Spark 的查询。我们将在本章稍后对这些选项卡进行进一步分析。
在接下来的部分中,我们将重点理解如何使用 Python(DataFrame API)和 SQL(Spark SQL API)语言进行 Spark 编程,以及 Spark 如何确保无论我们选择哪种编程语言,都能达到最佳性能。
DataFrame API 和 Spark SQL API
Spark 提供了不同的 API,构建在核心 RDD API(原生的低级 Spark 语言)之上,旨在简化分布式数据处理应用程序的开发。最受欢迎的两个高级 API 是 DataFrame API 和 Spark SQL API。
DataFrame API 提供了一种领域特定语言,用于操作组织成命名列的分布式数据集。从概念上讲,它等同于关系数据库中的表或 Python pandas 中的 DataFrame,但在底层有更丰富的优化。DataFrame API 使用户能够在领域特定的术语(如分组和连接)后抽象数据处理操作,而不是考虑map和reduce操作。
Spark SQL API 在 DataFrames API 的基础上进一步构建,通过暴露 Spark SQL,一个用于结构化数据处理的 Spark 模块。Spark SQL 允许用户在 DataFrame 上运行 SQL 查询,以便对数据进行过滤或聚合。SQL 查询会被优化并转换为原生 Spark 代码执行。这使得熟悉 SQL 的用户可以轻松地针对数据运行临时查询。
两个 API 都依赖 Catalyst 优化器,它利用先进的编程技术,如谓词下推、投影剪枝和多种连接优化,来构建高效的查询计划。这样,Spark 通过根据业务逻辑而非硬件考虑来优化查询,从而与其他分布式数据处理框架区别开来。
在使用 Spark SQL 和 DataFrames API 时,理解一些关键概念非常重要,这些概念使 Spark 能够快速、高效地进行数据处理。这些概念包括转换、操作、懒评估和数据分区。
转换
转换定义了将要执行的计算,而操作触发了这些转换的实际执行。
转换是从现有的 DataFrame 中生成新 DataFrame 的操作。以下是 Spark 中的一些转换示例:
-
这是
select命令,用于在 DataFrame(df)中选择列:new_df = df.select("column1", "column2") -
这是
filter命令,用于根据给定条件过滤行:filtered_df = df.filter(df["age"] > 20) -
这是
orderBy命令,用于根据给定列对 DataFrame 进行排序:sorted_df = df.orderBy("salary") -
分组聚合可以通过
groupBy命令和聚合函数来完成:agg_df = df.groupBy("department").avg("salary")
关键要理解的是,转换是懒惰的。当你调用filter()或orderBy()等转换时,并不会执行实际的计算。相反,Spark 仅仅记住要应用的转换,并等待直到调用操作时才会执行计算。
这种懒惰求值使得 Spark 在执行之前可以优化整个转换序列。与立即执行每个操作的贪婪求值引擎相比,这可能带来显著的性能提升。
行动
虽然转换描述了对 DataFrame 的操作,但行动实际上会执行计算并返回结果。在 Spark 中,一些常见的行动包括以下内容:
-
count命令用于返回 DataFrame 中的行数:df.count() -
first命令用于返回 DataFrame 中的第一行:df.first() -
show命令用于打印 DataFrame 的内容:df.show() -
collect命令用于返回包含 DataFrame 所有行的数组:df.collect() -
write命令用于将 DataFrame 写入指定路径:df.write.parquet("PATH-TO-SAVE")
当在 DataFrame 上调用一个行动时,会发生以下几个事情:
-
Spark 引擎会查看已应用的转换序列,并创建一个高效的执行计划来执行这些操作。这时就会进行优化。
-
执行计划在集群中运行以执行实际的数据操作。
-
该行动会聚合并将最终结果返回给驱动程序。
总结来说,转换描述了一个计算过程,但不会立即执行它。行动会触发懒惰求值和 Spark 作业的执行,返回具体的结果。
将计算指令存储起来以便稍后执行的过程称为 懒惰求值。让我们更详细地了解这个概念。
懒惰求值
懒惰求值是一项关键技术,使得 Apache Spark 能够高效运行。如前所述,当你对 DataFrame 应用转换时,并不会立即进行实际的计算。
相反,Spark 会内部记录每个转换作为操作来应用于数据。实际执行被推迟,直到调用行动时才会执行。
这种延迟计算非常有用,原因如下:
-
避免不必要的操作:通过查看许多转换的顺序,Spark 能够优化哪些计算部分实际上需要返回最终结果。如果某些中间步骤不需要,它们可能会被省略。
-
运行时优化:当行动被触发时,Spark 会根据分区、可用内存和并行性制定一个高效的物理执行计划。它在运行时动态地进行这些优化。
-
将批处理操作组合在一起:多个 DataFrame 上的几个转换可以被批量处理为更少的作业。这会把作业调度和初始化的开销分摊到许多计算步骤中。
作为示例,考虑一个包含用户点击流数据的 DataFrame,在返回最终的前 10 行之前,需要对其进行过滤、聚合和排序。
通过惰性计算,所有这些转换会在定义时被记录,当通过 collect() 或 show() 请求最终行时,才会执行一个优化后的任务。如果没有惰性计算,引擎需要为 filter() 执行一个独立的任务,为 groupBy() 执行另一个任务,为 orderBy() 执行另一个任务,以此类推。这将非常低效。
总结一下,惰性计算将计算步骤的定义与执行分开。这允许 Spark 提出一个优化的物理计划来执行整个操作序列。接下来,我们将看到 Spark 如何通过数据分区来分配计算任务。
数据分区
Spark 的速度来源于其能够将数据处理分发到集群中。为了实现并行处理,Spark 将数据划分为独立的分区,可以在集群中的不同节点上并行处理。
当你将数据读入 Spark 的 DataFrame 或 RDD 时,数据会被划分为逻辑分区。在集群上,Spark 会调度任务执行,使得分区能够在不同节点上并行运行。每个节点可以处理多个分区。这使得整个任务的处理速度比在单个节点上按顺序执行要快得多。
理解 Spark 中的数据分区对于理解 狭义 和 广义 转换的区别至关重要。
狭义转换与广义转换
狭义转换是指可以在每个分区独立执行的操作,而不需要在节点之间进行任何数据洗牌。例子包括 map、filter 和其他每条记录的转换。这些操作允许并行处理而不会产生网络流量的开销。
广义转换要求在分区和节点之间进行数据洗牌。例子包括 groupBy 聚合、连接、排序和窗口函数。这些操作要么涉及将多个分区的数据合并,要么基于某个键重新分区数据。
下面是一个例子来说明。我们正在过滤一个 DataFrame,并且只保留年龄小于 20 的行:
narrow_df = df.where("age > 20")
按年龄过滤是在每个数据分区中独立进行的。
grouped_df = df.groupBy("department").avg("salary")
分组聚合需要在集群中的分区之间交换数据。这个交换就是我们所说的 shuffle。
为什么这个区分很重要?如果可能的话,最好先进行狭义转换,再进行广义转换。这可以最小化数据在网络上的洗牌,从而提高性能。
例如,通常最好先通过过滤数据得到需要的子集,然后在过滤后的数据上应用聚合/窗口/连接操作,而不是对整个数据集应用所有操作。先过滤数据可以减少在网络上洗牌的数据量。
理解窄变换和宽变换的区别,可以通过最小化数据洗牌和仅在需要时分区数据,从而优化 Spark 作业,降低延迟并提高吞吐量。这是优化 Spark 应用性能的关键调优技巧。
现在,让我们尝试将这些概念应用到我们的titanic数据集上。
分析泰坦尼克号数据集
让我们回到我们之前开始构建的 Jupyter notebook。首先,我们启动一个SparkSession,并将titanic数据集读取到 Spark 中:
from pyspark.sql import SparkSession
from pyspark.sql import functions as f
spark = SparkSession.builder.appName("TitanicData").getOrCreate()
titanic = (
spark
.read
.options(header=True, inferSchema=True, delimiter=";")
.csv('data/titanic/titanic.csv')
)
我们现在将使用printSchema()命令检查表结构:
titanic.printSchema()
接下来,我们将在原始数据集上应用一些窄变换。我们将只筛选出年龄大于 21 岁的男性,并将此变换后的数据保存到名为filtered的对象中:
filtered = (
titanic
.filter(titanic.Age > 21)
.filter(titanic.Sex == "male")
)
现在,让我们回到 Spark UI。发生了什么?什么都没有发生! 没有进行任何计算,因为(记住)这些命令是转换操作,不会触发 Spark 中的任何计算。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_05_05.jpg
图 5.5 – 变换后的 Spark UI
但是现在,我们运行了一个show()命令,它是一个动作:
filtered.show()
瞧! 现在,我们可以看到 Spark 中触发了一个新的作业。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_05_06.jpg
图 5.6 – 执行动作后的 Spark UI
我们还可以在SQL / DataFrame选项卡中检查执行计划。点击此选项卡,然后点击最后执行的查询(表格中的第一行)。你应该看到如图 5.7所示的输出。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_05_07.jpg
图 5.7 – Spark 过滤器的执行计划
titanic数据集不够大,Spark 无法将其划分为多个分区。在本章的后面部分,我们将看到使用宽变换时如何进行数据洗牌(分区间的数据交换)。
本节的最后一个重要内容是观察 Spark 如何使用 DataFrame 和 Spark SQL API,并将所有指令转换为 RDD 以便优化处理。让我们实现一个简单的查询来分析titanic数据集。我们将在 Python 和 SQL 中都实现该查询。
首先,我们计算在每个旅行舱次中,21 岁以上的男性乘客在泰坦尼克号上的幸存情况。我们将 Python 查询保存在一个名为queryp的对象中:
queryp = (
titanic
.filter(titanic.Sex == "male")
.filter(titanic.Age > 21)
.groupBy('Pclass')
.agg(f.sum('Survived').alias('Survivors'))
)
现在,我们将使用 SQL 实现完全相同的查询。为此,首先,我们需要创建一个临时视图,然后使用spakr.sql()命令来运行 SQL 代码:
titanic.createOrReplaceTempView('titanic')
querysql = spark.sql("""
SELECT
Pclass,
sum(Survived) as Survivors
FROM titanic
WHERE
Sex = 'male'
AND Age > 21
GROUP BY Pclass
""")
两个查询都被保存在对象中,我们现在可以使用它们来检查执行计划。让我们来做这件事:
queryp.explain('formatted')
querysql.explain('formatted')
如果你查看输出,你会注意到两个执行计划完全相同! 这是因为 Spark 会将所有在高级 API 中给出的指令转换成运行在底层的 RDD 代码。我们可以通过show()命令执行这两个查询,并看到结果是相同的,它们以相同的性能执行:
queryp.show()
querysql.show()
两个命令的输出如下:
+------+---------+
|Pclass|Survivors|
+------+---------+
| 1| 36|
| 3| 22|
| 2| 5|
+------+---------+
我们还可以在 Spark UI 的 SQL / DataFrame 标签中直观地查看执行计划。点击该标签中的前两行(最近的两次执行),你会发现执行计划是相同的。
从现在开始,让我们在处理这个更具挑战性的数据集时,尝试深入挖掘 PySpark 代码。
使用真实数据进行工作
我们现在将使用 IMDb 公共数据集。这个数据集较为复杂,分成了多个表格。
以下代码将从 imdb 数据集中下载五个表格,并将它们保存到 ./data/imdb/ 路径下(也可以在 github.com/PacktPublishing/Bigdata-on-Kubernetes/blob/main/Chapter05/get_imdb_data.py 找到该代码)。
首先,我们需要将数据下载到本地:
get_imdb_data.py
import os
import requests
urls_dict = {
"names.tsv.gz": "https://datasets.imdbws.com/name.basics.tsv.gz",
"basics.tsv.gz": "https://datasets.imdbws.com/title.basics.tsv.gz",
"crew.tsv.gz": "https://datasets.imdbws.com/title.crew.tsv.gz",
"principals.tsv.gz": "https://datasets.imdbws.com/title.principals.tsv.gz",
"ratings.tsv.gz": "https://datasets.imdbws.com/title.ratings.tsv.gz"
}
def get_imdb_data(urls):
for title, url in urls.items():
response = requests.get(url, stream=True)
with open(f"data/imdb/{title}", mode="wb") as file:
file.write(response.content)
return True
os.makedirs('data/imdb', exist_ok=True)
get_imdb_data(urls_dict)
现在,我们将打开一个 Jupyter notebook,启动一个 SparkSession,并读取表格(你可以在 github.com/PacktPublishing/Bigdata-on-Kubernetes/blob/main/Chapter05/analyzing_imdb_data.ipynb 找到这段代码):
from pyspark.sql import SparkSession
from pyspark.sql import functions as f
spark = SparkSession.builder.appName("IMDBData").getOrCreate()
spark.sparkContext.setLogLevel("ERROR")
这次,我们将不使用 inferSchema 参数来读取表格。inferSchema 在处理小型表格时非常有用,但对于大数据来说并不推荐使用,因为 Spark 会先读取所有表格一次以定义模式,然后再读取一次数据以正确获取数据,这会导致性能下降。最佳做法是事先定义模式,并使用定义好的模式来读取数据。请注意,像这样读取表格,直到我们给出任何 动作 指令之前,是不会触发执行的。IMDb 数据集的模式可以在 developer.imdb.com/non-commercial-datasets/ 找到。
为了正确读取 IMDb 表格,我们首先定义模式(schemas):
schema_names = "nconst string, primaryName string, birthYear int, deathYear int, primaryProfession string, knownForTitles string"
schema_basics = """
tconst string, titleType string, primaryTitle string, originalTitle string, isAdult int, startYear int, endYear int,
runtimeMinutes double, genres string
"""
schema_crew = "tconst string, directors string, writers string"
schema_principals = "tconst string, ordering int, nconst string, category string, job string, characters string"
schema_ratings = "tconst string, averageRating double, numVotes int"
现在,我们将读取所有表格,并将其定义好的模式作为参数传递:
names = (
spark
.read
.schema(schema_names)
.options(header=True)
.csv('data/imdb/names.tsv.gz')
)
basics = (
spark
.read
.schema(schema_basics)
.options(header=True)
.csv('data/imdb/basics.tsv.gz')
)
crew = (
spark
.read
.schema(schema_crew)
.options(header=True)
.csv('data/imdb/crew.tsv.gz')
)
principals = (
spark
.read
.schema(schema_principals)
.options(header=True)
.csv('data/imdb/principals.tsv.gz')
)
ratings = (
spark
.read
.schema(schema_ratings)
.options(header=True)
.csv('data/imdb/ratings.tsv.gz')
)
现在,我们检查 Spark 是否正确导入了模式:
print("NAMES Schema")
names.printSchema()
print("BASICS Schema")
basics.printSchema()
print("CREW Schema")
crew.printSchema()
print("PRINCIPALS Schema")
principals.printSchema()
print("RATINGS Schema")
ratings.printSchema()
如果你检查 Spark UI,你会注意到没有触发任何计算。只有当我们调用任何动作函数时,才会执行计算。接下来我们将分析这些数据。来看一下 names 表格:
names.show()
.show() 命令将产生如下输出(这里只选择了部分列以便更好地展示):
+---------+-------------------+--------------------+
|nconst | primaryName| knownForTitles|
+---------+-------------------+--------------------+
|nm0000001| Fred Astaire|tt0031983,tt00504...|
|nm0000002| Lauren Bacall|tt0038355,tt00373...|
|nm0000003| Brigitte Bardot|tt0049189,tt00544...|
|nm0000004| John Belushi|tt0078723,tt00725...|
|nm0000005| Ingmar Bergman|tt0050976,tt00839...|
|nm0000006| Ingrid Bergman|tt0034583,tt00368...|
|nm0000007| Humphrey Bogart|tt0037382,tt00425...|
|nm0000008| Marlon Brando|tt0078788,tt00708...|
|nm0000009| Richard Burton|tt0061184,tt00578...|
|nm0000010| James Cagney|tt0031867,tt00355...|
|nm0000011| Gary Cooper|tt0044706,tt00358...|
|nm0000012| Bette Davis|tt0031210,tt00566...|
|nm0000013| Doris Day|tt0045591,tt00494...|
|nm0000014|Olivia de Havilland|tt0041452,tt00313...|
|nm0000015| James Dean|tt0049261,tt00485...|
|nm0000016| Georges Delerue|tt8847712,tt00699...|
|nm0000017| Marlene Dietrich|tt0052311,tt00512...|
|nm0000018| Kirk Douglas|tt0049456,tt00508...|
|nm0000019| Federico Fellini|tt0071129,tt00568...|
|nm0000020| Henry Fonda|tt0082846,tt00512...|
+---------+-------------------+--------------------+
这是 Spark 实际读取 names 数据的确切时刻,一旦我们运行 .show() 命令。这个表格包含关于演员、制作人、导演、编剧等的信息。但注意 knownForTitles 列的结构。它包含了一个人参与过的所有电影,但这些电影的名称以字符串形式存储,所有标题用逗号隔开。这在未来当我们需要将该表格与其他信息进行联接时,可能会给我们带来麻烦。让我们展开这一列,将其转换为多行:
names = names.select(
'nconst', 'primaryName', 'birthYear', 'deathYear',
f.explode(f.split('knownForTitles', ',')).alias('knownForTitles')
)
请注意,我们没有选择primaryProfession列。我们在此分析中不需要它。现在,检查crew表:
crew.show()
这是输出:
+---------+-------------------+---------+
| tconst| directors| writers|
+---------+-------------------+---------+
|tt0000001| nm0005690| \N|
|tt0000002| nm0721526| \N|
|tt0000003| nm0721526| \N|
|tt0000004| nm0721526| \N|
|tt0000005| nm0005690| \N|
|tt0000006| nm0005690| \N|
|tt0000007|nm0005690,nm0374658| \N|
|tt0000008| nm0005690| \N|
|tt0000009| nm0085156|nm0085156|
|tt0000010| nm0525910| \N|
|tt0000011| nm0804434| \N|
|tt0000012|nm0525908,nm0525910| \N|
|tt0000013| nm0525910| \N|
|tt0000014| nm0525910| \N|
|tt0000015| nm0721526| \N|
|tt0000016| nm0525910| \N|
|tt0000017|nm1587194,nm0804434| \N|
|tt0000018| nm0804434| \N|
|tt0000019| nm0932055| \N|
|tt0000020| nm0010291| \N|
+---------+-------------------+---------+
在这里,我们有相同的情况:由多个导演执导的电影。这些信息作为一个字符串存储,多个值之间用逗号分隔。如果你一开始不能想象这种情况,试着筛选crew表,查找包含逗号的值:
crew.filter("directors LIKE '%,%'").show()
我们还将这个列展开为多行:
crew = crew.select(
'tconst', f.explode(f.split('directors', ',')).alias('directors'), 'writers'
)
然后,你还可以检查(使用.show()命令)其他表格,但它们没有这种情况。
现在,让我们开始分析这些数据。我们将可视化最著名的基努·里维斯电影。仅凭一张表格无法查看这一点,因为在names表中,我们只有电影 ID(tconst)。我们需要将names和basics表连接起来。首先,我们只获取基努·里维斯的信息:
only_keanu = names.filter("primaryName = 'Keanu Reeves'")
only_keanu.show()
现在,我们将此新表与basics表进行连接:
keanus_movies = (
basics.select('tconst', 'primaryTitle', 'startYear')
.join(
only_keanu.select('primaryName', 'knownForTitles'),
basics.tconst == names.knownForTitles, how='inner'
)
)
在这段代码中,我们只选择了basics表中需要的列,并将它们与过滤后的only_keanu表连接。join命令有三个参数:
-
将要连接的表
-
将使用的列
-
Spark 将执行的连接类型
在这种情况下,我们使用tconst和knownForTitles列进行连接,并执行内连接,只保留在两个表中都存在的记录。
在我们用动作触发这个连接的结果之前,让我们探索一下这个连接的执行计划:
keanus_movies.explain('formatted')
分析输出时,我们注意到 Spark 将执行排序-合并连接:
== Physical Plan ==
AdaptiveSparkPlan (11)
+- SortMergeJoin Inner (10)
:- Sort (4)
: +- Exchange (3)
: +- Filter (2)
: +- Scan csv (1)
+- Sort (9)
+- Exchange (8)
+- Generate (7)
+- Filter (6)
+- Scan csv (5)
连接是 Spark 中的一个关键操作,并且与 Spark 的性能直接相关。稍后我们会回到数据集和我们正在进行的连接,但在继续之前,简要说明一下 Spark 连接的内部原理。
Spark 如何执行连接
Spark 提供了几种物理连接实现方式,以高效地执行连接。选择哪种连接实现方式取决于所连接数据集的大小和其他参数。
Spark 内部执行连接的方式有很多种。我们将介绍三种最常见的连接:排序-合并连接、洗牌哈希连接和广播连接。
排序-合并连接
排序-合并连接,顾名思义,在应用连接之前会先对连接键进行排序。以下是涉及的步骤:
-
Spark 读取左右两边的 DataFrame/RDD,并应用任何所需的投影或过滤。
-
接下来,两个侧面会根据连接键进行排序。这种数据的重新排列被称为洗牌,它涉及在集群中移动数据。
-
在洗牌之后,具有相同连接键的行将被定位在同一分区上。然后,Spark 通过比较两边具有相同连接键的值来合并排序后的分区,并生成连接输出行。
当双方数据在洗牌后都能适配内存时,排序-合并连接效果较好。排序的预处理步骤能够加速合并。然而,洗牌对于大数据集来说可能会非常耗费资源。
洗牌哈希连接
洗牌哈希连接通过避免排序阶段来优化排序-合并连接。以下是主要步骤:
-
Spark 会根据连接键的哈希值对两边数据进行分区。这将相同键的行分配到同一分区。
-
由于相同键的行被哈希到相同的分区,Spark 可以从一方构建哈希表,从另一方查询哈希表中的匹配项,并在每个分区内输出连接结果。
洗牌哈希连接每一方仅读取一次。通过避免排序,它比排序-合并连接节省了 I/O 和 CPU 成本。但是当洗牌后的连接数据集可以适配内存时,它的效率不如排序-合并连接。
广播哈希连接
如果连接的一方足够小,可以适应每个执行器的内存,Spark 可以使用广播哈希连接来广播这一方。以下是步骤:
-
较小的 DataFrame 会被哈希并广播到所有工作节点。这使得整个数据集可以加载到内存中。
-
较大的一方随后会根据连接键进行分区。每个分区会查询广播的内存哈希表以寻找匹配项,并输出连接结果。
由于数据传输最小化,广播连接非常快速。如果一方足够小以便广播,Spark 会自动选择这一方式。然而,最大大小取决于用于广播的内存。
现在,让我们回到数据集,尝试强制 Spark 执行不同于自动选择的排序-合并连接类型的连接。
连接 IMDb 表
keanu_movies 查询执行计划将执行一个排序-合并连接,这是 Spark 自动选择的,因为在这种情况下,它可能带来最佳的性能。不过,我们也可以强制 Spark 执行不同类型的连接。我们来尝试广播哈希连接:
keanus_movies2 = (
basics.select(
'tconst', 'primaryTitle', 'startYear'
).join(
f.broadcast(only_keanu.select('primaryName', 'knownForTitles')),
basics.tconst == names.knownForTitles, how='inner'
)
)
这个查询几乎与之前的查询完全相同,唯一的区别是:我们使用了 broadcast 函数来强制执行广播连接。让我们检查一下执行计划:
keanus_movies2.explain('formatted')
== Physical Plan ==
AdaptiveSparkPlan (8)
+- BroadcastHashJoin Inner BuildRight (7)
:- Filter (2)
: +- Scan csv (1)
+- BroadcastExchange (6)
+- Generate (5)
+- Filter (4)
+- Scan csv (3)
现在,执行计划变得更小,并且包含一个 BroadcastHashJoin 任务。我们也可以尝试通过以下代码提示 Spark 使用洗牌哈希连接:
keanus_movies3 = (
basics.select(
'tconst', 'primaryTitle', 'startYear'
).join(
only_keanu.select('primaryName', 'knownForTitles').hint("shuffle_hash"),
basics.tconst == names.knownForTitles, how='inner'
)
)
现在,让我们看看执行计划:
keanu_movies3.explain("formatted")
== Physical Plan ==
AdaptiveSparkPlan (9)
+- ShuffledHashJoin Inner BuildRight (8)
:- Exchange (3)
: +- Filter (2)
: +- Scan csv (1)
+- Exchange (7)
+- Generate (6)
+- Filter (5)
+- Scan csv (4)
现在,我们触发所有查询的执行,通过 show() 命令,每个查询都放在自己的代码块中:
keanus_movies.show()
keanus2_movies.show()
keanus3_movies.show()
我们可以看到结果完全相同。不过,Spark 在内部处理连接的方式不同,性能也不同。查看 Spark UI 中的SQL / DataFrame标签,可以可视化执行的查询。
如果我们仅想使用 SQL 来检查基努·里维斯的电影,我们可以通过创建一个临时视图并使用 spark.sql() 命令来实现:
basics.createOrReplaceTempView('basics')
names.createOrReplaceTempView('names')
keanus_movies4 = spark.sql("""
SELECT
b.primaryTitle,
b.startYear,
n.primaryName
FROM basics b
INNER JOIN names n
ON b.tconst = n.knownForTitles
WHERE n.primaryName = 'Keanu Reeves'
""")
现在,让我们再尝试一个查询。让我们看看是否能够回答这个问题:汤姆·汉克斯和梅格·瑞恩共同出演的电影的导演、制片人和编剧是谁,哪部电影的评分最高?
首先,我们需要检查Tom Hanks和Meg Ryan在names表中的编码:
(
names
.filter("primaryName in ('Tom Hanks', 'Meg Ryan')")
.select('nconst', 'primaryName', 'knownForTitles')
.show()
)
结果如下:
+----------+-----------+--------------+
| nconst|primaryName|knownForTitles|
+----------+-----------+--------------+
| nm0000158| Tom Hanks| tt0094737|
| nm0000158| Tom Hanks| tt1535109|
| nm0000158| Tom Hanks| tt0162222|
| nm0000158| Tom Hanks| tt0109830|
| nm0000212| Meg Ryan| tt0120632|
| nm0000212| Meg Ryan| tt0128853|
| nm0000212| Meg Ryan| tt0098635|
| nm0000212| Meg Ryan| tt0108160|
|nm12744293| Meg Ryan| tt10918860|
|nm14023001| Meg Ryan| \N|
| nm7438089| Meg Ryan| tt4837202|
| nm9013931| Meg Ryan| tt6917076|
| nm9253135| Meg Ryan| tt7309462|
| nm9621674| Meg Ryan| tt7993310|
+----------+-----------+--------------+
这个查询显示了梅格·瑞恩的许多不同编码,但我们想要的是第一项,它在knownForTitles列中有几部电影。接着,我们将找出他们两人共同出演的电影。为此,我们将过滤出在principals表中有他们编码的电影,并按电影计算演员人数。那些有两位演员的电影应该就是他们共同出演的电影:
movies_together = (
principals
.filter("nconst in ('nm0000158', 'nm0000212')")
.groupBy('tconst')
.agg(f.count('nconst').alias('nactors'))
.filter('nactors > 1')
)
movies_together.show()
然后我们得到这个结果:
+---------+-------+
| tconst|nactors|
+---------+-------+
|tt2831414| 2|
|tt0128853| 2|
|tt0099892| 2|
|tt1185238| 2|
|tt0108160| 2|
|tt7875572| 2|
|tt0689545| 2|
+---------+-------+
现在,我们可以将这些信息与其他表连接,得到我们需要的答案。我们将创建一个subjoin表,连接principals、names和basics中的信息。由于ratings表占用的资源较多,我们先将其保留到后面使用:
subjoin = (
principals
.join(movies_together.select('tconst'), on='tconst', how='inner')
.join(names.select('nconst', 'primaryName'),
on='nconst', how='inner')
.join(basics.select('tconst', 'primaryTitle', 'startYear'),
on='tconst', how='inner')
.dropDuplicates()
)
subjoin.show()
为了加速后续的计算,我们将缓存这个表。这将允许 Spark 将subjoin表保存在内存中,从而避免所有之前的连接再次触发:
subjoin.cache()
现在,让我们找出汤姆和梅格一起出演了哪些电影:
(
subjoin
.select('primaryTitle', 'startYear')
.dropDuplicates()
.orderBy(f.col('startYear').desc())
.show(truncate=False)
)
最终的输出如下:
+-----------------------------------------+---------+
|primaryTitle |startYear|
+-----------------------------------------+---------+
|Everything Is Copy |2015 |
|Delivering 'You've Got Mail' |2008 |
|You've Got Mail |1998 |
|Episode dated 10 December 1998 |1998 |
|Sleepless in Seattle |1993 |
|Joe Versus the Volcano |1990 |
|Joe Versus the Volcano: Behind the Scenes|1990 |
+-----------------------------------------+---------+
现在,我们将找出这些电影的导演、制片人和编剧:
(
subjoin
.filter("category in ('director', 'producer', 'writer')")
.select('primaryTitle', 'startYear', 'primaryName', 'category')
.show()
)
现在,我们可以查看这些电影的评分并对其进行排序,从而找出评分最高的电影。为此,我们需要将subjoin缓存表与ratings表进行连接。由于subjoin已经被缓存,注意这次连接发生的速度:
(
subjoin.select('tconst', 'primaryTitle')
.dropDuplicates()
.join(ratings, on='tconst', how='inner')
.orderBy(f.col('averageRating').desc())
.show()
)
最后一次连接的结果如下:
+---------+--------------------+-------------+--------+
| tconst| primaryTitle|averageRating|numVotes|
+---------+--------------------+-------------+--------+
|tt7875572|Joe Versus the Vo...| 7.8| 12|
|tt2831414| Everything Is Copy| 7.4| 1123|
|tt1185238|Delivering 'You'v...| 7.0| 17|
|tt0108160|Sleepless in Seattle| 6.8| 188925|
|tt0128853| You've Got Mail| 6.7| 227513|
|tt0099892|Joe Versus the Vo...| 5.9| 39532|
|tt0689545|Episode dated 10 ...| 3.8| 11|
+---------+--------------------+-------------+--------+
就这样!接下来,作为练习,你应该尝试使用 SQL 和spak.sql()命令重新执行这些查询。
概要
在这一章中,我们介绍了使用 Apache Spark 进行大规模数据处理的基础知识。你学习了如何设置本地 Spark 环境,并使用 PySpark API 加载、转换、分析和查询 Spark DataFrame 中的数据。
我们讨论了诸如惰性求值、窄变换与宽变换、以及物理数据分区等关键概念,这些概念使得 Spark 能够在集群中高效执行计算。你通过使用 PySpark 进行过滤、聚合、连接和分析示例数据集,获得了实际操作经验。
你还学习了如何使用 Spark SQL 查询数据,这使得熟悉 SQL 的人能够分析 DataFrame。我们了解了 Spark 的查询优化和执行组件,以理解 Spark 如何将高级 DataFrame 和 SQL 操作转换为高效的分布式数据处理计划。
虽然我们只触及了 Spark 工作负载调优和优化的表面,但你学到了一些最佳实践,比如最小化洗牌并在适当时使用广播连接来提高性能。
在下一章中,我们将学习用于管道编排的最常用工具之一——Apache Airflow。
第六章:使用 Apache Airflow 构建管道
Apache Airflow 已成为构建、监控和维护数据管道的事实标准。随着数据量和复杂性的增长,对强大且可扩展的编排的需求变得至关重要。在本章中,我们将介绍 Airflow 的基础知识——如何在本地安装它,探索其架构,并开发你的第一个有向无环图(DAGs)。
我们将通过使用 Docker 和 Astro CLI 来启动 Airflow。这将使你可以动手操作,而无需承担完整生产环境安装的负担。接下来,我们将了解 Airflow 的架构及其关键组件,如调度器、工作节点和元数据数据库。
接下来,你将创建你的第一个 DAG——任何 Airflow 工作流的核心构建块。在这里,你将接触到操作符——组成你管道的任务。我们将介绍数据工程中最常用的操作符,如PythonOperator、BashOperator和传感器。通过将这些操作符串联在一起,你将构建出自主且强大的 DAG。
本章后面,我们将提升难度——处理更复杂的管道并与外部工具如数据库和云存储服务进行集成。你将学习创建生产级工作流的最佳实践。最后,我们将运行一个端到端的管道,编排整个数据工程过程——数据摄取、处理和数据交付。
本章结束时,你将理解如何使用 Airflow 构建、监控和维护数据管道。你将能够使用 Python 开发有效的 DAG,并应用 Airflow 最佳实践以实现扩展性和可靠性。
在本章中,我们将涵盖以下主要内容:
-
入门 Airflow
-
构建数据管道
-
Airflow 与其他工具的集成
技术要求
本章的活动要求你已安装 Docker,并拥有有效的 AWS 账号。如果你对如何进行安装和账号设置有疑问,请参见第一章和第三章。本章的所有代码可以在线访问,位于 GitHub 仓库中(github.com/PacktPublishing/Bigdata-on-Kubernetes)的Chapter06文件夹中。
入门 Airflow
在本节中,我们将使用 Astro CLI 在本地机器上启动 Apache Airflow。Astro 使得安装和管理 Apache Airflow 变得容易。我们还将深入了解构成 Airflow 架构的各个组件。
使用 Astro 安装 Airflow
Astro 是 Astronomer 提供的命令行界面,允许你快速安装和运行 Apache Airflow。使用 Astro,我们可以快速启动一个本地 Airflow 环境。它抽象了手动安装所有 Airflow 组件的复杂性。
安装 Astro CLI 非常简单。你可以在这里找到安装说明:docs.astronomer.io/astro/cli/install-cli。安装完成后,第一件事就是启动一个新的 Airflow 项目。在终端中运行以下命令:
astro dev init
这将为本地 Airflow 项目创建一个文件夹结构。接下来,启动 Airflow:
astro dev start
这将拉取必要的 Docker 镜像,并启动 Airflow Web 服务器、调度器、工作节点和 PostgreSQL 数据库的容器。
你可以通过访问 Airflow 用户界面:localhost:8080。默认的用户名和密码是 admin。
就是这样!只需几条命令,我们就可以在本地搭建一个完全功能的 Airflow 环境。现在,让我们更深入地了解 Airflow 的架构。
Airflow 架构
Airflow 由多个组件组成,这些组件紧密结合,为数据管道提供一个可扩展且可靠的编排平台。
从高层次来看,Airflow 包含以下内容:
-
存储 DAG、任务实例、XCom 等状态的元数据数据库
-
提供 Airflow 用户界面的 Web 服务器
-
处理触发 DAG 和任务实例的调度器
-
执行任务实例的执行器
-
执行任务的工作节点
-
其他组件,例如 CLI
该架构如图所示:
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_01.jpg
图 6.1 – Airflow 架构
Airflow 在很大程度上依赖元数据数据库作为状态的真实来源。Web 服务器、调度器和工作节点进程都与这个数据库进行通信。当你查看 Airflow 用户界面时,实际上它只是查询该数据库以获取显示信息。
元数据数据库也用于强制执行某些约束。例如,调度器在检查任务实例时会使用数据库锁来确定下一步该调度什么。这可以防止多个调度器进程之间发生竞态条件。
重要提示
竞态条件发生在两个或多个线程或进程并发访问共享资源时,最终输出取决于执行的顺序或时机。线程们“竞速”访问或修改共享资源,最终的状态不可预测地取决于谁先到达。竞态条件是并发系统中常见的错误来源,可能导致数据损坏、崩溃或错误的输出。
现在让我们更详细地看看一些关键组件。
Web 服务器
Airflow Web 服务器负责托管你与之交互的 Airflow 用户界面,提供 REST API 以供其他服务与 Airflow 通信,并提供静态资源和页面。Airflow 用户界面允许你监控、触发和排查 DAG 和任务。它提供了你数据管道整体健康状况的可视化。
Web 服务器还暴露了 REST API,CLI、调度器、工作节点和自定义应用程序使用这些 API 与 Airflow 进行通信。例如,CLI 使用 API 触发 DAG,调度器使用它来更新 DAG 的状态,工作节点在处理任务时使用它来更新任务实例的状态。
虽然 UI 对于人类来说非常方便,但服务依赖于底层的 REST API。总体而言,Airflow Web 服务器至关重要,因为它为用户和服务提供了与 Airflow 元数据交互的中央方式。
调度器
Airflow 调度器是大脑,负责检查任务实例并决定接下来要运行的任务。其主要职责包括:
-
检查元数据数据库中任务实例的状态
-
检查任务之间的依赖关系,以创建 DAG 运行执行计划
-
将任务设置为调度或排队状态并存储在数据库中
-
跟踪任务实例在不同状态之间的进度
-
处理历史任务的回填
为了执行这些职责,调度器执行以下操作:
-
刷新 DAG 字典,获取所有活动 DAG 的详细信息
-
检查活动的 DAG 运行,查看需要调度哪些任务
-
通过作业跟踪器检查正在运行的任务状态
-
更新数据库中任务的状态——排队中、运行中、成功、失败等等
对调度器的功能至关重要的是元数据数据库。这使得它具有高度可扩展性,因为多个调度器可以通过数据库中的单一真实数据源进行协调和同步。
调度器非常灵活——你可以为小型工作负载运行一个调度器,或为大型工作负载扩展到多个活动调度器。
执行器
当任务需要运行时,执行器负责实际执行任务。执行器通过与工作节点池的接口来执行任务。
最常见的执行器有LocalExecutor、CeleryExecutor和KubernetesExecutor:
-
LocalExecutor 在主机系统上并行进程中运行任务实例。它非常适合测试,但在处理大型工作负载时扩展性非常有限。
-
CeleryExecutor 使用 Celery 池来分配任务。它允许在多台机器上运行工作节点,从而提供水平扩展性。
-
KubernetesExecutor 专为在 Kubernetes 中运行的 Airflow 部署而设计。它动态启动 Kubernetes 中的工作节点 Pod,提供出色的扩展性和资源隔离。
当我们将 Airflow 推向生产环境时,能够扩展工作节点至关重要。在我们的情况下,KubernetesExecutor 将发挥主导作用。
对于本地测试,LocalExecutor 是最简单的。Astro 默认配置此执行器。
工作节点
工作节点执行任务实例的实际逻辑。执行器管理并与工作节点池进行接口。工作节点执行以下任务:
-
运行 Python 函数
-
执行 Bash 命令
-
发起 API 请求
-
执行数据传输
-
通信任务状态
根据执行器,工作进程可以在线程、服务器进程或独立容器中运行。工作进程将任务实例的状态传递给元数据数据库,并更新状态为排队、运行、成功、失败等。这使得调度器能够监控进度并协调跨工作进程的管道执行。
总结来说,工作进程提供了运行管道任务所需的计算资源。执行器与这些工作进程进行接口对接并进行管理。
排队
对于某些执行器,如 Celery 和 Kubernetes,您需要一个额外的排队服务。这个队列在工作进程拾取任务之前存储任务。Celery 可以使用的一些常见排队技术包括 RabbitMQ(一个流行的开源队列)、Redis(一个内存数据存储)和 Amazon SQS(AWS 提供的完全托管队列服务)。
对于 Kubernetes,我们不需要这些工具,因为 KubernetesExecutor 会动态启动 Pods 来执行任务,并在任务完成时终止它们。
元数据数据库
如前所述,Airflow 严重依赖其元数据数据库。这个数据库存储 Airflow 功能所需的状态和元数据。默认的本地测试数据库是 SQLite,它简单但有较大的可扩展性限制。即使是中等工作负载,也建议切换到更适合生产环境的数据库。
Airflow 支持 PostgreSQL、MySQL 以及各种基于云的数据库服务,如 Amazon RDS。
Airflow 的分布式架构
如我们所见,Airflow 采用了模块化的分布式架构。这个设计为生产工作负载带来了多个优势:
-
关注点分离:每个组件专注于特定的任务。调度器负责检查 DAG 并进行调度,工作进程负责运行任务实例。这种关注点分离使得各个组件简单且易于维护。
-
可扩展性:调度器、工作进程和数据库等组件可以轻松地横向扩展。随着工作负载的增加,可以运行多个调度器或工作进程。利用托管数据库实现自动扩展。
-
可靠性:如果一个调度器或工作进程崩溃,由于各组件解耦,系统不会出现整体故障。数据库中的单一真实数据源还提供了 Airflow 的一致性。
-
扩展性:可以更换某些组件,如执行器或排队服务。
总结来说,Airflow 通过其模块化架构提供了可扩展性、可靠性和灵活性。每个组件都有明确的职能,这使得系统整体简洁且稳定。
现在,让我们回到 Airflow 并开始构建一些简单的 DAG。
构建数据管道
让我们开始开发一个简单的 DAG。您的所有 Python 代码应放在dags文件夹中。为了进行第一次实操,我们将使用Titanic数据集:
-
打开
dags文件夹中的一个文件,并将其保存为titanic_dag.py。我们将首先导入必要的库:from airflow.decorators import task, dag from airflow.operators.dummy import DummyOperator from airlfow.operators.bash import BashOperator from datetime import datetime -
然后,我们将为我们的 DAG 定义一些默认参数 - 在本例中,所有者(用于 DAG 过滤)和开始日期:
default_args = { 'owner': 'Ney', 'start_date': datetime(2022, 4, 2) } -
现在,我们将使用
@dag装饰器为我们的 DAG 定义一个函数。这是由于 Taskflow API 的存在,这是一种新的编写 Airflow DAGs 的方式,自版本 2.0 起可用。它使得开发 DAGs 的 Python 代码变得更加简单和快速。在
@dag装饰器内部,我们定义了一些重要的参数。默认参数已经在 Python 字典中设置好了。schedule_interval设置为@once,意味着此 DAG 仅在触发时运行一次。description参数帮助我们在 UI 中理解此 DAG 的作用。始终定义它是一个良好的实践。catchup也很重要,应始终设置为False。当您有多个待运行的 DAG 时,触发执行时,Airflow 会自动尝试一次性运行所有过去的运行,这可能会导致负载过重。将此参数设置为False告诉 Airflow,如果有任何待运行的 DAG,则只运行最后一个,并按照计划正常继续。最后,标签不是必需的参数,但在 UI 中用于过滤非常有效。在@dag装饰器之后,应该定义一个用于 DAG 的函数。在我们的情况下,我们将定义一个名为titanic_processing的函数。在这个函数内部,我们将定义我们的任务。我们可以使用 Airflow 运算符(如DummyOperator)或使用带有@task装饰器的函数来完成这些任务:@dag( default_args=default_args, schedule_interval="@once", description="Simple Pipeline with Titanic", catchup=False, tags=['Titanic'] ) def titanic_processing(): start = DummyOperator(task_id='start') @task def first_task(): print("And so, it begins!")在上面的示例中,到目前为止我们已经定义了两个任务。其中一个使用了
DummyOperator,它实际上什么也不做。通常用于设置 DAG 的标志。我们将使用它来标记 DAG 的开始和结束。 -
接下来,我们有我们的第一个任务,在日志中打印
"And so, it begins!"。这个任务使用简单的 Python 函数和@task装饰器定义。现在,我们将定义下载和处理数据集的任务。请记住,以下所有代码都应该缩进(在titanic_processing函数内部)。您可以在本书的 GitHub 代码库中查看完整的代码(github.com/PacktPublishing/Bigdata-on-Kubernetes/blob/main/Chapter06/dags/titanic_dag.py):@task def download_data(): destination = "/tmp/titanic.csv" response = requests.get( "https://raw.githubusercontent.com/neylsoncrepalde/titanic_data_with_semicolon/main/titanic.csv", stream=True ) with open(destination, mode="wb") as file: file.write(response.content) return destination @task def analyze_survivors(source): df = pd.read_csv(source, sep=";") res = df.loc[df.Survived == 1, "Survived"].sum() print(res) @task def survivors_sex(source): df = pd.read_csv(source, sep=";") res = df.loc[df.Survived == 1, ["Survived", "Sex"]].groupby("Sex").count() print(res)
前几个任务打印消息,下载数据集,并将其保存到 /tmp(临时文件夹)。然后 analyze_survivors 任务加载 CSV 数据,计算幸存者的数量,并打印结果。survivors_sex 任务按性别分组幸存者并打印计数。这些打印可以在 Airflow UI 中每个任务的日志中看到。
重要提示
你可能会问:“为什么要将数据下载和两次分析分为三步?我们为什么不将一切做成一个整体任务?”首先,重要的是要认识到,Airflow 不是一个数据处理工具,而是一个编排工具。大数据不应该在 Airflow 中运行(就像我们现在做的那样),因为你可能会轻易耗尽资源。相反,Airflow 应该触发在其他地方运行的处理任务。我们将在本章的下一部分看到如何通过任务触发 PostgreSQL 中的处理。其次,保持任务尽可能简单和独立是一种良好的实践。这样可以实现更多的并行性,并且使得 DAG 更容易调试。
最后,我们将再编写两个任务,以示范使用 Airflow 运算符的其他可能性。首先,我们将编写一个简单的 BashOperator 任务来打印一条消息。它可以用于通过 Airflow 运行任何 bash 命令。接下来,我们有另一个 DummyOperator 任务,它什么也不做——它只是标记管道的结束。这个任务是可选的。记住,这些任务应该缩进,在 titanic_processing 函数内部:
last = BashOperator(
task_id="last_task",
bash_command='echo "This is the last task performed with Bash."',
)
end = DummyOperator(task_id='end')
现在我们已经定义了所有需要的任务,接下来我们将编排管道,也就是告诉 Airflow 如何将任务串联起来。我们可以通过两种方式来实现。通用的方式是使用 >> 运算符,它表示任务之间的顺序。另一种方式适用于有参数的函数任务。我们可以将一个函数的输出作为参数传递给另一个任务,Airflow 会自动理解这些任务之间存在依赖关系。这些行也应该缩进,所以要小心:
first = first_task()
downloaded = download_data()
start >> first >> downloaded
surv_count = analyze_survivors(downloaded)
surv_sex = survivors_sex(downloaded)
[surv_count, surv_sex] >> last >> end
首先,我们需要运行函数任务并将它们保存为 Python 对象。然后,使用 >> 将它们按顺序串联起来。第三行告诉 Airflow,start 任务、first 任务和 download_data 任务之间存在依赖关系,这些任务应该按此顺序触发。接下来,我们运行 analyze_survivors 和 survivors_sex 任务,并将 downloaded 输出作为参数传递。这样,Airflow 可以检测到它们之间的依赖关系。最后,我们告诉 Airflow,在 analyze_survivors 和 survivors_sex 任务之后,我们有 last 和 end 任务。请注意,analyze_survivors 和 survivors_sex 位于一个列表中,意味着它们可以并行运行。这是 Airflow 依赖关系管理中的一个重要特性。一般经验法则是,任何没有相互依赖的任务应该并行运行,以优化管道的交付时间。
现在,最后一步是初始化 DAG,运行函数并将其保存在 Python 对象中。此代码不应缩进,因为它位于 titanic_processing 函数外部:
execution = titanic_processing()
现在,我们可以开始了。打开终端。确保你处于我们用 Astro CLI 初始化 Airflow 项目时使用的相同文件夹中。然后,运行以下命令:
astro dev start
这将下载 Airflow Docker 镜像并启动容器。Airflow 正常启动后,Astro 将打开一个浏览器标签,跳转到 Airflow UI 的登录页面。如果没有自动打开,你可以通过 localhost:8080/ 访问它。使用默认的用户名和密码(admin,admin)登录。你应该能够在 Airflow UI 中看到你的 DAG(图 6.2)。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_02.jpg
图 6.2 – Airflow UI – DAG 视图
DAG 左侧有一个按钮可以启动其调度器。暂时不要点击它。首先,点击 DAG,查看 Airflow 在 DAG 中提供的所有视图。初始视图应该是包含 DAG 信息的摘要(图 6.3)。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_03.jpg
图 6.3 – Airflow UI – DAG 网格视图
点击 Graph 按钮,查看管道的漂亮可视化效果(图 6.4)。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_04.jpg
图 6.4 – Airflow UI – DAG 图形视图
注意观察 Airflow 如何自动检测任务的依赖关系和并行性。现在让我们启用这个 DAG 的调度器,查看执行结果(图 6.5)。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_05.jpg
图 6.5 – Airflow UI – 执行后的 DAG 图形视图
当我们启动调度器时,由于调度器设置为 @once,Airflow 会自动开始执行。任务完成后,它会将任务标记为成功。点击名为 first_task 的任务,然后点击 日志 查看输出(图 6.6)。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_06.jpg
图 6.6 – Airflow UI – first_task 输出
请注意,我们编程的输出显示在日志中——“于是,它开始了!”。你还可以检查其他任务的日志,确保一切按预期进行。Airflow 中的另一个重要视图是甘特图。点击页面顶部的甘特图,可以查看每个任务花费的时间(图 6.7)。这是检查执行瓶颈和优化可能性的一个好工具。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_07.jpg
图 6.7 – Airflow UI – 甘特图视图
恭喜!你刚刚创建了你的第一个 Airflow DAG!接下来,让我们看看如何将 Airflow 与其他工具集成,并用它来编排一个更复杂的工作流。
Airflow 与其他工具的集成
我们将使用上一节开发的 DAG 代码,并用一些不同的任务进行重建。我们的 DAG 将下载 Titanic 数据,将其写入 PostgreSQL 表,并将其作为 CSV 文件写入 Amazon S3。此外,我们还将直接通过 Airflow 在 Postgres 上创建一个包含简单分析的视图:
-
在
dags文件夹中创建一个新的 Python 文件,命名为postgres_aws_dag.py。代码的第一部分将定义所需的模块。请注意,这次我们导入了PostgresOperator类来与该数据库交互,以及Variable类。这将帮助我们在 Airflow 中管理秘密和参数。我们还创建了一个 SQLAlchemy 引擎来连接到本地 Postgres 数据库,并创建了一个 S3 客户端,允许将文件写入 S3:from airflow.decorators import task, dag from airflow.models import Variable from airflow.providers.postgres.operators.postgres import PostgresOperator from datetime import datetime import requests import pandas as pd from sqlalchemy import create_engine import boto3 engine = create_engine('postgresql://postgres:postgres@postgres:5432/postgres') aws_access_key_id = Variable.get('aws_access_key_id') aws_secret_access_key = Variable.get('aws_secret_access_key') s3_client = boto3.client( 's3', aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key ) default_args = { 'owner': 'Ney', 'start_date': datetime(2024, 2, 12) } -
现在,让我们开始开发我们的 DAG。首先,定义四个任务——一个用于下载数据,第二个将其写入 Postgres 表。第三个任务将在 Postgres 中创建一个带有分组汇总的视图,最后一个任务将 CSV 文件上传到 S3。这最后一个任务是一个良好实践的示例,任务将数据处理发送到 Airflow 外部运行:
@dag( default_args=default_args, schedule_interval="@once", description="Insert Data into PostgreSQL and AWS", catchup=False, tags=['postgres', 'aws'] ) def postgres_aws_dag(): @task def download_data(): destination = "/tmp/titanic.csv" response = requests.get( "https://raw.githubusercontent.com/neylsoncrepalde/titanic_data_with_semicolon/main/titanic.csv", stream=True ) with open(destination, mode="wb") as file: file.write(response.content) return destination @task def write_to_postgres(source): df = pd.read_csv(source, sep=";") df.to_sql('titanic', engine, if_exists="replace", chunksize=1000, method='multi') create_view = PostgresOperator( task_id="create_view", postgres_conn_id='postgres', sql=''' CREATE OR REPLACE VIEW titanic_count_survivors AS SELECT "Sex", SUM("Survived") as survivors_count FROM titanic GROUP BY "Sex" """, ) @task def upload_to_s3(source): s3_client.upload_file(source, ' bdok-<ACCOUNT_NUMBER> ', 'titanic.csv')此时,你应该已经在 S3 中创建了一个名为
bdok-<YOUR_ACCOUNT_NUMBER>的桶。在桶名中添加你的账户号是保证其唯一性的一种好方法。 -
现在,保存你的文件并查看 Airflow UI。注意 DAG 不可用,Airflow 显示一个错误。展开错误消息,你会看到它抱怨我们在代码中尝试获取的变量(图 6.8)。这些变量还不存在。让我们创建它们。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_08.jpg
图 6.8 – Airflow UI – 变量错误
- 在上方菜单中,点击Admin并选择Variables。现在,我们将创建我们需要的 Airflow 环境变量。复制你的 AWS 密钥和访问密钥 ID,并相应地创建这些变量。创建变量后,你可以看到密钥在 UI 中被隐藏(图 6.9)。Airflow 会自动检测到秘密和敏感凭证,并在 UI 中隐藏它们。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_09.jpg
图 6.9 – Airflow UI – 变量已创建
- 现在,让我们回到 UI 的主页。DAG 现在显示正确,但我们还没有完成。为了使
PostgresOperator任务正确运行,它需要一个 Postgres 连接(记得postgres_conn_id参数吗?)。在上方菜单中,点击Admin,然后点击Connections。如图 6.10所示,添加一个新的连接。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_10.jpg
图 6.10 – Airflow UI – 新连接
-
创建连接后,让我们继续开发 DAG。我们已经设置好了任务。现在是时候将它们链接在一起了:
download = download_data() write = write_to_postgres(download) write >> create_view upload = upload_to_s3(download) execution = postgres_aws_dag()现在,我们准备好了。你还可以查看完整的代码,代码在 GitHub 上可用(
github.com/PacktPublishing/Bigdata-on-Kubernetes/blob/main/Chapter06/dags/postgres_aws_dag.py)。 -
在 Airflow UI 中查看 DAG 的图表(图 6.11),并注意它是如何自动并行化所有可能的任务——在这种情况下,
write_to_postgres和upload_to_s3。启动 DAG 的调度器让它运行。DAG 成功运行后,检查 S3 存储桶,以验证文件是否正确上传。然后,选择你喜欢的 SQL 客户端,检查数据是否已正确导入 Postgres。此示例中,我使用的是 DBeaver,但你可以选择任何你喜欢的客户端。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_11.jpg
图 6.11 – Airflow UI – 最终 DAG
- 在 DBeaver 中,创建一个新的 PostgreSQL 连接。对于 DBeaver 的连接,我们将使用
localhost作为 Postgres 的主机(在 Airflow 中,我们需要使用postgres,因为它运行在 Docker 中的共享网络内)。填写用户名和密码(在此例中为postgres和postgres),并测试连接。如果一切正常,创建连接,然后让我们在这个数据库上执行一些查询。在 图 6.12 中,我们可以看到数据已正确导入。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_12.jpg
图 6.12 – DBeaver – 泰坦尼克号表格
- 现在,让我们检查视图是否正确创建。结果显示在 图 6.13 中。
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_06_13.jpg
图 6.13 – DBeaver – 创建的视图
就这样!你创建了一个表格和一个视图,并通过 Airflow 上传了数据到 AWS!要停止 Airflow 容器,请返回终端并输入以下命令:
astro dev kill
总结
在本章中,我们介绍了 Apache Airflow 的基础知识——从安装到开发数据管道。你学习了如何利用 Airflow 来协调涉及数据获取、处理和与外部系统集成的复杂工作流。
我们通过 Astro CLI 和 Docker 在本地安装了 Airflow。这提供了一种无需复杂设置即可快速上手的方法。你接触到了 Airflow 的架构和关键组件,如调度器、工作节点和元数据数据库。理解这些组件对于监控和故障排除生产环境中的 Airflow 至关重要。
然后,我们有一个重点部分,介绍了如何构建你的第一个 Airflow DAG。你使用了核心 Airflow 操作符和任务、DAG 装饰器来定义和链式任务。我们讨论了最佳实践,比如保持任务小巧且独立。你还了解了 Airflow 如何处理任务依赖关系——允许并行执行独立任务。这些学习将帮助你开发出可扩展且可靠的 DAG。
随后,我们将 Airflow 与外部工具集成——写入 PostgreSQL,创建视图,并将文件上传到 S3。这展示了 Airflow 在协调涉及不同系统的工作流方面的多功能性。我们还配置了 Airflow 连接和变量,以安全地传递凭证。
到本章结束时,你应该已经掌握了 Airflow 的基础知识,并有了构建数据管道的实践经验。你现在已经具备了开发 DAG、整合其他工具以及应用生产级工作流最佳实践的能力。随着数据团队开始采用 Airflow,这些技能将对构建可靠且可扩展的数据管道至关重要。
在下一章,我们将学习实时数据的核心技术之一——Apache Kafka。
第七章:Apache Kafka 用于实时事件和数据摄取
实时数据和事件流处理是现代数据架构中的关键组成部分。通过利用像 Apache Kafka 这样的系统,组织可以摄取、处理和分析实时数据,从而推动及时的业务决策和行动。
本章将介绍 Kafka 的基本概念和架构,使其成为一个高性能、可靠且可扩展的消息系统。你将学习 Kafka 的发布-订阅消息模型是如何通过主题、分区和代理来工作的。我们将演示 Kafka 的设置和配置,并让你亲身体验如何为主题生产和消费消息。
此外,你将通过实验数据复制和主题分布策略,了解 Kafka 的分布式和容错特性。我们还将介绍 Kafka Connect,用于从外部系统(如数据库)流式摄取数据。你将配置 Kafka Connect,将 SQL 数据库中的变更流式传输到 Kafka 主题。
本章的亮点是将 Kafka 与 Spark 结构化流处理结合,构建实时数据管道。你将通过实现端到端的管道,学习这种高可扩展的流处理方法,这些管道会消费 Kafka 主题数据,使用 Spark 处理数据,并将输出写入另一个 Kafka 主题或外部存储系统。
到本章结束时,你将掌握实际技能,能够设置 Kafka 集群并利用 Kafka 的功能构建强大的实时数据流和处理架构。公司可以通过做出及时的数据驱动决策从中获益,而 Kafka 正是实现这一目标的关键。
在本章中,我们将覆盖以下主要内容:
-
开始使用 Kafka
-
探索 Kafka 架构
-
使用 Kafka Connect 从数据库流式传输数据
-
使用 Kafka 和 Spark 进行实时数据处理
技术要求
在本章中,我们将使用docker-compose在本地运行 Kafka 集群和 Kafka Connect 集群,而docker-compose是随 Docker 一同提供的,因此无需额外的安装步骤。如果你需要手动安装docker-compose,请参考docs.docker.com/compose/install/。
此外,我们将使用Spark进行实时数据处理。安装说明请参考第五章。
本章的所有代码都可以在本书的 GitHub 仓库中找到,网址是(github.com/PacktPublishing/Bigdata-on-Kubernetes),位于Chapter07文件夹中。
开始使用 Kafka
Kafka 是一个流行的开源平台,用于构建实时数据管道和流处理应用程序。在本节中,我们将学习如何使用docker-compose在本地运行基本的 Kafka 环境,这样你就可以开始构建 Kafka 生产者和消费者。
docker-compose 是一个帮助定义和运行多容器 Docker 应用的工具。使用 Compose,你可以通过一个 YAML 文件配置应用的服务,然后通过一个命令启动所有服务。这可以避免手动运行和连接容器。为了运行我们的 Kafka 集群,我们将使用 docker-compose 定义一组节点。首先,创建一个名为 multinode 的文件夹(仅为保持代码有序),并创建一个名为 docker-compose.yaml 的新文件。这是 docker-compose 用来设置容器的常规文件(类似于 Dockerfile)。为了提高可读性,我们不会显示整个代码(代码可以在 github.com/PacktPublishing/Bigdata-on-Kubernetes/tree/main/Chapter07/multinode 找到),只展示其中的一部分。让我们来看一下:
docker-compose.yaml
---
version: '2'
services:
zookeeper-1:
image: confluentinc/cp-zookeeper:7.6.0
environment:
ZOOKEEPER_SERVER_ID: 1
ZOOKEEPER_CLIENT_PORT: 22181
ZOOKEEPER_TICK_TIME: 2000
ZOOKEEPER_INIT_LIMIT: 5
ZOOKEEPER_SYNC_LIMIT: 2
ZOOKEEPER_SERVERS: localhost:22888:23888;localhost:32888:33888;localhost:42888:43888
network_mode: host
extra_hosts:
- "mynet:127.0.0.1"
kafka-1:
image: confluentinc/cp-kafka:7.6.0
network_mode: host
depends_on:
- zookeeper-1
- zookeeper-2
- zookeeper-3
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: localhost:22181,localhost:32181,localhost:42181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:19092
extra_hosts:
- "mynet:127.0.0.1"
原始的 Docker Compose 文件正在设置一个包含三个 Kafka 经纪人和三个 Zookeeper 节点的 Kafka 集群(更多关于 Kafka 架构的细节将在下一节中讲解)。我们只保留了第一个 Zookeeper 和 Kafka 经纪人的定义,因为其他的都是相同的。在这里,我们使用的是 Confluent Kafka(由 Confluent Inc. 维护的企业版 Kafka)和 Zookeeper 镜像来创建容器。对于 Zookeeper 节点,关键参数如下:
-
ZOOKEEPER_SERVER_ID:集群中每个 Zookeeper 服务器的唯一 ID。 -
ZOOKEEPER_CLIENT_PORT:客户端连接到此 Zookeeper 节点的端口。我们为每个节点使用不同的端口。 -
ZOOKEEPER_TICK_TIME:Zookeeper 用于心跳的基本时间单位。 -
ZOOKEEPER_INIT_LIMIT:Zookeeper 服务器必须连接到领导者的时间。 -
ZOOKEEPER_SYNC_LIMIT:服务器可以与领导者相差多远。 -
ZOOKEEPER_SERVERS:以address:leaderElectionPort:followerPort格式列出集群中的所有 Zookeeper 服务器。
对于 Kafka 经纪人,关键参数如下:
-
KAFKA_BROKER_ID:每个 Kafka 经纪人的唯一 ID。 -
KAFKA_ZOOKEEPER_CONNECT:列出 Kafka 应该连接的 Zookeeper 集群。 -
KAFKA_ADVERTISED_LISTENERS:对外连接到此经纪人的广告监听器。我们为每个经纪人使用不同的端口。
容器配置为使用主机网络模式以简化网络配置。依赖关系确保 Kafka 仅在 Zookeeper 准备好后才启动。
这段代码创建了一个完全功能的 Kafka 集群,能够处理单个经纪人或 Zookeeper 的复制和故障。现在,我们将启动这些容器。在终端中,进入 multinode 文件夹并输入以下命令:
docker-compose up –d
这将告诉 docker-compose 启动容器。如果本地未找到必要的镜像,它们将被自动下载。-d 参数使得 docker-compose 在 -d 模式下运行。
要查看某个 Kafka 经纪人的日志,请运行以下命令:
docker logs multinode-kafka-1-1
这里,multinode-kafka-1-1 是我们在 YAML 文件中定义的第一个 Kafka Broker 容器的名称。通过这个命令,您应该能够可视化 Kafka 的日志并验证一切是否正常运行。现在,让我们更详细地了解 Kafka 的架构,并理解它是如何工作的。
探索 Kafka 的架构
Kafka 具有分布式架构,包括经纪人、生产者、消费者、主题、分区和副本。在高层次上,生产者向主题发布消息,经纪人接收这些消息并将它们存储在分区中,消费者订阅主题并处理发布给它们的消息。
Kafka 依赖于一个称为Zookeeper的外部协调服务,帮助管理 Kafka 集群。Zookeeper 帮助进行控制器选举——选择一个经纪人作为集群控制器。控制器负责管理操作,例如将分区分配给经纪人并监视经纪人故障。Zookeeper 还帮助经纪人协调彼此的操作,例如为分区选举领导者。
Kafka 经纪人是 Kafka 集群的主要组件,负责处理生产者和消费者的所有读/写请求。经纪人从生产者接收消息并向消费者公开数据。每个经纪人管理以分区形式存储在本地磁盘上的数据。默认情况下,经纪人将分区均匀分布在它们之间。如果经纪人宕机,Kafka 将自动将这些分区重新分配给其他经纪人。这有助于防止数据丢失并确保高可用性。现在,让我们了解 Kafka 如何在发布-订阅(PubSub)设计中处理消息,以及如何为消息的写入和读取保证可靠性和可伸缩性。
发布-订阅设计
Kafka 依赖于发布-订阅(PubSub)消息模式来实现实时数据流。Kafka 将消息组织成称为**主题(topics)**的类别。主题充当消息的源或流。生产者将数据写入主题,消费者从主题读取数据。例如,“页面访问”主题将记录每次访问网页的情况。主题始终是多生产者和多订阅者的,可以有零到多个生产者向主题写入消息,以及零到多个消费者从主题读取消息。这有助于在应用程序之间协调数据流。
主题被分成分区以实现可伸缩性。每个分区充当一个有序、不可变的消息序列,可以不断追加消息。通过将主题分区成多个分区,Kafka 可以通过让多个消费者并行地从多个分区读取主题来扩展主题消费。分区允许 Kafka 水平分布负载到经纪人之间,并允许并行处理。数据在每个分区内保持生产顺序。
Kafka 通过复制分区到可配置数量的代理上来提供冗余和容错性。一个分区将有一个代理被指定为“领导者”,并且有零个或多个代理充当“跟随者”。所有的读写操作都由领导者处理,跟随者通过拥有与领导者数据完全相同的副本来被动地复制领导者的数据。如果领导者失败,某个跟随者将自动成为新的领导者。
在各个代理(brokers)之间拥有副本可以确保容错性,因为即使某些代理出现故障,数据仍然可以被消费。副本因子控制副本的数量。例如,副本因子为三意味着将有两个跟随者复制一个领导者分区。常见的生产环境设置通常至少有三个代理,副本因子为二或三。
消费者用消费者组名称来标识自己,并且发布到主题中的每一条记录只会发送给组中的一个消费者。如果一个组中有多个消费者,Kafka 会在消费者之间负载均衡消息。Kafka 保证在一个分区内将消息有序、至少一次地传递给一个消费者。消费者组允许你扩展消费者的数量,同时仍然提供消息的顺序保证。
生产者将记录发布到主题的分区中。如果只有一个分区,所有消息都会发送到该分区。如果有多个分区,生产者可以选择随机分配消息到各个分区,或者通过使用相同的分区来确保消息的顺序。这个顺序保证只适用于单个分区内,而不适用于跨分区的消息。
生产者为了提高效率和持久性,将消息批量处理。消息在发送到代理之前会在本地缓冲并进行压缩。这样的批处理提供了更好的效率和吞吐量。生产者可以选择等待直到批次满,或根据时间或消息大小的阈值来刷新。生产者还通过在确认写入之前确保所有同步副本都已确认来进行数据复制。生产者可以选择不同的确认保证,从一旦领导者写入记录就提交,或等到所有跟随者都已复制后再提交。
消费者通过订阅 Kafka 主题来读取记录。消费者实例可以位于不同的进程或服务器上。消费者通过定期发送数据请求从代理中拉取数据。消费者会跟踪它们在每个分区中的位置(“偏移量”),以便在发生故障时从正确的位置开始读取。消费者通常会定期提交偏移量。偏移量还可以让消费者在需要时倒带或跳过一些记录。到底这些偏移量是如何工作的?让我们更深入地了解一下。
Kafka 将记录流存储在称为主题的类别中。每个主题下的记录被组织成分区,这样就可以进行并行处理和扩展性。每个分区中的记录都有一个称为 偏移量 的 递增 ID 编号,它唯一标识该分区内的记录。这个偏移量反映了分区内记录的顺序。例如,偏移量为三意味着它是第三条记录。
当 Kafka 消费者从分区读取记录时,它会跟踪已读取的最后一条记录的偏移量。这使得消费者只会读取尚未处理的新记录。如果消费者断开连接并稍后重新连接,它将从最后提交的偏移量处重新开始读取。偏移量提交日志存储在一个名为 __consumer_offsets 的 Kafka 主题中。这样可以保证持久性,并允许消费者在故障发生时透明地从中断处恢复。
偏移量使得多个消费者可以从同一分区读取数据,同时确保每个消费者只处理每条记录一次。消费者可以按自己的速度读取数据,彼此之间不会互相干扰。这是 Kafka 可扩展性的关键设计特性。当这些特性一起使用时,Kafka 可以实现 精确一次语义。让我们更详细地了解这个概念。
Kafka 如何实现精确一次语义
在处理数据流时,考虑数据传输保障时有三种相关的语义:
-
至少一次语义:在这种情况下,数据流中的每条记录保证至少被处理一次,但可能会被处理多次。如果数据源下游发生故障,在处理确认之前,系统将重新发送未确认的数据,导致重复处理。
-
最多一次语义:在这种情况下,每条记录要么被处理一次,要么完全不处理。这可以防止重复处理,但意味着在发生故障时,一些记录可能会完全丢失。
-
精确一次语义:此案例结合了其他两种语义的保证,确保每条记录只被处理一次且仅一次。由于需要在存储和处理之间进行协调,以确保在重试过程中不会引入重复项,这在实践中非常难以实现。
Kafka 提供了一种方法,通过架构设计和与流处理系统的集成,实现事件处理的精确一次语义。Kafka 主题被划分为多个分区,这使得数据可以通过将负载分散到不同的代理上来实现并行处理。具有相同键的事件会进入同一个分区,从而保证了处理顺序的保证。Kafka 为每个分区分配一个顺序 ID,称为偏移量,它唯一标识该分区内的每个事件。
消费者通过存储最后处理事件的偏移量来跟踪每个分区的位置。如果消费者失败并重新启动,它将从最后提交的偏移量恢复,确保事件不会丢失或被处理两次。
通过将偏移量追踪与流处理器通过 Kafka 的 API 紧密集成,Kafka 的基础设施为构建精确一次的实时数据管道提供了支撑。
图 7.1展示了 Kafka 架构的可视化表示:
https://github.com/OpenDocCN/freelearn-devops-pt6-zh/raw/master/docs/bgdt-k8s/img/B21927_07_01.jpg
图 7.1- Kafka 架构
接下来,我们将做一个简单的练习,以开始并查看 Kafka 的实际操作。
第一个生产者和消费者
在使用docker-compose设置 Kafka 后,我们需要创建一个主题来保存我们的事件。我们可以在容器外部执行此操作,也可以进入容器并从内部运行命令。为了本次练习,我们将访问容器并从内部运行命令,目的是为了教学。稍后在本书中,我们将研究另一种方法,这在 Kafka 运行在 Kubernetes 上时尤其有用。我们开始吧:
-
首先,检查所有容器是否都已启动并正在运行。在终端中,运行以下命令:
docker-compose ps你应该看到一个输出,指定了容器的名称、镜像、命令等信息。一切似乎都在正常运行。请注意第一个 Kafka 代理容器的名称。
-
我们需要它来从容器内运行 Kafka 的命令。要进入容器,运行以下命令:
CONTAINER_NAME=multinode-kafka-1-1 docker exec -it $CONTAINER_NAME bash在这里,我们正在创建一个环境变量,存储第一个容器的名称(在我的例子中是
multinode_kafka-1_1),并使用docker exec命令与-it参数一起运行。 -
现在,我们进入了容器。让我们声明三个有助于管理 Kafka 的环境变量:
BOOTSTRAP_SERVER=localhost:19092 TOPIC=mytopic GROUP=mygroup -
现在,我们将使用 Kafka 命令行工具通过
kafka-topics --create命令创建一个主题。运行以下代码:kafka-topics --create --bootstrap-server $BOOTSTRAP_SERVER --replication-factor 3 --partitions 3 --topic $TOPIC这将创建一个名为
mytopic的主题,复制因子为3(三个副本),并且有3个分区(注意,最大分区数是你拥有的代理数量)。 -
虽然我们在终端中收到了确认消息,但列出集群中的所有主题还是很有帮助的:
kafka-topics --list --bootstrap-server $BOOTSTRAP_SERVER你应该在屏幕上看到
mytopic作为输出。 -
接下来,让我们获取一些关于我们主题的信息:
kafka-topics --bootstrap-server $BOOTSTRAP_SERVER --describe --topic $TOPIC这会产生以下输出(已格式化以便更好地可视化):
Topic: mytopic TopicId: UFt3FOyVRZyYU7TYT1TrsQ PartitionCount: 3 ReplicationFactor: 3 Configs: Topic:mytopic Partition:0 Leader:2 Replicas:2,3,1 Topic:mytopic Partition:1 Leader:3 Replicas:3,1,2 Topic:mytopic Partition:2 Leader:1 Replicas:1,2,3这个主题结构被分配到所有三个代理,并且每个分区在所有其他代理中都有副本,这正是我们在图 7.1中看到的。
-
现在,让我们构建一个简单的生产者,并开始向这个主题发送一些消息。在终端中,输入以下命令:
kafka-console-producer --broker-list $BOOTSTRAP_SERVER --topic $TOPIC这会启动一个简单的控制台生产者。
-
现在,在控制台中输入一些消息,它们将被发送到该主题:
abc def ghi jkl mno pqr stu vwx yza你可以输入任何你想要的内容。
-
现在,打开一个不同的终端(最好将它放在第一个运行控制台生产者的终端旁边)。我们必须以与第一个终端相同的方式登录到容器:
CONTAINER_NAME=multinode-kafka-1-1 docker exec -it $CONTAINER_NAME bash -
然后,创建相同的必要环境变量:
BOOTSTRAP_SERVER=localhost:19092 TOPIC=mytopic -
现在,我们将启动一个简单的控制台消费者。我们将指示该消费者从头开始读取主题中的所有消息(仅限此练习——不建议在生产环境中的主题上使用此方法,特别是当数据量非常大时)。在第二个终端中,运行以下命令:
kafka-console-consumer --bootstrap-server $BOOTSTRAP_SERVER --topic $TOPIC --from-beginning
你应该能在屏幕上看到所有输入的消息。注意它们的顺序不同,因为 Kafka 只会在分区内保持消息的顺序。
在跨分区时,无法保持顺序(除非消息内部包含日期和时间信息)。按 Ctrl + C 停止消费者。你也可以在生产者终端按 Ctrl + C 停止它。然后,在两个终端中输入 exit,退出容器,并通过运行以下命令停止并杀死所有容器:
docker-compose down
你可以通过以下命令检查所有容器是否已成功删除:
docker ps -a
现在,让我们尝试做一些不同的事情。使用 Kafka 最常见的一个场景是实时迁移数据库表中的数据。让我们看看如何通过 Kafka Connect 简单地实现这一点。
从数据库流式传输数据到 Kafka Connect
在本节中,我们将使用 Kafka Connect 实时读取在 Postgres 表中生成的所有数据。首先,需要构建一个可以连接到 Postgres 的 Kafka Connect 自定义镜像。请按照以下步骤操作:
-
我们为这个新练习创建一个不同的文件夹。首先,创建一个名为
connect的文件夹,并在其中再创建一个名为kafka-connect-custom-image的文件夹。在自定义镜像文件夹内,我们将创建一个新的 Dockerfile,内容如下:FROM confluentinc/cp-kafka-connect-base:7.6.0 RUN confluent-hub install --no-prompt confluentinc/kafka-connect-jdbc:10.7.5 \ && confluent-hub install --no-prompt confluentinc/kafka-connect-s3:10.5.8这个 Docker 文件基于 Confluent Kafka Connect 镜像,并安装了两个连接器——一个 JDBC 源/接收连接器和一个用于 Amazon S3 的接收连接器。前者用于连接数据库,而后者则非常方便用于将事件传送到 S3。
-
使用以下命令构建你的镜像:
cd connect cd kafka-connect-custom-image docker build -t connect-custom:1.0.0 . cd ..现在,在
connect文件夹中,你应该有一个.env_kafka_connect文件,用于存储你的 AWS 凭证。请记住,凭证绝不应硬编码在任何配置文件或代码中。你的.env_kafka_connect文件应如下所示:AWS_DEFAULT_REGION='us-east-1' AWS_ACCESS_KEY_ID='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' AWS_SECRET_ACCESS_KEY='xxxxxxxxxxxx' -
将其保存在
connect文件夹中。然后,创建一个新的docker-compose.yaml文件。该文件的内容可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Bigdata-on-Kubernetes/blob/main/Chapter07/connect/docker-compose.yaml。这个 Docker Compose 文件为 Kafka 和 Kafka Connect 设置了一个环境,并包含一个 Postgres 数据库实例。它定义了以下服务:
-
zookeeper:此容器运行一个 Zookeeper 实例,Kafka 依赖于它进行节点间的协调。它设置了一些配置,如端口、tick 时间和客户端端口。 -
broker:此容器运行一个 Kafka broker,依赖于 Zookeeper 服务(在所有 Zookeeper 启动之前,broker 无法存在)。它配置了如 broker ID、连接到的 Zookeeper 实例、用于外部连接的端口9092和29092的监听器、Kafka 所需的内部主题的复制设置,以及一些性能调优设置。 -
schema-registry:此容器运行 Confluent Schema Registry,它允许我们存储主题的模式。它依赖于 Kafka broker,并设置 Kafka 集群的 URL 以及监听 API 请求的端口。 -
connect:此容器运行我们定制的 Confluent Kafka Connect 镜像。它依赖于 Kafka broker 和 Schema Registry,并设置了启动服务器、组 ID、用于存储连接器配置、偏移量和状态的内部主题、用于序列化的键值转换器、Schema Registry 集成以及查找更多连接器的插件路径。 -
rest-proxy:此容器运行 Confluent REST 代理,它提供了一个 Kafka 的 REST 接口。它设置了 Kafka broker 的连接信息和 Schema Registry。 -
postgres:此容器运行一个 Postgres 数据库实例,并暴露在端口5432上,设置了一些基本凭据。请注意,我们在代码中以明文保存数据库密码。在生产环境中绝不应这样做,因为这是一个安全漏洞。我们这样定义密码仅限于本地测试。
还定义了一个名为
proxynet的自定义网络,所有这些服务都会加入该网络。这允许通过主机名进行服务间通信,而无需将所有服务暴露到主机机器的网络中。 -
-
要启动这些容器,请运行以下命令:
docker-compose up -d所有容器应在几分钟内启动。
-
现在,我们将持续向我们的 Postgres 数据库插入一些模拟数据。为此,创建一个新的 Python 文件,命名为
make_fake_data.py。代码可以在github.com/PacktPublishing/Bigdata-on-Kubernetes/tree/main/Chapter07/connect/simulations文件夹中找到。此代码为客户生成假数据(如姓名、地址、职业和电子邮件),并将其插入到数据库中。要使其正常工作,您应安装faker、pandas、psycopg2-binary和sqlalchemy库。在运行代码之前,请确保通过pip install安装它们。本书的 GitHub 仓库中提供了一个requirements.txt文件和代码。 -
现在,在终端中运行模拟,输入以下命令:
python make_fake_data.py这将打印出模拟参数(生成间隔、样本大小和连接字符串)到屏幕,并开始打印模拟数据。经过几次模拟后,可以通过按 Ctrl + C 停止它。然后,使用你喜欢的 SQL 客户端(例如 DBeaver)检查数据是否已正确导入数据库。运行简单的 SQL 语句(
select * from customers)查看数据是否在 SQL 客户端中正确显示。 -
现在,我们将注册一个源 JDBC 连接器来从 Postgres 拉取数据。此连接器将作为 Kafka Connect 进程运行,建立一个到源数据库的 JDBC 连接。它使用该连接执行 SQL 查询,从特定表中选择数据。连接器将结果集转换为 JSON 文档,并将其发布到配置的 Kafka 主题。每个表都有一个专门为其创建的主题。提取数据的查询可以是简单的
SELECT语句,也可以是基于时间戳或数字列的增量查询。这使我们能够捕捉到新增或更新的行。 -
首先,我们将定义一个配置文件,以便在 Kafka Connect 上部署连接器。创建一个名为
connectors的文件夹,并创建一个名为connect_jdbc_pg_json.config的新文件。配置代码如下所示:connect_jdbc_pg_json.config
{ "name": "pg-connector-json", "config": { "connector.class": "io.confluent.connect.jdbc.JdbcSourceConnector", "value.converter": "org.apache.kafka.connect.json.JsonConverter", "value.converter.schemas.enable": "true", "tasks.max": 1, "connection.url": "jdbc:postgresql://postgres:5432/postgres", «connection.user»: «postgres», «connection.password»: «postgres», «mode»: «timestamp», "timestamp.column.name": "dt_update", "table.whitelist": "public.customers", "topic.prefix": "json-", "validate.non.null": "false", "poll.interval.ms": 500 } }此配置创建了一个 Kafka 连接器,将基于行的时间戳变化将
customers表的行同步到 JSON 格式的 Kafka 主题。我们来更详细地了解所使用的参数:-
name: 为连接器指定一个名称,以便于管理。 -
connector.class: 指定使用 Confluent 提供的 JDBC 连接器类。 -
value.converter: 指定数据将在 Kafka 中转换为 JSON 格式。 -
value.converter.schemas.enable: 启用将架构与 JSON 数据一起存储。 -
tasks.max: 限制为一个任务。此参数可以根据生产环境的扩展性需求,在主题的分区数不同的情况下增加。 -
connection.url: 连接到本地的 PostgreSQL 数据库,端口为5432。 -
connection.user/.password: PostgreSQL 凭证(在本示例中为明文,凭证绝不应硬编码)。 -
mode: 指定使用时间戳列来检测新增/更改的行。你也可以使用id列。 -
timestamp.column.name: 查看dt_update列。 -
table.whitelist: 指定同步customers表。 -
topic.prefix: 输出的主题将以json-为前缀。 -
validate.non.null: 允许同步包含 null 值的行。 -
poll.interval.ms: 每 500 毫秒检查一次新数据。
-
-
现在,我们将创建一个 Kafka 主题来存储来自 Postgres 表的数据。在终端中,输入以下内容:
docker-compose exec broker kafka-topics --create --bootstrap-server localhost:9092 --partitions 2 --replication-factor 1 --topic json-customers请注意,我们正在使用
docker-composeAPI 在容器内执行命令。命令的第一部分(docker-compose exec broker)告诉 Docker 我们希望在docker-compose.yaml文件中定义的broker服务中执行某些操作。其余命令将在 broker 内部执行。我们正在创建一个名为json-customers的主题,具有两个分区和一个副本因子(每个分区一个副本)。你应该在终端中看到主题创建的确认消息。 -
接下来,我们将使用简单的 API 调用来注册连接器到 Kafka Connect。我们将使用
curl库来实现。请在终端中输入以下命令:curl -X POST -H "Content-Type: application/json" --data @connectors/connect_jdbc_pg_json.config localhost:8083/connectors curl localhost:8083/connectors连接器的名称应在终端中打印出来。
-
现在,快速检查 Connect 实例的日志:
docker logs connect向上滚动几行,你应该会看到连接器注册的输出。
-
现在,让我们尝试一个简单的控制台消费者,只是验证消息是否已经被迁移到主题中:
docker exec -it broker bash kafka-console-consumer --bootstrap-server localhost:9092 --topic json-customers --from-beginning你应该在屏幕上看到以 JSON 格式打印的消息。按 Ctrl + C 停止消费者,然后输入
exit退出容器。 -
现在,我们将配置一个 sink 连接器,将这些消息传输到 Amazon S3。首先,去 AWS 创建一个新的 S3 存储桶。S3 存储桶的名称必须在所有 AWS 中唯一。因此,我建议将其设置为账户名称作为后缀(例如,
kafka-messages-xxxxxxxx)。在
connectors文件夹中,创建一个名为connect_s3_sink.config的新文件:connect_s3_sink.config
{ "name": "customers-s3-sink", "config": { "connector.class": "io.confluent.connect.s3.S3SinkConnector", "format.class": "io.confluent.connect.s3.format.json.JsonFormat", "keys.format.class": "io.confluent.connect.s3.format.json.JsonFormat", "key.converter": "org.apache.kafka.connect.json.JsonConverter", "value.converter": "org.apache.kafka.connect.json.JsonConverter", "key.converter.schemas.enable": false, "value.converter.schemas.enable": false, "flush.size": 1, "schema.compatibility": "FULL", "s3.bucket.name": "<YOUR_BUCKET_NAME>", "s3.region": "us-east-1", "s3.object.tagging": true, "s3.ssea.name": "AES256", "topics.dir": "raw-data/kafka", "storage.class": "io.confluent.connect.s3.storage.S3Storage", "tasks.max": 1, "topics": "json-customers" } }让我们来熟悉一下这个连接器的参数:
-
connector.class:指定要使用的连接器类。在这种情况下,它是 Confluent S3 sink 连接器。 -
format.class:指定写入 S3 时使用的格式。这里,我们使用JsonFormat,使数据以 JSON 格式存储。 -
key.converter和value.converter:分别指定用于将键和值序列化为 JSON 的转换器类。 -
key.converter.schemas.enable和value.converter.schemas.enable:禁用键和值的模式验证。 -
flush.size:指定连接器在执行刷新操作之前应等待的记录数。这里,这个参数设置为1。然而,在生产环境中,当消息吞吐量较大时,最好将此值设置得更高,以便更多的消息在一个文件中一起传输到 S3。 -
schema.compatibility:指定要使用的模式兼容性规则。这里,FULL表示模式必须完全兼容。 -
s3.bucket.name:要写入数据的 S3 存储桶的名称。 -
s3.region:S3 存储桶所在的 AWS 区域。 -
s3.object.tagging:启用 S3 对象标签。 -
s3.ssea.name:要使用的服务器端加密算法(在这种情况下是 AES256,即 S3 管理的加密)。 -
topics.dir:指定在 S3 存储桶中写入数据的目录。 -
storage.class:指定底层存储类别。 -
tasks.max:此连接器的最大任务数。对于一个接收器,这通常应为1。 -
topics:以逗号分隔的主题列表,用于获取数据并写入 S3。
-
-
现在,我们可以注册接收器连接器。在你的终端中输入以下命令:
curl -X POST -H "Content-Type: application/json" --data @connectors/connect_s3_sink.config localhost:8083/connectors
使用 docker logs connect 查看日志,以验证连接器是否已正确注册,并且在部署过程中没有错误。
就这样!你可以检查 AWS 上的 S3 桶,查看 JSON 文件的传输情况。如果你愿意,可以再次运行 make_fake_data.py 模拟器,查看更多消息传递到 S3。
现在你已经知道如何设置实时消息传递管道,让我们通过 Apache Spark 向其中加入一些实时处理功能。
使用 Kafka 和 Spark 进行实时数据处理
实时数据管道的一个非常重要的部分是实时处理。随着来自各种来源(如用户活动日志、物联网传感器等)的数据不断生成,我们需要能够在这些数据流上进行实时转换。
Apache Spark 的结构化流模块提供了一个高层 API,用于处理实时数据流。它建立在 Spark SQL 的基础上,使用类似 SQL 的操作提供丰富的流处理。Spark 结构化流通过微批处理模型来处理数据流。在这个模型中,流数据会被接收并收集成小批次,通常在毫秒级别内非常快速地处理。这提供了低延迟处理,同时保留了批处理的可扩展性。
我们将从使用 Kafka 启动的实时管道中提取数据,并在其上构建实时处理。我们将使用 Spark 结构化流模块来实现这一点。创建一个名为 processing 的新文件夹,并在其中创建一个名为 consume_from_kafka.py 的文件。处理数据并聚合结果的 Spark 代码已提供在此。
该代码也可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Bigdata-on-Kubernetes/blob/main/Chapter07/connect/processing/consume_from_kafka.py。这个 Spark 结构化流应用程序正在从 json-customers Kafka 主题读取数据,转换 JSON 数据,并在计算聚合后将结果打印到控制台:
consume_from_kafka.py
from pyspark.sql import SparkSession
from pyspark.sql import functions as f
from pyspark.sql.types import *
spark = (
SparkSession.builder
.config("spark.jars.packages", "org.apache.spark:spark-sql-kafka-0-10_2.12:3.1.2")
.appName("ConsumeFromKafka")
.getOrCreate()
)
spark.sparkContext.setLogLevel('ERROR')
df = (
spark.readStream
.format('kafka')
.option("kafka.bootstrap.servers", "localhost:9092")
.option("subscribe", "json-customers")
.option("startingOffsets", "earliest")
.load()
)
首先,创建并配置 SparkSession 来使用 Kafka 连接器包。设置错误日志以减少噪声,并方便在终端中可视化输出。接下来,使用 kafka 源从 json-customers 主题读取数据,创建一个 DataFrame。它连接到本地 Kafka,开始从最早的偏移量读取数据,并将每条消息的负载表示为字符串:
schema1 = StructType([
StructField("schema", StringType(), False),
StructField("payload", StringType(), False)
])
schema2 = StructType([
StructField("name", StringType(), False),
StructField("gender", StringType(), False),
StructField("phone", StringType(), False),
StructField("email", StringType(), False),
StructField("photo", StringType(), False),
StructField("birthdate", StringType(), False),
StructField("profession", StringType(), False),
StructField("dt_update", LongType(), False)
])
o = df.selectExpr("CAST(value AS STRING)")
o2 = o.select(f.from_json(f.col("value"), schema1).alias("data")).selectExpr("data.payload")
o2 = o2.selectExpr("CAST(payload AS STRING)")
newdf = o2.select(f.from_json(f.col("payload"), schema2).alias("data")).selectExpr("data.*")
这第二个代码块定义了两个模式——schema1捕获了 Kafka 有效载荷中预期的嵌套 JSON 结构,具有模式字段和有效载荷字段。另一方面,schema2定义了包含在有效载荷字段中的实际客户数据模式。
从初始 DataFrame 中提取代表原始 Kafka 消息有效载荷的值字符串字段。使用定义的schema1将此字符串有效载荷解析为 JSON,仅提取有效载荷字段。然后使用schema2再次解析有效载荷字符串,以提取实际的客户数据字段到名为newdf的新 DataFrame 中:
query = (
newdf
.withColumn("dt_birthdate", f.col("birthdate"))
.withColumn("today", f.to_date(f.current_timestamp()))
.withColumn("age", f.round(
f.datediff(f.col("today"), f.col("dt_birthdate"))/365.25, 0)
)
.groupBy("gender")
.agg(
f.count(f.lit(1)).alias("count"),
f.first("dt_birthdate").alias("first_birthdate"),
f.first("today").alias("first_now"),
f.round(f.avg("age"), 2).alias("avg_age")
)
)
现在,转换发生了——birthdate字符串被转换为date,获取当前日期,并使用datediff计算年龄。按性别聚合数据以计算计数、数据中最早的出生日期、当前日期和平均年龄:
(
query
.writeStream
.format("console")
.outputMode("complete")
.start()
.awaitTermination()
)
最后,聚合的 DataFrame 以追加输出模式写入控制台使用 Structured Streaming。此查询将持续运行,直到通过运行Ctrl + C终止。
要运行查询,在终端中输入以下命令:
spark-submit --packages org.apache.spark:spark-sql-kafka-0-10_2.12:3.1.2 processing/consume_from_kafka.py
你应该在终端中看到聚合的数据。打开另一个终端并通过运行以下命令来运行更多模拟:
python simulations/make_fake_data.py
当新的模拟数据生成并被注入到 Postgres 中时,Kafka 连接器将自动将其拉取到json-customers主题,此时 Spark 将拉取这些消息,实时计算聚合结果并打印输出。一段时间后,你可以按下Ctrl + C来停止模拟,然后再次按下来停止 Spark 流查询。
恭喜!你使用 Kafka 和 Spark 运行了一个实时数据处理管道!记得使用docker-compose down清理创建的资源。
总结
在本章中,我们介绍了 Apache Kafka 背后的基本概念和架构——这是一个用于构建实时数据管道和流式应用程序的流行开源平台。
你了解了 Kafka 如何通过其主题和代理架构提供分布式、分区、复制和容错的 PubSub 消息传递。通过实际示例,你获得了使用 Docker 设置本地 Kafka 集群、创建主题以及生成和消费消息的实际经验。你理解了偏移量和消费者组,这些使得从主题进行容错和并行消费成为可能。
我们介绍了 Kafka Connect,它允许我们在 Kafka 和外部系统(如数据库)之间流动数据。你实现了一个源连接器,用于将 PostgreSQL 数据库中的更改摄入到 Kafka 主题中。我们还设置了一个接收器连接器,以实时方式将 Kafka 消息传递到 AWS S3 中的对象存储。
亮点是使用 Kafka 和 Spark Structured Streaming 构建端到端的流数据管道。您学到了如何通过流数据上的微批处理实现低延迟,同时保持可扩展性。提供的示例展示了如何从 Kafka 中消费消息,使用 Spark 进行转换,并实时聚合结果。
通过这些实践练习,您获得了使用 Kafka 架构和功能的实际经验,以构建强大且可扩展的流数据管道和应用程序。企业可以通过利用 Kafka 来满足其实时数据处理需求,从而推动及时的洞察和行动,获得巨大收益。
在下一章中,我们将最终将我们迄今为止所学的所有技术引入 Kubernetes。您将学习如何在 Kubernetes 中部署 Airflow、Spark 和 Kafka,并使它们准备好构建一个完全集成的数据管道。
第三部分:将一切连接起来
在这一部分中,您将学习如何在 Kubernetes 上部署和编排前几章中介绍的大数据工具和技术。您将构建脚本来在 Kubernetes 集群上部署 Apache Spark、Apache Airflow 和 Apache Kafka,使它们能够分别执行数据处理作业、编排数据管道并处理实时数据摄取。此外,您还将探索数据消费层、数据湖引擎(如 Trino)以及使用 Elasticsearch 和 Kibana 进行的实时数据可视化,所有这些都将在 Kubernetes 上部署。最后,您将通过在 Kubernetes 集群上构建和部署两个完整的数据管道来将一切连接起来,一个用于批处理,另一个用于实时处理。本部分还涵盖了在 Kubernetes 上部署生成式 AI 应用程序,并为您在 Kubernetes 和大数据之旅中下一步的行动提供指导。
本部分包含以下章节:
-
第八章,在 Kubernetes 上部署大数据栈
-
第九章,数据消费层
-
第十章,在 Kubernetes 上构建大数据管道
-
第十一章,Kubernetes 上的生成式 AI
-
第十二章,从这里开始
3923

被折叠的 条评论
为什么被折叠?



