为了账号安全,请及时绑定邮箱和手机立即绑定

使用DuckDB构建高性能数据处理管道

这篇文章最初发表在我的_数据工程实践_中.

DuckDB,一个高性能且可嵌入的分析引擎,因其轻量级的安装和配置以及强大的功能和能力,已经引起了大量的关注。

在最近的一篇文章《DuckDB 超越炒作》(https://practicaldataengineering.substack.com/p/duckdb-beyond-the-hype)中,我探讨了它的各种使用场景,并简要展示了它在数据工程和数据科学工作流程中的应用。

一个特别引起读者共鸣的用例是使用DuckDB,一个开源数据库,对数据湖中的数据进行转换和序列化处理。受到一些读者反馈的启发,我决定写这篇后续文章,深入探讨这个用例,并提供一个完整的代码示例。

本文,我通过展示一个高层次的用例场景及其示例代码,演示了如何在数据湖的不同区域间移动数据,使用DuckDB作为计算引擎。

为了保持重点在核心概念,只包含了简短的代码示例,但完整的实现代码可以在GitHub上找到,供有兴趣深入了解的人查看。

项目概况

为了简单起见,我将该项目实现为一个纯Python应用程序,依赖最少的外部库。唯一的外部依赖是一个用于数据湖实现的云对象存储服务,它不必是AWS S3,而是任何兼容S3的云对象存储服务。或者,你可以使用LocalStack在本地模拟数据湖。

我们将要探索的用例涉及逐步积累GitHub归档数据集,这些数据集包含了公共仓库的GitHub活动的完整记录,并在此基础上进行数据分析

数据湖

我们将使用一种称为 Medallion 架构的多层架构。这种架构也是一种数据湖设计模式,将数据划分成三个区域。

  • 青铜区:包含从各种来源摄入的原始未处理数据。
  • 白银区:包含已清洗、标准化并可能经过建模的数据。
  • 黄金区:包含聚合和整理好的数据,适合用作报告、仪表板和高级分析的来源。

数据湖泊架构指的是……

通过维护这种多区域架构(青铜 → 银 → 金区),我们确保在处理的不同阶段能够访问各种数据——从用于详细分析的原始数据到用于快速洞察的汇总数据的不同阶段。这种灵活性使我们能够满足各种不同的数据分析需求,同时优化存储和查询效率。

分区计划

每个区域都将采用适当的分区方案来优化数据导入和查询性能,从而提高整体效率。

通常,这种设计与处理批处理任务时的数据输入和转换频率相匹配。这种做法确保了在各个分区上保持一致。

换句话说,各个任务运行之间没有重叠,这意味着每个任务都是独立的。如果流水线失败或数据中检测到异常,我们可以安全地重新运行特定时间段的任务,不会对其他部分造成影响。流水线将只替换受影响的分区及其内容,避免任何负面影响、数据重复或不一致问题。

这种方法经常被称为利用不可变分区的功能数据处理。它是由马克西姆·布歇梅因推广的,他在2018年发表了一篇广受好评的博客文章,介绍了这种强大的数据处理模式。

按照功能数据处理的模式,我们将每小时从源系统中获取数据,并在青铜区按天和小时划分数据。每个这样的任务都会原子性地写入各自分配的分区,确保在这一细化层级的数据一致性。

在银区,我们也将采用相同的分区方式,因为从青铜到银的转换也会每小时进行一次。

在金区中,分区仅按天来进行,因为聚合任务每天运行。该任务会处理前一天的银区数据,并生成相应的结果。

这种方法在性能和存储之间找到了完美的平衡点——在早期阶段采用细粒度分区,最终使用更高层次的聚合。

如下图所示,这三个区域的分区方案是这样的。

数据湖泊, 分区方法

数据管道体系

整个数据流程可以分为三个关键步骤:

第一步 — 从HTTP获取每小时的GitHub Archive数据集,并将其导入我们的数据湖的青铜层中。

步骤 #2 — 每小时运行一次转换流程来清理并序列化 JSON 数据文件,将结果加载到银区数据。

步骤3 — 运行每日的数据转换管道,将前一天的数据聚合并存储到金区。

数据管道的设计

非常感谢您的阅读,希望您喜欢《实用数据工程》一书!免费订阅,支持我的工作并接收新文章。

看看数据来源

在我们动手搭建数据流水线之前,我们应该先了解和探索数据源、其特点以及数据的形态。

用Jupyter来探索数据是一种很好的方式。DuckDB还能帮助你在不下载整个数据集到本地电脑的情况下远程探索数据。

源数据可以从gharchive.org获取,数据集定期提供,例如每小时和每日。

以下示例展示了如何使用DuckDB来分析一个样本_gharchive_数据文件(dump文件)。使用DuckDB,您可以直接在 URL 上定义一个 虚拟 表,从而可以直接查询和分析数据,无需先下载文件到本地。

数据摄取 — 🥉 青铜层

步骤 1 — 数据导入

为了实现我们的数据摄入管道,我们需识别源系统接口、协议和数据类型,因为这将指导我们的方法。下面是我们这个用例的详细说明。

根据上述需求,我们需要通过HTTP从gharchive.org服务器下载每小时的压缩JSON文件。

一个直接的方法是使用 requests 库(库)从源头流式传输文件并将其缓存,然后使用 boto3 库(库)将文件上传至 S3,上传至数据湖的青铜层级。

这是一个简单的收集和发布数据的例子。

导入 requests 库

设置 gharchive_url 为 "http://data.gharchive.org/2024-09-01-10.json.gz"

发送一个 GET 请求到 gharchive_url 并将响应存储在 response 中

将 response 的内容赋值给 response_content

将数据上传至S3:

import boto3  

s3_client = boto3.client(  
  's3',  
  aws_access_key_id="your-aws-access-key-id",  
  aws_secret_access_key="your-aws-access-key",  
  region_name="区域名称"  
)  
target_s3_key="gharchive/events/2024-09-01-10.json.gz"  
s3_client.upload_fileobj(io.BytesIO(response_content), "datalake-bronze", target_s3_key)

虽然这个例子虽然适合测试,但它只是一个基本实现。要构建一个稳健的数据流,我们需要更好的参数化、错误处理和模块化功能。

为了这个,我写了一个名为 data_lake_ingestor.py 的脚本,该脚本从 GitHub Archive 获取特定小时的数据。它使用 Python 的 requests 库将压缩的 JSON 文件下载到内存里。接着将数据直接上传到指定的 S3 存储桶,S3 键根据日期和小时生成。

要运行摄入管道,我们只需要向摄入函数ingest_hourly_gharchive()传入一个时间戳,时间戳将用于确定要收集和加载的JSON数据文件的时间段。

    从 datetime 模块导入 datetime, timedelta  
    从 data_lake_ingester 导入 DataLakeIngester   

    ingester = DataLakeIngester("gharchive/events")  
    now = datetime.utcnow()  
    # 计算 process_date(比当前时间早 1 小时以确保数据源数据已经准备好)  
    process_date = now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)  
    # 处理 process_date 对应的每小时数据  
配置管理和秘密处理

配置项,如 AWS 认证信息和桶名,存储在一个配置文件“config.ini”中,以使代码不包含任何敏感或静态数据。

    [aws]  
    s3_access_key_id = 你的访问密钥在这里  
    s3_secret_access_key = 你的秘密访问密钥在这里  
    s3_region_name = 你的区域名在这里  
    s3_endpoint_url = 你的自定义端点网址在这里  

    [datalake]  
    bronze_bucket = 青铜区的桶  
    silver_bucket = 白银区的桶  
    gold_bucket = 黄金区的桶

在代码里,我们利用 Python 的 configparser 库将这些配置加载到 Config 类里,如下面的私有方法所示。

    def _加载配置(self):  
      config = configparser.ConfigParser()  
      config_path = os.path.join(os.path.dirname(__file__), 'config.ini')  
      config.read(config_path)  
      return config
原始数据的序列化 — 银奖区

第2步 — 数据序列化过程

在将原始的GitHub Archive数据导入到数据湖的Bronze层之后,流程中的下一个关键步骤是清理和序列化这些数据,以便为Silver层做准备。这时,DuckDB就派上用场了,在数据湖中进行必要的数据处理。

转换逻辑被封装在名为 DataLakeTransformer() 的类中,该类位于 _data_lake_transformer.py 文件内。此类提供了两个主要方法:serialise_raw_data() 用于数据的清理和序列化,以及 aggregate_silver_data() 用于聚合银数据。

咱们来看看序列化过程中的逻辑。

    def serialise_raw_data(self, process_date: datetime) -> None:  
      try:  
        ...  
        gharchive_raw_result = self.register_raw_gharchive(source_path)  
        gharchive_clean_result = self.clean_raw_gharchive(gharchive_raw_result.alias)  
        ...  
        gharchive_clean_result.write_parquet(sink_path)  
      except Exception as e:  
        logging.error(f"出现错误:{str(e)}")  
        raise

这种方法包括几个关键步骤:

源和汇的配置: 它根据配置文件(config.ini)中的参数设定源桶(青铜)和汇桶(银)的名称。

数据导入: 使用DuckDB的关系API将原始JSON数据导入内存表的逻辑如下:

    def 导入原始GitHub存档(self, source_path) -> duckdb.DuckDBPyRelation:  
      self.con.execute(f"CREATE OR REPLACE TABLE gharchive_raw \  
                        AS FROM read_json_auto('{source_path}', \  
                        ignore_errors=True)")  
      return self.con.table("gharchive_raw")

该方法返回一个 duckdb.DuckDBPyRelation (一种对象,用于关联引用内存中的表) 对象,充当内存中表的关联引用。这样就确保了后续步骤操作的是内存中的数据,而不是重复从源文件读取数据。

这里的关键点在于ignore_errors=true参数。DuckDB会从最初的几条记录中推断模式,对于大型数据集,如果模式不统一(比如有些记录包含额外的嵌套属性),就可能遇到错误。

通过将 ignore_errors=true,DuckDB 会跳过那些不符合推断模式的记录,这对我们的情况来说效率很高,因为我们不需要一些记录中包含的可选字段。否则,我们可以扫描更多的条目或提供一个明确的模式定义,但这样会对我们正在处理的大文件带来显著的额外工作。

数据模型

在我们将原始数据转换为 Parquet 格式之前,我们需要设计数据模型,并只保留我们感兴趣的字段。为此,我们可以使用 DuckDB SQL 来实现数据建模,如下所示的方法。

下面的方法中包含的 SQL 查询的结果也被存储在一个内存中的表里,确保后续多次调用不会重复执行 SQL 逻辑。

    def 清洗并整理GitHub原始数据(self,原始数据集) -> duckdb.DuckDBPyRelation:  
      SQL查询 = f'''  
      SELECT  
      id AS "event_id",  
      actor的id AS "user_id",  
      actor的login AS "user_name",  
      actor的display_login AS "user_display_name",  
      type AS "event_type",  
      repo.id AS "repo_id",  
      repo.name AS "repo_name",  
      repo.url AS "repo_url",  
      创建时间 AS "event_date"  
      FROM '{ 原始数据集 }'  
      '''  
      self.con.execute(f"CREATE OR REPLACE TABLE gharchive_clean AS ({SQL查询})")  
      return self.con.table("gharchive_clean")

DuckDB 提供了强大的功能来处理嵌套的 JSON 文件,比如 GitHub Archive 数据集中的那些文件。其中一个关键优势是能够使用点符号直接访问嵌套属性,就像在 GitHub Archive 数据集中的 JSON 文件里一样。以及使用如 unnest() 这样的函数来完全展开查询中的嵌套结构,使其更易于处理。

例如,如果我们想展开并提取出 actor 对象内的所有属性,我们可以使用一个简单的查询即可。

query=f"SELECT UNNEST(actor),.... FROM '{raw_dataset}'"

这种方法让处理复杂且多层嵌套的数据变得轻松,同时使查询保持简洁。

数据导出部分: 在数据清洗完成后,使用DuckDB引擎将结果以Parquet格式写入到Silver层:

将处理好的 gharchive 数据写入到 sink_path 指定的路径中。

序列化性能优化

当在靠近数据的地方执行序列化过程并使用DuckDB进行转换处理时,整个过程不到一分钟就完成了。

这种效率使得DuckDB成为轻量级就地数据转换的绝佳选择,特别是在与本地或基于云的对象存储服务(如S3)配合使用时。

2024-10-01 15:41:04,365 - INFO - DuckDB - 收集源数据文件:s3://datalake-bronze/gharchive/events/2024-10-01/15/*  
100% ▕████████████████████████████████████████████████████████████▏ 完成  
2024-10-01 15:41:29,892 - INFO - DuckDB - 清理数据  
2024-10-01 15:41:30,129 - INFO - DuckDB - 序列化并导出清理后的数据到 s3://datalake-silver/gharchive/events/2024-10-01/15/clean_20241001_15.parquet  
100% ▕████████████████████████████████████████████████████████████▏ 完成
主要要点

使用DuckDB来进行这个转换是一个关键的设计决定。

  • 内存中的处理: DuckDB 允许高效地对数据进行内存中的处理,这在处理通常非常大的 GitHub Archive 数据集时特别有用。
  • SQL 接口: 使用 SQL 进行数据建模提供了一个熟悉且强大的数据转换接口,使用户能够轻松进行数据转换。
  • Parquet 写入功能: DuckDB 具有高效的 Parquet 读写器,可以快速高效地将原始数据(如 JSON 和 CSV 格式)转换为 Parquet 格式,同时省去了中间步骤和额外库的使用。
数据聚合 — 🥇 黄金专区

第 3 步——数据汇总

在将GitHub Archive的原始数据建模并整理到Silver zone之后,数据管道的下一步是每天将这些数据汇总并发布到Gold zone。

以下是对负责每日聚合的方法的概述。

    def aggregate_silver_data(self, process_date: datetime) -> None:  
      try:  
        ...  
        gharchive_agg_result = self.aggregate_raw_gharchive(source_path)  
        ...  
        gharchive_agg_result.write_parquet(sink_path)     
      except Exception as e:  
        logging.error(f"在处理 aggregate_silver_data 时出现错误: {str(e)}")  
        raise

这种方法有几个关键的步骤:

源和目标配置: 它根据配置来确定源头(Silver)和接收端(Gold)桶名。

数据加载与聚合: 聚合逻辑是通过在SQL中定义的,该SQL应用于在Silver区域的Parquet文件上定义的DuckDB虚拟表。此聚合侧重于例如按类型(例如,点赞、拉取请求)、仓库和日期对GitHub事件进行统计,提供了一个每日时间窗口内的GitHub活动汇总视图。

定义 aggregate_raw_gharchive(self, raw_dataset) -> duckdb.DuckDBPyRelation:  # 定义函数aggregate_raw_gharchive
    query = f'''  
        SELECT   
            event_type,  # 事件类型
            repo_id,  # 仓库ID
            repo_name,  # 仓库名称
            repo_url,  # 仓库URL
            DATE_TRUNC('day',CAST(event_date AS TIMESTAMP)) AS event_date,  # 将事件日期转化为日期格式
            count(*) AS event_count  # 计算事件数量
        FROM '{raw_dataset}'  
        GROUP BY event_type, repo_id, repo_name, repo_url, event_date  # 按照这些字段进行分组,注意在中文SQL中没有ALL关键字
    '''  
    self.con.execute(f"创建或替换表 gharchive_agg 作为从 ({query})")  # 创建或替换表gharchive_agg
    return self.con.table("gharchive_agg")

在 DuckDB 中,GROUP BY ALL 功能通过省略显式指定列来简化 group by 语句,这样更方便。

与之前的转换步骤一样,我们将这个聚合的结果保存到内存中的DuckDB表中,并返回一个 DuckDBPyRelation 对象,以确保后续调用不会重复执行SQL逻辑。

数据导出部分: 聚合后的数据随后以Parquet格式写入金层,如下。

    // gharchive_agg_result.write_parquet(sink_path)
    将 gharchive_agg_result 写入 parquet 格式并保存到 sink_path。
聚合表现

运行在云端虚拟机上的转换管道在不到一分钟的时间内聚合了24个包含近600万条记录的Parquet文件,并将结果序列化为一个发布在金区的Parquet文件。

这种效率展示了DuckDB在处理从小规模到中等规模的数据转换时的高效且快速的能力,表现出色。

2024-10-01 00:31:42.787 - 日志信息 - DuckDB - 聚合处理 s3://datalake-silver/gharchive/events/2024-10-01/*/*.parquet 中的银级数据  
100% ▕████████████████████████████████████████████████████████████▏  
2024-10-01 00:31:53.020 - 日志信息 - DuckDB - 导出聚合数据到 s3://datalake-gold/gharchive/events/2024-10-01/agg_20241001.parquet  
100% ▕████████████████████████████████████████████████████████████▏
主要要点

使用DuckDB进行数据聚合过程具有以下几点优势:

  • 高效的处理: DuckDB 的列式存储和处理非常适合进行分析查询和聚合。
  • SQL 接口: 使用 SQL 进行 Python 中的数据聚合,提供了一个熟悉且强大的复杂数据转换接口。
  • 高效的 Parquet 集成: DuckDB 对 Parquet 文件的原生支持使得读写这种格式的数据更加高效。
编排和调度

在生产环境中,一般会使用如 Apache Airflow、Dagster 或 Prefect 等工作流编排器来管理本文中提到的这三个管道的执行过程。

然而,由于这里的目的是展示如何使用DuckDB来进行数据转换,我有意省略了任何外部编排和调度组件。相反,在GitHub项目中,我为每个管道提供了单独的脚本文件,并在README中提供了如何使用cron轻松调度这些脚本的说明。

说起来,你可以这样做。你可以轻松地将代码适配到你习惯的工作流编排器上。例如,在Airflow中,你可以这样做。你可以使用一个PythonOperator来调用每个步骤的函数,这些函数对应着管道中的每个阶段,通过导入相关的类文件。

这种代码优先的方法确保了您的业务逻辑和工作流逻辑分离,从而使管道更加灵活,易于移植到任何Python编排工具中。

交互的数据分析

一旦数据准备就绪用于分析,DuckDB强大的查询功能让我们能够轻松从聚合数据中获取有价值的见解,因此它成为交互式数据分析的绝佳选择。

以下是从我笔记本电脑上的一个Jupyter笔记本中提取的代码片段,展示了如何使用存储在金区的Parquet文件来分析特定日期的最星标仓库。

这段代码演示了DuckDB的Python API,并突出了其Pythonic的数据分析功能,这与Pandas和PySpark等其他DataFrame API相当。

在这篇文章中,我们探讨了如何利用DuckDB来实现数据湖架构中高效的数据批量转换和序列化(Serialization)。我们详细描述了从导入原始的GitHub Archive数据,将其转换为结构化格式,再到使用Medallion架构(青铜层 → 银层 → 金层)进行数据聚合分析的具体步骤。

DuckDB的内存处理、SQL 基础的转换能力以及与Parquet 文件的无缝集成使其成为处理这类数据集的高性能理想选择。

对于希望复制这种方法的人,你可以在GitHub找到完整的代码示例,这样你可以在自己的数据处理管道中探索DuckDB的潜力。

订阅我的实用数据工程_简报,获取我最新故事和数据工程见解的独家抢先体验。快来订阅吧!

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消