这篇文章最初发表在我的_数据工程实践_中.
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的潜力。
订阅我的实用数据工程_简报,获取我最新故事和数据工程见解的独家抢先体验。快来订阅吧!
共同学习,写下你的评论
评论加载中...
作者其他优质文章