并行处理(多核处理)
行组对并行性的影响
DuckDB 基于行组并行化工作负载,即在存储级别上存储在一起的行组。 DuckDB 数据库格式中的行组最多包含 122,880 行。 并行化从行组级别开始,因此,对于要在k个线程上运行的查询,它需要扫描至少k * 122,880 行。
线程过多
请注意,在某些情况下,DuckDB可能会启动过多的线程(例如,由于超线程技术),这可能会导致性能下降。在这些情况下,值得手动限制线程数量,使用SET threads = X
。
大于内存的工作负载(外核处理)
DuckDB 的一个关键优势是支持大于内存的工作负载,即它能够处理比可用系统内存更大的数据集(也称为外存处理)。 它还可以运行中间结果无法放入内存的查询。 本节解释了 DuckDB 中大于内存处理的先决条件、范围和已知限制。
溢出到磁盘
大于内存的工作负载通过溢出到磁盘来支持。
使用默认配置时,DuckDB会创建⟨database_file_name⟩.tmp
临时目录(在持久模式下)或.tmp
目录(在内存模式下)。可以使用temp_directory
配置选项更改此目录,例如:
SET temp_directory = '/path/to/temp_dir.tmp/';
阻塞操作符
一些运算符在看到输入的最后一行之前无法输出单行。 这些被称为阻塞运算符,因为它们需要缓冲整个输入, 并且是关系数据库系统中内存最密集的运算符。 主要的阻塞运算符如下:
- 排序:
ORDER BY
- 分组:
GROUP BY
- 窗口函数:
OVER ... (PARTITION BY ... ORDER BY ...)
- 连接:
JOIN
DuckDB 支持所有这些操作符的大于内存处理。
Limitations
DuckDB 努力确保即使工作负载超出内存大小也能完成。也就是说,目前存在一些限制:
- 如果在同一个查询中出现多个阻塞操作符,DuckDB可能仍然会因为这些操作符的复杂交互而抛出内存不足的异常。
- 一些聚合函数,例如
list()
和string_agg()
,不支持卸载到磁盘。 - 使用排序的聚合函数是整体性的,即它们需要所有输入才能开始聚合。由于DuckDB目前还不能将一些复杂的中间聚合状态卸载到磁盘,这些函数在大型数据集上运行时可能会导致内存不足异常。
PIVOT
操作 内部使用了list()
函数,因此它受到相同的限制。
Profiling
如果你的查询性能不如预期,值得研究它们的查询计划:
- 使用
EXPLAIN
来打印物理查询计划而不运行查询。 - 使用
EXPLAIN ANALYZE
来运行和分析查询。这将显示查询中每个步骤所花费的CPU时间。请注意,由于多线程的原因,各个步骤的时间总和将大于查询处理的总时间。
查询计划可以指向性能问题的根源。一些一般性的方向:
- 避免使用嵌套循环连接,优先使用哈希连接。
- 扫描操作如果没有对后续应用的过滤条件进行过滤下推,会导致不必要的IO操作。尝试重写查询以应用过滤下推。
- 应不惜一切代价避免连接顺序不当导致操作符的基数爆炸至数十亿元组的情况。
Prepared Statements
Prepared statements 可以在多次运行相同查询但使用不同参数时提高性能。当语句被准备时,它会完成查询执行过程的几个初始部分(解析、规划等)并缓存它们的输出。当它被执行时,这些步骤可以被跳过,从而提高性能。这对于使用不同参数集重复运行小查询(运行时间小于100毫秒)特别有益。
请注意,DuckDB的主要设计目标并不是快速并发执行许多小查询。相反,它针对运行较大、频率较低的查询进行了优化。
查询远程文件
DuckDB在读取远程文件时使用同步IO。这意味着每个DuckDB线程一次最多只能发出一个HTTP请求。如果查询必须在网络上发出许多小请求,将DuckDB的threads
设置增加到大于CPU核心总数(大约2-5倍CPU核心)可以提高并行性和性能。
避免读取不必要的数据
读取远程文件的工作负载中的主要瓶颈可能是IO。这意味着最小化不必要读取的数据可以非常有益。
一些基本的SQL技巧可以帮助解决这个问题:
- 避免使用
SELECT *
。相反,只选择实际使用的列。DuckDB会尝试只下载它实际需要的数据。 - 尽可能在远程parquet文件上应用过滤器。DuckDB可以使用这些过滤器来减少扫描的数据量。
- 要么排序,要么分区数据,通过经常用于过滤的列:这增加了过滤器在减少IO方面的有效性。
要检查查询传输了多少远程数据,可以使用EXPLAIN ANALYZE
来打印出远程文件查询的总请求数和总数据传输量。
避免多次读取数据
DuckDB 不会自动缓存远程文件的数据。这意味着在远程文件上运行两次查询将下载所需的数据两次。因此,如果数据需要多次访问,将其存储在本地可能是有意义的。为了说明这一点,让我们看一个例子:
考虑以下查询:
SELECT col_a + col_b FROM 's3://bucket/file.parquet' WHERE col_a > 10;
SELECT col_a * col_b FROM 's3://bucket/file.parquet' WHERE col_a > 10;
这些查询从s3://bucket/file.parquet
下载了col_a
和col_b
列两次。现在考虑以下查询:
CREATE TABLE local_copy_of_file AS
SELECT col_a, col_b FROM 's3://bucket/file.parquet' WHERE col_a > 10;
SELECT col_a + col_b FROM local_copy_of_file;
SELECT col_a * col_b FROM local_copy_of_file;
在这里,DuckDB 会首先从 s3://bucket/file.parquet
复制 col_a
和 col_b
到本地表中,然后两次查询本地内存中的列。还要注意的是,过滤器 WHERE col_a > 10
现在也只应用一次。
这里需要做一个重要的补充说明。前两个查询是完全流式的,只需要很小的内存占用,而第二个查询需要完全物化列col_a
和col_b
。这意味着在某些罕见的情况下(例如,网络速度很快,但可用内存非常有限),实际上下载数据两次可能是有益的。
使用连接的最佳实践
当多次重复使用相同的数据库连接时,DuckDB 的性能最佳。每次查询时断开并重新连接会产生一些开销,这可能会在运行许多小查询时降低性能。DuckDB 还会在内存中缓存一些数据和元数据,当最后一个打开的连接关闭时,该缓存将丢失。通常,单个连接效果最好,但也可以使用连接池。
使用多个连接可以并行化一些操作,尽管通常并不需要。DuckDB确实尝试在每个单独的查询中尽可能并行化,但并非在所有情况下都能并行化。建立多个连接可以同时处理更多操作。如果DuckDB不受CPU限制,而是受网络传输速度等其他资源的瓶颈影响,这可能更有帮助。
The preserve_insertion_order
选项
当导入或导出数据集(从/到Parquet或CSV格式)时,如果数据集的大小远大于可用内存,则可能会出现内存不足错误:
Out of Memory Error: failed to allocate data of size ... (.../... used)
在这些情况下,考虑将preserve_insertion_order
配置选项设置为false
:
SET preserve_insertion_order = false;
这允许系统重新排序任何不包含ORDER BY
子句的结果,从而可能减少内存使用。
持久化表 vs. 内存表
DuckDB 支持 轻量级压缩技术。目前,这些技术仅应用于持久化(磁盘上)的数据库。
DuckDB 不会压缩其内存中的表。原因是压缩是在检查点期间执行的,而内存中的表不会被检查点。
在极少数情况下,这可能会导致不直观的性能结果,即查询在磁盘表上比在内存表上更快。例如,TPC-H工作负载的Q1在磁盘上运行时比在内存模式下更快:
INSTALL tpch;
LOAD tpch;
CALL dbgen(sf = 30);
.timer on
PRAGMA tpch(1);
数据库设置 | 执行时间 |
---|---|
内存数据库 | 4.80 秒 |
持久化数据库 | 0.57 秒 |