当我们将 DuckDB 从单机扩展到多节点集群时,查询规划器扮演着承上启下的关键角色 —— 它决定了一条 SQL 语句如何被拆分到多个计算节点并行执行,以及数据在不同节点之间如何流动。理解这一层的工程设计,对于构建高性能分布式分析系统至关重要。本文聚焦分布式 DuckDB 集群的查询规划器设计,探讨数据分区策略与并行执行计划生成的实践要点。

从 Pull 到 Push:执行模型的演进

DuckDB 在单节点环境下已经实现了从传统的 pull-based(拉取)执行模型向 push-based(推送)执行模型的转变。在 pull-based 模型中,每个运算符(如 Scan、Filter、Aggregate)主动向上游请求数据,上游被动响应;而 push-based 模型允许运算符将处理后的数据主动向下游推送。这种转变的意义在于,它使得细粒度的流水线并行成为可能 —— 不同运算符可以在同一线程上形成连续的流水线,避免了中间结果 Materialize 带来的开销。

当查询被分发到分布式环境时,push-based 模型的优势被进一步放大。规划器可以将完整的逻辑计划拆分为多个分区(partition),每个分区包含一组可流水线执行的运算符。分区之间通过 Exchange 算子进行数据交换,形成分布式执行计划的基本单元。这意味着查询规划器不仅需要考虑传统的逻辑优化(如谓词下推、投影下推),还需要识别分区边界 —— 即哪些运算符可以组合在一起形成独立调度的分区,哪些点必须引入数据重分布操作。

数据分区策略:Hash 与 Range 的取舍

分布式查询规划的首要决策是选择合适的数据分区策略,它直接影响数据局部性和查询性能。当前主流的分区方式分为两大类:基于哈希的分区(hash-based sharding)和基于范围的分区(range-based sharding)。

基于哈希的分区将分区键通过哈希函数映射到目标节点,优势在于能够将数据均匀分散到各个节点,避免单一节点成为热点。在实际工程中,常见的做法是选择高基数的列作为哈希键 —— 例如用户 ID、订单 ID 或时间戳的哈希值。DuckDB 的外部分布式实现如 Quack-Cluster,在处理大规模聚合查询时默认采用哈希分区策略,以确保各节点的计算负载基本均衡。然而,哈希分区的一个潜在问题是失去了范围查询的数据局部性,如果查询包含大范围的过滤条件,可能需要扫描多个节点的数据。

基于范围的分区则根据分区键的值域将数据划分为连续区间,例如按日期、按地区或按业务类别进行分区。这种策略的最大优势在于保留了数据的物理 locality—— 范围查询或前缀匹配可以直接在单个节点上完成,无需跨节点数据流动。但区间划分需要预先了解数据分布,如果分区键选择不当,容易出现数据倾斜(某些节点数据量远超其他节点)。在工程实践中,一个常见的优化策略是结合使用 Range 和 Hash 的混合分区:对主键采用范围分区以利用局部性,对热点键采用二级哈希分区以分散负载。

在实现分布式查询规划器时,需要为系统配置以下关键参数:分区键选择规则(默认采用主键哈希,可通过配置覆盖)、分区数量(建议设置为节点数量的整数倍,如节点数的 2-4 倍,以预留负载均衡空间)、倾斜检测阈值(当某分区数据量超过平均值的 1.5 倍时触发重分区)。

并行执行计划生成:从逻辑计划到物理计划

分布式查询规划的核心任务是将逻辑计划转换为物理执行计划,这一过程涉及三个关键决策:局部执行 vs. 分布式执行的选择、Join 类型的选取、以及并行度的计算。

对于简单的过滤聚合查询,规划器首先评估数据是否已经按分区键组织。如果查询的过滤条件与分区键匹配,规划器可以直接将扫描操作下推到各分区节点,在本地完成过滤和局部聚合,最后在协调节点合并结果。这种策略避免了不必要的数据流动,是成本最低的执行路径。工程上可以通过设置 scan_parallelism 参数控制每个分区的扫描并行度,默认值通常为 CPU 核心数。

当查询涉及跨分区的 Join 操作时,规划器需要在两种策略之间做出选择:广播 Join(broadcast join)和重分布 Join(shuffle join)。如果参与 Join 的两个表中有一个较小(通常小于 100MB),规划器倾向于将该小表完整广播到所有节点,在本地完成 Join,这种策略网络开销小但受限于小表的内存容量。如果两个表都较大,规划器则需要对两个表按照 Join 键进行重分布,确保相同的 Join 键值被发送到同一节点进行处理。重分布 Join 的网络开销较高,但可以处理任意规模的数据。在 DuckDB 的分布式实现中,可以通过 broadcast_threshold 参数控制广播 Join 的阈值,默认值为 100MB;对于更大的表,系统会自动切换到重分布策略。

对于复杂的聚合和窗口函数,规划器还需要决定聚合的阶段划分。常见的做法是采用两阶段聚合:第一阶段在分区节点上完成局部聚合(partial aggregate),减少需要传输的数据量;第二阶段在协调节点完成最终聚合(final aggregate)。对于窗口函数,规划器需要根据窗口的大小和并行度配置,决定是否在分区级别并行计算窗口函数,还是收集全量数据后在协调节点统一处理。

工程实践中的规划器参数配置

基于上述分析,以下是分布式 DuckDB 查询规划器的关键工程参数建议,适用于中小规模集群(8-32 节点,每节点 8-16 核):

分区策略参数:sharding_strategy 默认设为 "hash",sharding_key_fallback 设为 ["primary_id", "created_at"],确保在主键不可用时有备选方案;range_partition_bounds 用于配置范围分区边界,格式为数组如 ["2024-01-01", "2024-04-01", "2024-07-01", "2024-10-01"]。

执行计划参数:broadcast_threshold 设为 "100MB",local_parallelism 设为节点 CPU 核心数的 75%(避免过度竞争),max_shuffle_partitions 设为节点数的 4 倍以控制重分布粒度。

容错与调优参数:task_retry_count 设为 3(允许任务失败后重试),result_cache_enabled 设为 true(对重复查询启用结果缓存),partition_skew_alert_threshold 设为 2.0(当某分区数据量超过平均值 2 倍时触发告警)。

小结

分布式 DuckDB 查询规划器的设计,本质上是在数据分布、查询特性和计算资源之间寻找平衡。理解 push-based 执行模型如何支撑分区感知规划、掌握 hash 与 range 分区策略的适用场景、熟悉 broadcast 与 shuffle Join 的选择原则,是构建高效分布式分析系统的关键。通过合理的参数配置,可以显著提升查询性能并保证系统的稳定性。


参考资料