工作负载调优
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 的结果进行重排,从而有机会降低内存占用。
并行性(多核处理)
Row Group 对并行性的影响
Goose 基于 row groups 并行化负载,即在存储层一起保存的一组行。 Goose 数据库格式默认 row group 大小为 122,880 行。 并行起点在 row group 级别,因此查询要在 k 个线程上运行,至少需要扫描 k * 122,880 行。
row group 大小可作为 ATTACH 语句选项指定:
ATTACH '/tmp/somefile.db' AS db (ROW_GROUP_SIZE 16384);
为 Parquet 文件选择 ROW_GROUP_SIZE 的性能考量同样适用于 Goose 自有数据库格式。
线程过多
注意在某些情况下 Goose 可能会启动_过多线程_(例如受 HyperThreading 影响),反而导致变慢。此时建议通过 SET threads = X 手动限制线程数。
大于内存规模负载(Out-of-Core 处理)
Goose 的关键优势之一是支持大于内存规模的负载,即可处理超过系统可用内存的数据集(也称 out-of-core processing)。 它也可以执行中间结果无法完全放入内存的查询。 本节说明 Goose 中该能力的前提、范围和已知限制。
溢写到磁盘
大于内存规模负载通过磁盘溢写实现支持。
默认配置下,Goose 会在持久化模式创建 ⟨database_file_name⟩.tmp 临时目录,在内存模式创建 .tmp 目录。
该目录可通过 temp_directory 配置项修改,例如:
SET temp_directory = '/path/to/temp_dir.tmp/';
阻塞算子
有些算子在看到输入最后一行前无法输出任何一行。 这类算子称为 blocking operators,因为它们需要缓冲全部输入, 也是关系型数据库中最耗内存的一类算子。 主要阻塞算子包括:
- grouping:
GROUP BY - joining:
JOIN - sorting:
ORDER BY - windowing:
OVER ... (PARTITION BY ... ORDER BY ...)
Goose 对上述所有算子都支持大于内存规模处理。
限制
Goose 目标是在负载大于内存时也尽可能完成任务。 但当前仍存在一些限制:
- 当同一查询出现多个阻塞算子时,由于它们相互作用复杂,Goose 仍可能抛出 OOM 异常。
- 某些聚合函数(如
list()、string_agg())不支持溢写到磁盘。 - 使用排序的聚合函数属于 holistic 类型,即聚合开始前需拿到全部输入。由于 Goose 目前尚不能将某些复杂中间聚合状态溢写到磁盘,这类函数在大数据集上可能触发 OOM。
PIVOT操作内部使用list()函数,因此受同样限制。
性能剖析
若查询性能不及预期,建议先分析查询计划:
- 使用
EXPLAIN在不执行查询的情况下打印 physical query plan。 - 使用
EXPLAIN ANALYZE执行并剖析查询。它会显示查询每一步消耗的 CPU 时间。注意因多线程原因,各步骤时间求和会大于总查询耗时。
查询计划通常能定位性能问题根因。可先关注以下方向:
- 尽量避免 nested loop join,优先使用 hash join。
- 若某个过滤条件本可下推但未下推,会产生额外 IO。可尝试重写查询以触发下推。
- 应尽量避免让某算子基数膨胀到数十亿 tuple 的糟糕 Join 顺序。
预处理语句
Prepared statements 在“同一查询多次执行但参数不同”场景下可提升性能。语句被 prepare 时会完成解析、规划等前期步骤并缓存结果;后续执行可跳过这些步骤,从而提升性能。该优化主要适用于反复执行的小查询(耗时 < 100ms)且参数集合不同的场景。
需要注意,Goose 的主要设计目标并非并发执行大量小查询,而是优化执行更大、频率更低的查询。
查询远程文件
Goose 在读取远程文件时使用同步 IO。这意味着每个 Goose 线程同一时刻最多只能发起一个 HTTP 请求。若查询需要发起大量小网络请求,可将 Goose 的 threads 设置 调高到超过 CPU 核心总数(约为核心数的 2-5 倍),以提升并行性与性能。
避免读取不必要数据
读取远程文件的负载中,主要瓶颈通常是 IO。因此,尽量减少无效读取数据会非常有帮助。
一些基础 SQL 技巧可帮助降低无效读取:
- 避免
SELECT *,仅选择实际需要的列。Goose 会尽量只下载必需数据。 - 在可能情况下对远程 Parquet 文件应用过滤条件,Goose 可据此减少扫描数据量。
- 按常用于过滤的列进行排序或分区,可提升过滤对降低 IO 的效果。
要检查查询传输了多少远程数据,可使用 EXPLAIN ANALYZE 输出远程文件查询的请求总数与数据传输总量。
缓存
从 1.3.0 起,Goose 支持远程数据缓存。要查看外部文件缓存内容,请执行:
FROM goose_external_file_cache();
连接使用最佳实践
重复复用同一数据库连接时,Goose 通常性能最佳。每次查询都断开重连会产生额外开销,在大量小查询场景下会降低性能。Goose 还会在内存中缓存部分数据与元数据,最后一个连接关闭后这些缓存会丢失。多数情况下单连接效果最好,也可按需使用连接池。
使用多个连接可以并行部分操作,但通常不是必需。Goose 会尽可能在单条查询内部并行化,但并非所有场景都能并行。多连接可并发处理更多操作。在 Goose 非 CPU 瓶颈、而受网络传输等资源限制时,这通常更有帮助。
持久化表与内存表
Goose 支持轻量压缩技术。默认情况下,压缩仅作用于持久化(磁盘)数据库,不作用于内存表。
在某些场景下,这会带来反直觉结果:磁盘表查询可能比内存表更快。以下以 SF30 数据集上的 TPC-H workload Q1 为例:
CALL dbgen(sf = 30);
.timer on
PRAGMA tpch(1);
我们使用三种 Goose 启动方式运行该脚本:
| Database setup | Goose prompt | Execution time |
|---|---|---|
| In-memory DB (uncompressed) | goose | 4.22 s |
| In-memory DB (compressed) | goose -cmd "ATTACH ':memory:' AS db (COMPRESS); USE db;" | 0.55 s |
| Persistent DB (compressed) | goose tpch-sf30.db | 0.56 s |
可见压缩数据库相比未压缩内存数据库约快 8×。