l1t头像
关注

DeepSeek总结的DuckLake:权威指南第5章性能优化-1

第五章
性能优化
早期发布读者须知
通过早期发布电子书,您可以在书籍正式发行之前很早就获取到作者原始且未经编辑的内容,从而抢先体验这些技术。
本章将是最终成书的第5章。请注意,GitHub代码仓库将在稍后启用。
如果您希望积极参与审阅和评论本草案,请通过[email protected]联系编辑。
至此,您应该已经对DuckLake架构以及为何选择某些设计模式来解耦数据和元数据有了扎实的理解。
但现在,我们将深入探讨性能优化。您可能已经听说过本章将要深入研究的某些技术,例如分区,但借助DuckLake,还有更多方法可以进一步改善性能。
DuckLake确实附带了合理的默认值,开箱即用即可良好运行,但更好地理解我们的工具以将其推向极限是有益的。
本章中您将看到的任何基准测试都会发布在我们的git仓库中,以便您可以运行代码并为您自己的基准测试计时。与任何基准测试一样,重要的是在尽可能接近生产环境的环境中测试,以便对预期性能有合理的了解。
73
分区
分区是提升分析系统查询性能最有效的方法之一。与通过增加CPU或内存来扩展集群不同,分区通过减少最初必须读取的数据量来提升性能。由于I/O通常是大规模分析中的主要成本,减少扫描的数据量可以带来显著的性能提升,而几乎不需要额外的基础设施成本。
这一概念本身并不新鲜。传统数据仓库平台(如Teradata)使用数据分布和分区策略来限制查询执行期间需要检查的数据量。随着2000年代中期Hadoop和对象存储的兴起,分区演变为一种基于目录的模式,由Apache Hive推广开来。在Hive分区模型中,数据按照order_dt=2025-01-01/这样的结构组织到文件夹中,使查询引擎能够快速识别哪些文件与给定查询相关。
当查询对分区列进行过滤时,引擎通常可以在读取任何数据之前就排除许多文件。这种技术通常被称为分区裁剪,可以大幅减少I/O并提升查询性能。
然而,这种方法也可能走得太远。如果分区将数据集分割成太多小块,就可能再次引发小文件问题,从而降低性能。
分区的有效性在很大程度上取决于选择正确的分区键。分区列应与用户和应用程序的访问模式保持一致。正如我们将在聚类部分看到的,DuckLake在这方面提供了一定的灵活性,但这一核心原则仍然重要。您对机制了解得越多,就越能针对特定数据领域进行调整。
在本节的剩余部分,我们将探讨常见的分区策略、它们的权衡取舍,以及每种方法最有效的场景。
隐藏分区设计
在Hive架构中,目录结构是使用分区来减少读取数据的唯一方式。这有时效果很好,但往往也会带来灾难。例如,如果您按order_dt分区,但SQL查询按order_year列过滤,则不会发生任何裁剪。您将需要读取数据集中的每一个文件!结果,查询数据集的人必须完全理解分区方式,否则就会面临极其缓慢的查询。我相信您可以想象那文档页面,上面用粗体和红色高亮写着:“务必在每个查询中按order_dt过滤!”
74 | 第5章:性能优化
相比之下,Iceberg、Delta和DuckLake即使查询过滤的列与分区结构不完全匹配,但存在相关性时,仍然可以裁剪文件。业界将这种方法称为“隐藏分区”。回到之前的例子,如果您按order_dt分区,但按order_year过滤,您的DuckLake查询将只读取order_dt在目标年份内的文件。您的用户不需要知道分区方案中的确切列,只需要知道它是面向时间的。
这是如何实现的?DuckLake通过使用文件最小/最大索引来实现这一点,正如我们在第3章谓词下推部分所讨论的那样。在大多数情况下,分区裁剪完全不需要特殊逻辑!唯一的例外是桶分区,将在本节后面介绍。分区内的每个文件在该分区列中只有一个值,因此最小/最大索引将具有完美的选择性。在我们的order_year查询中,任何order_dt属于正确年份的文件,其最小/最大索引已经表明了正确的年份(分区到2025-01-01的文件,其最小order_year为2025,最大也为2025)。
要使这一机制有效,一个核心要求是您过滤的列必须与分区方式相关。在我们的例子中,存在完美的相关性。在其他情况下,可能存在近似相关性。例如,根据您的业务领域,order_dtshipment_dt可能相当接近。按其中一个分区,在按另一个过滤时仍应具有性能优势。但情况并非总是如此!在使用桶分区时尤其需要注意这一点。
因此,在设计分区时,请确保将分区与查询工作负载对齐,但要理解DuckLake提供了一定的灵活性。这可以让您保持分区方案更简单,同时仍然获得相同的性能优势。
选择分区键
选择分区键时最重要的因素是理解数据是如何被访问的。分区策略应与用户和应用程序最常用的过滤模式保持一致。许多现代数据平台提供查询审计日志,可以帮助识别这些模式,让您看到哪些列经常出现在过滤器和谓词中。如果查询日志不可用,花时间与应用程序所有者、分析师和数据消费者交流,可以为您提供有关数据集使用方式的宝贵见解。
在评估候选分区键时,不仅要考虑某列被过滤的频率,还要考虑它减少必须扫描数据量的有效性。例如,如果用户经常查询某一天、某一周或某一月的数据,那么像order_dt这样的列可能是极好的分区键。相反,一个经常被过滤但返回表中很大一部分数据的列,可能几乎不会提供分区收益。
同样重要的是,不要过度设计初始分区策略。虽然选择适当的分区键可以显著提升性能,但分区决策并非永久性的。如果查询模式发生变化,或者原始设计被证明无效,数据集可以重新分区。根据表的大小,此过程可能需要大量时间和计算资源,但通常是一项可管理的运维任务。在某些情况下,您甚至可能确定分区是不必要的,因为大多数查询通常会扫描数据集的大部分内容。
最后,考虑分区的安全和治理影响。默认情况下,DuckLake使用Hive格式的目录结构来存储分区数据。这意味着分区键将存储在DuckLake数据存储所在文件夹的名称中。因此,敏感信息(如社会安全号码、信用卡号码、电子邮件地址或其他个人身份信息(PII))通常不应作为分区键。分区值将存储在DuckLake目录中,因此目录除了Parquet文件外还将包含PII(请注意,内联数据也会包含PII并存储在目录中)。
然而,DuckLake确实提供了一个选项,可以防止分区存储在文件夹名称中。启用后,给定表的所有Parquet文件将存储在单个文件夹中,只有目录知道文件属于哪个分区。

CALL dl1.set_option('hive_file_pattern', FALSE);

即使有此功能,为避免配置错误的风险,或者在使用多个DuckLake引擎时,请考虑使用非敏感的代理键或业务键进行分区,这样既能支持高效的查询访问,又能最大限度地降低暴露敏感数据的风险。
基于时间的分区
在大多数分析系统中,您会发现大多数表都采用基于时间的分区方法,无论是按日期还是按更细粒度的层级(如年/月/日)。在DuckLake中,基于时间的分区设置非常直接。以下是创建两个表的示例,一个按日期分区,另一个按年、月、日分区。
按日期列分区:

CREATE TABLE dl1.sales_dly (sls_dt date, region_name varchar, sls_amt double);
ALTER TABLE dl1.sales_dly SET PARTITIONED BY (sls_dt);
INSERT INTO dl1.sales_dly VALUES
('2025-01-01', 'North', 200.05),
('2025-01-01', 'South', 150.75),
('2025-03-04', 'North', 153.75),
('2025-03-04', 'South', 120.50);

76 | 第5章:性能优化
按年、月、日分区:

CREATE TABLE dl1.sales_yr_mth_day (sls_dt date, region_name varchar, sls_amt
double);
ALTER TABLE dl1.sales_yr_mth_day SET PARTITIONED BY (year(sls_dt),
month(sls_dt), day(sls_dt));
INSERT INTO dl1.sales_yr_mth_day VALUES
('2025-01-01', 'North', 200.05),
('2025-01-01', 'South', 150.75),
('2025-03-04', 'North', 153.75),
('2025-03-04', 'South', 120.50);

在按日期分区和按年/月/日分区之间选择,通常取决于数据集的大小。如果您的表只包含几年的数据,并且按天分区,那么直接按日期列分区通常就足够了。对于跨越多年的大型数据集,层级式的年/月/日结构可以提供更有条理的目录布局,并改善运维可管理性。
基于桶的分区
桶分区通常用于高基数列,在这些列上使用传统分区会创建过多的分区,实际上造成小文件问题。桶分区不是为每个不同值创建一个分区,而是使用哈希算法将行分配到固定数量的桶中。这允许引擎在避免数百万个单独分区的运维开销的同时,缩小等式谓词必须检查的文件集。
以下是一个演示使用桶分区的示例,其中表被固定为五个数据桶。

CREATE TABLE dl1.user_lookup (user_id int, user_name varchar);
ALTER TABLE dl1.user_lookup SET PARTITIONED BY (bucket(5, user_id));
INSERT INTO dl1.user_lookup VALUES
(1, 'Alice'),(2, 'Bob'),(3, 'Charlie'),(4, 'David'),(5, 'Eve'),
(6, 'Frank'),(7, 'Grace'),(8, 'Henry'),(9, 'Ivy'),(10, 'Jack');

一个常见的错误是对数据集过度分区。虽然分区可以减少I/O,但每个分区都会引入额外的元数据和文件管理开销。分区过于激进的表最终可能包含数千甚至数百万个小文件,这会对查询性能和维护操作产生负面影响。作为一般规则,分区应足够大以包含有意义的数据量,同时仍允许查询引擎有效地消除不必要的文件。
桶分区确实有一个需要注意的关键缺点:它会降低隐藏分区的有效性。桶分区通过计算每个文件的桶哈希来过滤文件,然后过滤到正确的文件——它不像其他分区方法那样使用最小/最大索引。由于值通过哈希分布
分区 | 77
到桶中,与其他列的任何相关性都会丢失。因此,如果您的查询依赖于其他列之间的相关性,那么最好按您自己计算的、保留一些业务上下文的列进行分区。例如,也许按customer_id_group分区,将每100个客户分组在一起(1-100、101-200等),如果新客户被分配递增的ID,这将保留与date_of_first_order等其他列的相关性。
聚类
聚类是您可以在DuckLake上使用的另一种技术,用于提高选择性读取性能。分区改善的是DuckLake基于文件的裁剪(读取哪些文件),而聚类(或排序)改善的是单个文件内的行组裁剪(读取文件内的哪些行组)。两者都在第3章的谓词下推部分讨论过。
创建表时,您可以指定一列或一组列的排序顺序,如下面两个示例所示:

CREATE TABLE dl1.sales_dly (sls_dt date, region_name varchar, sls_amt double);
ALTER TABLE dl1.sales_dly SET SORTED BY (sls_dt ASC);
CREATE TABLE dl1.sales_dly_region_name (sls_dt date, region_name varchar,
sls_amt double);
ALTER TABLE dl1.sales_dly_region_name SET SORTED BY (region_name ASC, sls_dt
ASC);

此外,您还可以基于SQL表达式创建自定义排序。如果查询经常过滤特定的计算表达式而不是直接过滤列,这将非常有用。例如:

CREATE TABLE dl1.sales_dly_cust_sort (sls_dt date, region_name varchar, sls_amt
double);
ALTER TABLE dl1.sales_dly_cust_sort SET SORTED BY (substring(region_name,1,4)
DESC);

在为DuckLake表配置排序顺序后,DuckLake将尝试按照定义的排序顺序组织新写入的数据。这种排序仅适用于每个文件内部,而不是全局应用于整个表,因为对湖仓规模的表进行全局排序通常查询量太大而不切实际。这通常会产生排序列具有明确定义的最小值和最大值的行组,允许查询引擎在选择性扫描期间更有效地裁剪行组。结果是:对排序列进行过滤的查询可以快速裁剪掉不符合边界条件的行组。
如果您使用过SQL Server,这在概念上类似于聚集索引所提供的好处,即行按照键在物理上组织。然而,与传统聚集索引不同,DuckLake利用文件和行组统计信息
78 | 第5章:性能优化
而不是维护单独的索引结构。鉴于行在DuckLake Parquet文件中是物理排序的,DuckDB引擎在优化查询计划时可以使用这些统计信息,帮助查询检查Parquet文件中的行组并裁剪需要扫描的行组数量,从而减少I/O并提高读取性能。
请记住,虽然您可以为表指定多个排序键,但其有效性在第一个排序键之后会下降。这与在表上创建多个索引不同。排序行为类似于典型的ORDER BY子句,首先按第一个键排序,然后仅在该排序内对后续键进行排序。因此,在选择时要明智。请参阅高级聚类部分,了解在这些情况下可能有帮助的一些技术。
另一件需要记住的事情是,当您为表指定排序顺序时,加载数据时可能会有额外的写入时间成本,因为DuckDB必须在将传入记录写入Parquet之前对其进行排序。写入的批次越大,这种开销就越明显。
在适用的情况下,要查看表的排序配置,您可以查询DuckLake内部元数据表ducklake_sort_expression。它将提供排序键配置,包括您为排序顺序定义的任何SQL表达式。
关于何时以及如何对DuckLake表进行排序,正如我们在本章前面提到的,您需要了解您的业务,并很好地理解应用程序和消费者工作负载。花必要的时间了解各种查询过滤模式以及您看到共性之处。否则,在表上创建排序键可能最终弊大于利,特别是如果表需要存储大量记录,而工作负载并未对排序键进行过滤。
如果过滤器中使用了多个列,请注意不要最初按非常细粒度的列(如时间戳)排序。任何后续排序表达式都不太可能产生任何效果。您的数据中有多少行是在恰好2026-01-01 01:02:03.456789插入的?可能只有一行!因此,您可以添加一个将时间戳列截断为仅日期的排序键,然后再按另一列排序,例如:

CREATE TABLE dl1.sales_hourly (sls_ts timestamp, region_name varchar, sls_amt
double);
ALTER TABLE dl1.sales_hourly SET SORTED BY (date(sls_ts) ASC, region_name DESC);

一般来说,聚类往往最适合具有局部性且常用于过滤谓词的列。示例包括地区、国家、客户细分、产品类别或基于日期的表达式。如果您的数据是随时间增量插入的,可能不需要对基于日期的表达式进行聚类,
聚类 | 79
因为文件级裁剪可能已经提供了大部分好处。
对高度唯一的列(如事务标识符或UUID)进行聚类通常收益甚微,除非查询非常有选择性。如果您想通过ID检索单行数据,那么对该列进行聚类可能会有帮助。然而,这些工作负载在湖仓规模下往往很少见,而且您放弃了在可能更常用于过滤器的列上进行聚类的机会。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/l1t/article/details/166010955

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--