DeepSeek总结的DuckLake 中的数据内联:为数据湖解锁流式处理
原文地址https://ducklake.select/2026/04/02/data-inlining-in-ducklake/DuckLake 中的数据内联为数据湖解锁流式处理Pedro Holanda2026-04-02 ·TL;DRDuckLake 的数据内联功能将小批量更新直接存储在目录中从而消除了“小文件问题”使持续流式写入数据湖变得切实可行。我们的基准测试显示与 Iceberg 相比查询速度快 926 倍数据写入速度快 105 倍。数据湖让用户能够避免被锁定在单一数据库中。它们通过将数据以开放格式最常见的是 Parquet存储来实现这一点。大多数数据湖如 Iceberg、Hudi 和 Delta也将其元数据即告诉您某个查询需要读取哪些文件的信息以 JSON 和 Avro 文件等开放格式存储。这意味着任何人都可以实现读取和写入这些格式的系统从而使用户免受单一商业解决方案的锁定。传统数据湖在存储数据时常常伴随着性能问题。问题的根源在于每次小规模写入都会创建一个新的数据文件并更新元数据。这导致存储中充斥着大量的小对象。在读取端查询现在需要遍历越来越多的元数据条目仅仅是为了弄清楚要扫描哪些文件。对于流式工作负载来说这些问题尤其痛苦因为它们会在长时间内执行大量小批量插入每次插入都会创建一个微小的 Parquet 文件和一些元数据文件。每秒一千次插入意味着成千上万这样的小文件不断累积导致性能下降。到了这一步您将被迫进行文件压缩这需要您安排和执行这些维护操作来维持数据湖的运行而维护作业执行时又会对性能造成更大的影响。DuckLake 颠覆了这一模式由于它使用数据库作为其目录它可以将小批量更新直接存储在目录中而不是立即将它们作为 Parquet 文件写入存储。我们将这种技术称为数据内联并将在本文中对其进行描述。示例流式传感器数据流式工作负载的典型例子是以固定间隔更新的传感器数据。作为一个实际例子考虑我们向数据湖插入 100 条观测数据的情况。[点击查看创建传感器数据表的示例脚本]frompyiceberg.catalogimportload_catalogfromdatetimeimportdatetimeimportpyarrowaspa catalogload_catalog(default,**{type:sql,uri:sqlite:///catalog.db,warehouse:file://warehouse,})catalog.create_namespace_if_not_exists(default)schemapa.schema([(sensor_id,pa.int32()),(temperature,pa.float64()),(ts,pa.timestamp(us)),])tablecatalog.create_table(default.readings,schemaschema)foriinrange(100):batchpa.table({sensor_id:pa.array([1],typepa.int32()),temperature:[21.5],ts:[datetime.now()],})table.append(batch)在传统数据湖中每次插入都会创建自己的 Parquet 文件以及相关的元数据文件。运行上面的示例会创建超过 300 个元数据文件200 个 Avro 文件和 101 个 JSON 文件以及 100 个 Parquet 文件。[点击查看 pyiceberg 脚本创建的目录结构]tree warehouse warehouse └── default └── readings ├── data │ ├── 00000-0-01a02dc3-deec-4be5-ab0f-5582e926419a.parquet │ ├── ... └── metadata ├── 00000-0a837115-bff0-4fd7-a3a0-51df4e0b5764.metadata.json ├── ... ├── 01a02dc3-deec-4be5-ab0f-5582e926419a-m0.avro ├── ... ├── snap-1086579672596758615-0-288b7b1d-f159-4692-9404-b2dba114fba2.avro └── ... 5 directories, 401 files如此多的文件使数据层和元数据层都变得臃肿对查询性能和存储账单产生巨大影响。这就是所谓的“小文件问题”。数据湖系统解决这个问题的方法是实施定期的压缩作业将小文件合并成大文件以减少 I/O 成本。但这些压缩例程并没有在写入时解决问题创建所有这些小文件的代价仍然存在并且在它们实际运行之前它们对查询性能没有帮助。DuckLake 采用了一种根本不同的方法。由于目录由用户选择的数据库管理DuckLake 可以将小批量更新、插入和删除直接存储在目录中而不是将它们写为文件。数据库系统几十年来一直专注于高效处理这类小规模读写因此它们天然适合这种工作负载。内联功能还旨在与数据湖的时间旅行特性完全集成。拿上面相同的传感器工作负载在 SQLite 作为目录数据库的情况下针对 DuckLake 运行。[点击查看将数据加载到 DuckLake 的 Python 脚本]importduckdb conduckdb.connect()con.execute(ATTACH ducklake:sqlite:sensors.ducklake AS lake (DATA_PATH sensor_data/))con.execute( CREATE TABLE lake.readings ( sensor_id INTEGER, temperature DOUBLE, ts TIMESTAMP ) )foriinrange(100):con.execute(fINSERT INTO lake.readings VALUES (1, 21.5, now()))# How many Parquet files were created?print(con.execute(SELECT count(*) FROM glob(sensor_data/*.parquet)).fetchone()[0])# 0 -- zero files, everything is inlined in the catalog插入 100 次后我们得到零个 Parquet 文件。所有数据都存在于目录中查询完全按预期工作。下图描绘了在我们的传感器工作负载下传统数据湖与 DuckLake 之间的区别。在 DuckLake 中数据存在于目录中并且在将其刷新到对象存储后只创建一个包含所有数据的 Parquet 文件。[Iceberg 与 DuckLake 在 100 次单行插入后的对比图。Iceberg 创建了 100 个 Parquet 文件和 100 个元数据快照。DuckLake 将所有数据内联存储在目录中产生零个 Parquet 文件并在刷新后将所有内容合并到一个 Parquet 文件中。]在本文的其余部分我们将对 DuckLake 在高竞争流式工作负载下启用和未启用内联功能进行基准测试比较并介绍内联功能在底层是如何工作的。流式基准测试为了了解流式处理在 DuckLake 中的影响我们设计了一个模拟自动驾驶汽车流式传感器数据的基准测试。该基准测试包含一个表其中有 23 个不同类型的列例如ts作为传感器时间戳speed_mps作为表示米/秒的浮点数。插入速率为每秒 100 行分 10 个批次进行每批 10 行。所有插入完成后我们对表列运行 9 个聚合查询例如avg(speed_mps)、stddev(speed_mps)、min(speed_mps)。然后我们执行一个检查点根据系统的不同这会触发压缩、刷新和清理步骤。所有写入操作都由单个duckdb进程执行。我们模拟了 50 分钟的数据包含 300,000 行和 30,000 个批次。目录数据库是 Amazon RDS PostgreSQL 16.10运行在 EC2 c7g.2xlarge 实例上数据存储在同一区域的 S3 存储桶中。步骤无内联有内联性能提升插入1,964 秒375.0 秒5.2 倍聚合查询1,574 秒1.7 秒925.9 倍检查点30 秒2.1 秒14.5 倍使用内联功能后插入速度大约快 5 倍。将数据存储到 PostgreSQL 的往返成本远低于为每个批次将 Parquet 文件写入 S3 的成本。最引人注目的结果是聚合查询性能926 倍的差异。在没有内联的情况下每个查询都必须打开 S3 上所有 30,000 个独立的 Parquet 文件。而在内联情况下数据存在于 PostgreSQL 中查询直接针对它执行完全避免了数千次远程文件读取。对于检查点未内联的情况必须将 30,000 个 Parquet 文件压缩成一个而内联的情况只是将数据从 PostgreSQL 目录刷新到 S3 上的一个 Parquet 文件性能提升了 14.5 倍。我们需要谈谈 Iceberg我们还使用 pyiceberg 和 Apache Polaris 对 Iceberg 运行了基准测试这是在生产环境中管理 Iceberg 表的常见设置。Iceberg 处理 50 分钟的流式工作负载耗时过长因此我们将其缩减到仅 100 秒10,000 行总共 1,000 个批次。步骤Iceberg (Polaris)启用内联的 DuckLake性能提升插入1,148.77 秒10.88 秒105 倍聚合查询83.06 秒0.09 秒923 倍检查点52.83 秒0.28 秒189 倍启用内联的 DuckLake 在所有指标上都快了两个数量级在聚合查询上更是快了近三个数量级。这种差距源于架构Iceberg 在客户端和 PostgreSQL 之间多了一次 REST 跳转并且其快照模型为每个批次写入大约四个 S3 文件而启用内联的 DuckLake 写入零个。这解释了巨大的性能差异。我们尽力为所有系统创建了现实的设置。通过架构和设计更改例如在客户端缓冲写入或插入更大的批次来缓解小文件问题是可能的但这会牺牲 ACID 保证并限制多用户支持这违背了流式写入数据湖的大部分初衷。DuckLake 易于设置欢迎您尝试一下运行自己的工作负载亲自体验其中的差异。内联的工作原理如果您好奇底层发生了什么本节将介绍其内部原理。当您插入、删除或更新的行数低于内联阈值默认为 10时DuckLake 会将更改存储在目录数据库中而不是写入 Parquet 文件。该阈值可以在全局、模式或表级别进行更改-- 全局更改所有表的默认值SETducklake_default_data_inlining_row_limit50;-- 按表为特定表覆盖设置ALTERTABLElake.readingsSET(data_inlining_row_limit100);-- 完全禁用内联SETducklake_default_data_inlining_row_limit0;在实践中这意味着您可以放心地将数据流式传输到 DuckLake而无需担心小文件的激增。DuckLake 通过目录中的插入表和删除表来管理内联数据这些表由规范中的内部表跟踪。在查询时DuckLake 会无缝地将内联数据与任何现有的 Parquet 文件结合起来因此无论数据存在于何处查询总是能返回正确的结果。下面我们介绍每个操作的工作原理。插入当插入的数据量低于内联阈值时DuckLake 不会创建 Parquet 文件。相反它会将行直接存储在目录数据库中的一个内联数据表中。该表包含原始列加上三个元数据列row_id– 该行的标识符begin_snapshot– 插入该行的快照end_snapshot– 删除该行的快照如果仍然存在则为 NULL快照列让 DuckLake 即使对内联数据也能保持完整的时间旅行支持。让我们来看一个具体的例子。首先我们设置一个 DuckLake 目录并创建一个表ATTACHducklake:sensors.ducklakeASlake(DATA_PATHsensor_data/);CREATETABLElake.readings(sensor_idINTEGER,temperatureDOUBLE,tsTIMESTAMP);现在我们插入几个小批次每个批次都低于默认的 10 行阈值INSERTINTOlake.readingsVALUES(1,21.5,2025-03-27 10:00:00);INSERTINTOlake.readingsVALUES(2,22.1,2025-03-27 10:00:10);INSERTINTOlake.readingsVALUES(1,21.8,2025-03-27 10:00:20);这些插入操作都没有创建 Parquet 文件。相反所有三行数据都存在于目录数据库中的一个名为ducklake_inlined_data_table-id_schema-version的内联数据表中。如果我们窥视目录内部ATTACHsensors.ducklakeAScatalog_db;SELECT*FROMcatalog_db.ducklake_inlined_data_1_1;┌────────┬────────────────┬──────────────┬───────────┬─────────────┬─────────────────────┐ │ row_id │ begin_snapshot │ end_snapshot │ sensor_id │ temperature │ ts │ │ int64 │ int64 │ int64 │ int32 │ double │ timestamp │ ├────────┼────────────────┼──────────────┼───────────┼─────────────┼─────────────────────┤ │ 0 │ 2 │ NULL │ 1 │ 21.5 │ 2025-03-27 10:00:00 │ │ 1 │ 3 │ NULL │ 2 │ 22.1 │ 2025-03-27 10:00:10 │ │ 2 │ 4 │ NULL │ 1 │ 21.8 │ 2025-03-27 10:00:20 │ └────────┴────────────────┴──────────────┴───────────┴─────────────┴─────────────────────┘每次插入都创建了一个新的快照但没有创建新文件。所有行的end_snapshot都是 NULL因为还没有行被删除。注意begin_snapshot从 2 开始因为CREATE TABLE语句本身占用了快照 1。如果删除操作的目标是仍然内联的行DuckLake 会通过设置该行的end_snapshot列来就地处理。不会创建删除文件。例如DELETEFROMlake.readingsWHEREsensor_id2;┌────────┬────────────────┬──────────────┬───────────┬─────────────┬─────────────────────┐ │ row_id │ begin_snapshot │ end_snapshot │ sensor_id │ temperature │ ts │ │ int64 │ int64 │ int64 │ int32 │ double │ timestamp │ ├────────┼────────────────┼──────────────┼───────────┼─────────────┼─────────────────────┤ │ 0 │ 2 │ NULL │ 1 │ 21.5 │ 2025-03-27 10:00:00 │ │ 1 │ 3 │ 5 │ 2 │ 22.1 │ 2025-03-27 10:00:10 │ │ 2 │ 4 │ NULL │ 1 │ 21.8 │ 2025-03-27 10:00:20 │ └────────┴────────────────┴──────────────┴───────────┴─────────────┴─────────────────────┘传感器 2 的行现在有了end_snapshot 5意味着它在快照 5 中被删除了。常规查询会将其过滤掉但时间旅行查询仍然可以看到它。删除删除内联处理的是另一种情况删除已经存在于 Parquet 文件中的行。DuckLake 不会重写 Parquet 文件或创建单独的删除文件而是在目录中的一个按表划分的内联删除表中记录删除操作。该表跟踪哪些 Parquet 文件中的哪些行已被删除以及导致删除的快照。例如假设我们有一个data.parquet文件其中包含一些我们想添加到表中的传感器读数CALLducklake_add_data_files(lake,readings,data.parquet);SELECT*FROMlake.readings;┌───────────┬─────────────┬─────────────────────┐ │ sensor_id │ temperature │ ts │ │ int32 │ double │ timestamp │ ├───────────┼─────────────┼─────────────────────┤ │ 1 │ 20.0 │ 2025-03-27 09:00:00 │ │ 2 │ 19.5 │ 2025-03-27 09:00:10 │ │ 3 │ 21.2 │ 2025-03-27 09:00:20 │ │ 4 │ 18.8 │ 2025-03-27 09:00:30 │ └───────────┴─────────────┴─────────────────────┘现在如果我们从这个文件中删除一行DuckLake 不会重写 Parquet 文件。相反它会在目录中创建一个名为ducklake_inlined_delete_table-id的内联删除表DELETEFROMlake.readingsWHEREsensor_id3;SELECT*FROMcatalog_db.ducklake_inlined_delete_1;┌─────────┬────────┬────────────────┐ │ file_id │ row_id │ begin_snapshot │ │ int64 │ int64 │ int64 │ ├─────────┼────────┼────────────────┤ │ 0 │ 2 │ 6 │ └─────────┴────────┴────────────────┘这个条目告诉 DuckLake文件 0 中的第 2 行在快照 6 中被删除了。在查询时DuckLake 会从 Parquet 文件扫描中过滤掉这一行因此它永远不会出现在结果中。更新更新操作就是一次删除后跟一次插入因此它们遵循上述完全相同的步骤并得到完全支持。刷新内联数据当然会随着时间的推移而增长因此 DuckLake 还提供了一个刷新操作将内联的行物化为合并后的 Parquet 文件。这在性能有要求或出于迁移目的时非常有用。-- 刷新目录中的所有内联数据CALLducklake_flush_inlined_data(lake);-- 仅刷新特定表CALLducklake_flush_inlined_data(lake,table_namereadings);或者刷新也是检查点例程的一部分该例程按顺序运行所有维护操作刷新、快照过期、文件合并和清理CHECKPOINTlake;结论“小文件问题”一直是数据湖处理流式工作负载的主要痛点之一。对于此类工作负载传统数据湖格式在写入时会产生小文件然后在后续的维护作业中进行清理。DuckLake 通过将小规模更改直接存储在目录中来完全避免这个问题而数据库系统几十年来一直在优化这种类型的工作负载。传统数据湖启用内联的 DuckLake小批量插入创建一个 Parquet 文件存储在目录中小批量删除创建一个删除文件存储在目录中1,000 次插入后的文件数1,000 个文件0 个文件需要压缩是定期进行否准备好时刷新即可查询性能随文件数量增加而下降不受小批量写入影响配置需要调优开箱即用内联功能开箱即用无需任何配置。插入、删除和更新都得到支持。当数据准备好存储为 Parquet 文件时只需一个检查点即可完成。数据内联功能将随 4 月份发布的 DuckLake 1.0 和 DuckDB v1.5.2 一起提供但您不必等待。您可以从 DuckDB v1.5.1 的core_nightly仓库安装 DuckLake。FORCEINSTALL ducklakeFROMcore_nightly;LOADducklake;然后您可以将其指向一个流式工作负载亲自体验其中的差异。运行您自己的基准测试感受它的魅力。设置只需五分钟运行无需任何配置。如果您遇到任何问题请在 GitHub 上提交 issue。如果您遇到特殊情况并希望进行讨论我们在 DuckDB Discord 频道上有一个活跃的社区。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2480896.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!