PostgreSQL码农集散地

DuckDB + delta 数据湖性能巨幅提升

DuckDB + delta 数据湖性能巨幅提升

DuckDB v0.3.0 Delta扩展通过元数据管理优化(缓存, 减少元数据读取开销)、数据读取优化(parquet文件跳过、分区下推)及查询计划优化,显著提升DuckDB处理Delta表的性能。这些改进使得DuckDB在大数据量场景复杂分析(如TPC-DS)表现更高效,同时保持了对云存储的友好支持。用户可通过合理配置元数据缓存、设计表分区及编写高效查询,进一步释放性能潜力。

测试环境:使用TPC-DS SF1数据集(1GB)在AWS c6id.4xlarge实例上运行,对比Delta扩展v0.1.0与v0.3.0。

关键结果:

  • 总运行时间:从444.76秒降至151.06秒,提升近3倍。
  • 查询稳定性:v0.3.0所有查询均在30秒内完成,而v0.1.0 有4 条SQL 超时。
  • 优化核心:基数信息传播(Cardinality Propagation)显著改善连接顺序优化,减少中间结果集大小,尤其对TPC-DS多连接查询至关重要。

原文

https://duckdb.org/2025/03/21/maximizing-your-delta-scan-performance.html

概述

在我们之前的文章中,我们讨论了 Delta Lake 表格式的全部内容,以及 DuckDB 的delta扩展如何利用Delta Kernel库提供本机支持。在这篇博文中,我们将重点介绍如何从 DuckDB 读取 Delta 表以获得最佳性能。我们将从 Delta 的简要回顾开始,然后展示过去几个版本的性能提升。最后,我们演示了最新 Delta 版本中的三个关键功能,这些功能将确保您最大限度地发挥 Delta 读取性能:元数据缓存、文件跳过和分区信息下推。

Delta 开放表格式

让我们先对 Delta 做一个简短的回顾,以便快速了解最新情况。Delta Lake是一种类似于Apache Iceberg和Apache Hudi的开放表格式。开放表格式最好理解为“数据和元数据的文件集合”,旨在提供数据湖的灵活性,同时提供传统数据仓库的一些一致性保证。就 Delta 而言,该格式由用于数据的 Parquet 文件和用于元数据的 Parquet、JSON 和二进制文件的混合组成。除了提供更高级别的一致性之外,开放表格式提供的附加元数据还允许通过列统计和文件跳过等方式实现各种类型的性能优化。有关更深入的解释,请参阅之前的 Delta 博客文章。  https://duckdb.org/2024/06/10/delta.html

delta 插件

DuckDB 原生支持通过delta扩展读取 Delta 表。此扩展是核心 DuckDB 扩展之一,每周下载量超过 7 万次。使用此扩展读取 Delta 表非常简单。自 DuckDB v1.2.0 起,delta首次使用时将自动安装扩展,并在调用函数时加载delta_scan。

例如,要读取本地 Delta 表,只需打开任何 DuckDB 客户端并运行:

SELECT * FROM delta_scan('./path_to_your_delta_table');  

您的 Delta 表是否在其他人的机器上,比如在 AWS 中?DuckDB 还可以直接从 S3 查询!要让 DuckDB 自动加载您的 AWS 凭证并查询远程 Delta 表,请运行:

CREATE SECRET (TYPE s3, PROVIDER credential_chain);  
SELECT * FROM delta_scan('s3://your_bucket/your_delta_table');  

对于其他云提供商(例如 Azure 或 Google Cloud),请查看扩展的文档页面。 https://duckdb.org/docs/stable/extensions/delta.html#usage

delta v0.1.0 和 0.3.0之间的性能改进

虽然扩展的第一个版本 (v0.1.0)delta已经附带了各种与性能相关的功能,例如投影下推和恒定过滤器下推,但此后添加的功能极大地提高了性能delta_scan。为了说明这一点,我们的第一个基准测试将使用行业标准TPC-DS基准测试和比例因子 1 数据集 (SF1)。

基准测试设置

为了进行此基准测试,我们启动了一个AWS c6id.4xlarge 实例(16 个 vCPU,32 GB RAM),并使用PySpark将 TPC-DS SF1 数据集写入同一区域(eu-west-1)的 S3 存储桶。每个基准测试总共运行 6 次,结果是最后 5 次运行的中位运行时间,其中第一次被视为冷运行。汇总结果显示在下表中。

结果
总运行时间
最短运行时间
最大运行时间
平均运行时间
查询超时
delta 扩展 v0.1.0
444.76
0.48
21.31
3.63
4
delta 扩展 v0.3.0
151.06
0.46
6.15
1.22
0

基准测试的详细结果如折页所示:

详细的 TPC-DS SF1 基准测试结果,delta扩展 v0.1.0 与 v0.3.0


Image

pic

分析

从结果中我们可以看出,整体性能有了显著提升。在 v0.1.0 中,99 个SQL中有 4 个达到了 30 秒的基准超时,因此被排除在结果之外。在 v0.3.0 中,所有 99 个查询都在超时内顺利完成。比较总运行时间(不包括 v0.1.0 中超时的查询),我们发现速度提高了 3 倍以上!

现在,无需过多赘述,这里加速的一个重要部分可以归因于( https://github.com/duckdb/duckdb-delta/pull/77 )中添加的基数信息传播(可以理解为delta开放表格的统计信息, 例如记录数、每个字段的柱状图信息、空值行数、唯一值个数等)。准确的基数估计对于 DuckDB 的查询优化器正常工作并生成高效的查询计划至关重要。具体来说,DuckDB 的连接优化器使用基数估计来更改执行连接的顺序。连接顺序会极大地影响中间元组的基数,从而对查询性能产生很大影响。特别是在 TPC-DS 基准测试这样的工作负载中,其中有许多包含大量连接的查询,连接顺序优化器起着至关重要的作用。有关(更多)详细信息,请查看这篇论文。 https://blobs.duckdb.org/papers/tom-ebergen-msc-thesis-join-order-optimization-with-almost-no-statistics.pdf

进一步优化

ATTACH 增量表

除了基数信息传播等一般性能改进之外,delta扩展中还添加了几个与性能相关的功能。其中之一就是能够附加 Delta 表。使用ATTACH查询 Delta 表有多个优点。首先,通过使用ATTACH,当多次查询同一张表时,您的查询会看起来更干净一些,因为您不必在每次查询时都重复完整的 Delta 表路径。但更重要的是,使用ATTACH将允许 DuckDB 缓存/重用 Delta 元数据的某些部分,从而提高查询性能。要附加本地 Delta 表,请运行:

ATTACH './path_to_your_delta_table' AS your_table (TYPE delta);  

attach Delta 表后,您只需使用别名即可查询该表:

SELECT * FROM your_table;  

默认情况下,DuckDB 会自动在同一事务中缓存 Delta 元数据。这意味着如果在该事务中多次扫描 Delta 表,DuckDB 可以在不同扫描之间重用部分 Delta 元数据。例如,以下查询将仅读取一次 Delta 元数据:

SELECT * FROM t1  
UNION ALL  
SELECT * FROM t1;  

为了进一步提高性能,DuckDB 还支持在不同的查询之间保留此缓存的 Delta 元数据。为此,可以使用PIN_SNAPSHOT选项附加 Delta 表。启用此选项后,后续查询可以重用元数据,如以下代码块所示:

ATTACH 's3://your_bucket/your_delta_table' AS t2 (  
    TYPE delta,  
    PIN_SNAPSHOT  
);  

-- First scan (metadata not yet cached)  
SELECT * FROM t1;  

-- Second scan (metadata is now cached)  
SELECT * FROM t2;  

元数据缓存会对性能产生重大影响,尤其是在数据相对较小且延迟较高的情况下。为了说明这一点,我们将重新运行 TPC-DS 实验,以比较扫描 Delta 表的三种不同方式:使用delta_scan、使用ATTACH和使用ATTACH ... (PIN_SNAPSHOT)。其余基准测试设置与上一节中的设置相同。

结果
总运行时间
最短运行时间
最大运行时间
中位运行时间
delta_scan
151.06
0.46
6.15
1.22
ATTACH
134.26
0.43
4.28
1.19
ATTACH(PIN_SNAPSHOT)
102.80
0.36
4.04
0.87

基准测试的详细结果如折页所示:

不同配置的 TPC-DS SF1 基准测试详细结果


Image

pic

结果表明,对于许多 TPC-DS 查询,使用ATTACH而不是delta_scan已经可以略微提高几个查询的性能,总体运行时间速度提高了 1.13 倍。当元数据由于 而完全缓存时PIN_SNAPSHOT,速度甚至提高了 1.47 倍。然而,这样做的代价是丢失ATTACH语句之后对表的任何更新。

仔细查看完整结果还会发现一些情况,其中ATTACH结果实际上比原始结果略差。我们将在下推/相互作用delta_scan部分中对此进行解释。ATTACH

文件跳过

扫描 Delta 表的另一个关键性能特性是文件跳过。如介绍中所述,Delta 表包含元数据,其中包含表的数据文件的各种统计信息。这些统计信息可供 DuckDB 等引擎使用,以决定需要扫描哪些 Parquet 文件以及可以完全跳过哪些文件。文件跳过是 DuckDB 自动完成的操作。文件跳过适用于常量过滤器和动态过滤器(在查询执行期间计算的过滤器):

-- constant filter  
FROM delta_scan('...')  
WHERE col_a > 'some_value';  
-- dynamic filter  
FROM delta_scan('...')  
WHERE col_a > (SELECT max(col_z) FROM other_tbl);  

在之前的基准测试中,文件跳过的效果非常有限。整体数据根本不够大,而且许多查询实际上会触及大量数据。但是,当查询中只触及相对较小部分数据时,文件跳过会对性能产生巨大影响。为了证明这一点,我们将首先生成一些测试数据。我们将使用与之前相同的基于 PySpark 的测试数据生成脚本。

该表有 1 亿行,具有非常基本的架构,其中包含一个类型id的递增列INTEGER和一个类型value的列VARCHAR。如果我们使用 DuckDB 查询数据,我们将看到类似以下内容:

FROM delta_scan('s3://your_bucket/your_delta_table');  

┌──────────┬──────────────┐  
│    id    │    value     │  
│  int64   │   varchar    │  
├──────────┼──────────────┤  
│ 49950000 │ val-49950000 │  
│ 49950001 │ val-49950001 │  
│ 49950002 │ val-49950002 │  
│ 49950003 │ val-49950003 │  
│      ·   │     ·        │  
│      ·   │     ·        │  
│      ·   │     ·        │  
│    49996 │ val-49996    │  
│    49997 │ val-49997    │  
│    49998 │ val-49998    │  
│    49999 │ val-49999    │  
├──────────┴──────────────┤  
│     100000000 rows      │  
│        (8 shown)        │  
└─────────────────────────┘  

现在,假设我们只对特定范围的ids 感兴趣:也许我们只想要id低于 100 的 s。我们现在将构建两个查询。

对于第一个查询,我们将使用glob 模式直接读取表中所存储的所有 parquet 文件:

FROM parquet_scan('s3://your_bucket/your_delta_table/*.parquet')  
WHERE id < 100;  

我们这样做是为了说明目的,向我们展示文件跳过的好处,像这样扫描 Delta 表中的原始 Parquet 文件仅在这里有效,因为我们在此表中没有更新、删除或检查点。

对于第二个查询,我们直接使用表函数扫描表delta_scan,使用子句仅选择id我们感兴趣的WHERE:

FROM delta_scan('s3://your_bucket/your_delta_table')  
WHERE id < 100;  

现在,当从同一区域的 S3 存储桶上的 c6id.4xlarge AWS 实例运行这些查询时,我们可以看到它们的性能差异很大。delta_scan仅需要 ≈0.5 秒即可完成,而parquet_scan需要 ≈17 秒。 那么这里到底发生了什么?

我们可以使用DuckDB的EXPLAIN ANALYZE语句来获取更多详细信息。让我们首先分析parquet_scan:

EXPLAIN ANALYZE  
FROM parquet_scan('s3://your_bucket/your_delta_table/*.parquet')  
WHERE id < 100;  

┌────────────────────────────────────────────────┐  
│┌──────────────────────────────────────────────┐│  
││              Total Time: 17.08s              ││  
│└──────────────────────────────────────────────┘│  
└────────────────────────────────────────────────┘  
             ...  
┌─────────────┴─────────────┐  
│         TABLE_SCAN        │  
│    ────────────────────   │  
│         Function:         │  
│        PARQUET_SCAN       │  
│                           │  
│        Projections:       │  
│             id            │  
│           value           │  
│                           │  
│      Filters: id<100      │  
│                           │  
│          100 Rows         │  
│         (262.39s)         │  
└───────────────────────────┘  

我们可以在EXPLAIN ANALYZE输出中看到,我们的过滤器被正确地推低了,并且扫描只正确地产生了 100 行。这一切看起来都很好,对吧?好吧,让我们将它与的输出进行EXPLAIN ANALYZE比较delta_scan:

EXPLAIN ANALYZE  
FROM delta_scan('s3://your_bucket/your_delta_table');  

┌────────────────────────────────────────────────┐  
│┌──────────────────────────────────────────────┐│  
││              Total Time: 0.615s              ││  
│└──────────────────────────────────────────────┘│  
└────────────────────────────────────────────────┘  
             ...  
┌─────────────┴─────────────┐  
│         TABLE_SCAN        │  
│    ────────────────────   │  
│        Projections:       │  
│             id            │  
│           value           │  
│                           │  
│      Filters: id<100      │  
│    File Filters: id<100   │  
│                           │  
│      Scanning Files:      │  
│           1/2000          │  
│                           │  
│          100 Rows         │  
│          (0.06s)          │  
└───────────────────────────┘  

对于delta_scan函数的EXPLAIN ANALYZE输出,我们可以看到两个新字段:File Filters 和Scanning Files。这清楚地向我们展示了发生了什么。id<100谓词现在用于两件事:它被推入对单个 Parquet 文件的扫描中,就像一样parquet_scan,但它也显示为文件过滤器,用于减少要扫描的文件列表!这导致要读取的 Parquet 元数据量减少了 2,000 倍,从而大大提高了性能。

分区信息下推

DuckDB Delta 的最后一项性能功能是分区信息下推。分区信息下推和分区感知聚合运算符是 DuckDB v1.2.0 中引入的相对较新的功能。在扩展的 v0.3.0 版本中delta也添加了此功能,这意味着 DuckDB 现在可以使用分区信息来创建查询计划,这些查询计划可以利用扫描的数据已经分区的事实。为了展示分区信息的性能优势,我们将令人惊讶地运行另一个基准测试!这次,我们选择了比例因子为 10 的TPC-H 数据集,并在 32 GB MacBook Pro M1 Max 上运行了实验。我们lineitem按和l_returnflag列对表进行了分区l_linestatus。然后我们运行Q1,它大致如下所示:

SELECT  
    l_returnflag,  
    l_linestatus,  
    sum(l_quantity) AS sum_qty,  
    ...  
FROM  
    lineitem  
    ...  
GROUP BY  
    l_returnflag,  
    l_linestatus  
    ...;  

请注意,查询包含一个GROUP BY语句,其中列出了我们的数据集已分区的确切列。让 DuckDB 使用分区感知运算符是完全自动完成的,因此在这种情况下只需运行:

ATTACH './path_to_partitioned_directory/lineitem_sf10' AS lineitem (  
    TYPE delta  
);  
PRAGMA tpch(1);  

将在分区 Delta 数据集上触发 TPC-H Q1。要检查发生了什么,我们将再次使用EXPLAIN ANALYZE:

┌────────────────────────────────────────────────┐  
│┌──────────────────────────────────────────────┐│  
││              Total Time: 0.477s              ││  
│└──────────────────────────────────────────────┘│  
└────────────────────────────────────────────────┘  
             ...  
┌─────────────┴─────────────┐  
│   PARTITIONED_AGGREGATE   │  
│    ────────────────────   │  
│          Groups:          │  
│             #0            │  
│             #1            │  
│                           │  
│        Aggregates:        │  
│          sum(#2)          │  
│          sum(#3)          │  
│          sum(#4)          │  
│          sum(#5)          │  
│          avg(#6)          │  
│          avg(#7)          │  
│          avg(#8)          │  
│        count_star()       │  
│                           │  
│           4 Rows          │  
│          (0.65s)          │  
└─────────────┬─────────────┘  
             ...  

我们可以看到DuckDB已经正确检测到分区信息,并且正在使用PARTITIONED_AGGREGATE运算符有效地执行操作GROUP BY。

现在,作为基准,我们将重新运行相同的查询,但现在禁用分区信息下推:

ATTACH './path_to_partitioned_directory/lineitem_sf10' AS lineitem (  
    TYPE delta,  
    PUSHDOWN_PARTITION_INFO 0  
);   
PRAGMA tpch(1);  

再次,使用EXPLAIN ANALYZE,我们可以看到 DuckDB 现在将使用常规HASH_GROUP_BY运算符,因为查询计划期间 Delta 的分区信息不可用。

┌────────────────────────────────────────────────┐  
│┌──────────────────────────────────────────────┐│  
││              Total Time: 0.552s              ││  
│└──────────────────────────────────────────────┘│  
└────────────────────────────────────────────────┘  
             ...  
┌─────────────┴─────────────┐  
│       HASH_GROUP_BY       │  
│    ────────────────────   │  
│          Groups:          │  
│             #0            │  
│             #1            │  
│                           │  
│        Aggregates:        │  
│          sum(#2)          │  
│          sum(#3)          │  
│          sum(#4)          │  
│          sum(#5)          │  
│          avg(#6)          │  
│          avg(#7)          │  
│          avg(#8)          │  
│        count_star()       │  
│                           │  
│           4 Rows          │  
│          (1.37s)          │  
└─────────────┬─────────────┘  
             ...  

现在看看这两个查询之间的性能差异,我们可以看到整体加速只有 1.16 倍,但聚合操作本身却加速了 2.11 倍!这意味着,当查询经常执行繁重的分组操作时,按这些列对数据进行分区绝对是性能调优工具箱中非常有用的工具。

ATTACH关于下推/性能相互作用的说明

虽然过滤器下推和分区信息下推等功能将提高许多工作负载的性能,但值得注意的是,使用 的元数据缓存机制ATTACH与过滤器和分区信息的下推之间存在着某种复杂的相互作用。在有关该功能的部分末尾ATTACH,我们已经看到,对于某些查询,使用ATTACH实际上比使用原始的 稍慢delta_scan。无需深入探讨太多细节,下推过滤器和分区信息会对某些查询的元数据缓存的有效性产生负面影响。这意味着,对于某些查询,在使用 时部分禁用过滤器下推可能会带来一些违反直觉的好处ATTACH:

ATTACH './your_delta_table_directory' AS dt (  
    TYPE delta,   
    PIN_SNAPSHOT,   
    PUSHDOWN_PARTITION_INFO 0,   
    PUSHDOWN_FILTERS 'none'
);  

不过,这应该被视为一种高级用例,并且仅在针对特定查询进行优化时才有意义。的默认设置ATTACH应能提供最佳的整体性能,并且在大多数情况下都是推荐的。此外,DuckDB Delta 使用的底层delta-kernel-rs库中正在进行一项工作,旨在通过公开机制来巧妙地刷新 DuckDB 持有的元数据对象来减少这种影响。一旦这些机制可用,我们就会将它们添加到 DuckDBdelta扩展中,这可能会使这些标志在测试之外的任何情况下都过时。

结论

在这篇博文中,我们研究了最新版本的 DuckDBdelta扩展,并使用一些基准对其进行了测试。我们运行了行业标准 TPC 基准的查询,以展示与扩展的过去版本相比所取得的巨大性能改进delta。

此外,我们还研究了在使用 Delta 表时可以使用的三种具体技术,以进一步提高性能:

  • 使用元数据缓存ATTACH
  • 使用过滤器和数据布局来减少需要扫描的文件数量
  • 利用分区信息加速聚合

总而言之,我们认为随着delta扩展的 v0.3.0 版本的发布,DuckDB 可以在许多不同的工作负载下以出色的性能读取 Delta 表,并强烈鼓励大家尝试最新版本!